Skip to main content

max / makenotwork

server: restructure the frontend into a typed core lib + custom-element islands Reverse the no-build-step rule and pull ~7.8k lines of untested, module-less vanilla JS toward a typed, tested architecture. One build-time dependency (typescript); tsc typechecks and emits browser ESM to static/dist, run from build.rs (non-fatal — degrades to the existing tree when Node is absent). Core: port static/mnw.js (the data-action dispatcher + primitives — escaping, CSRF fetch, toasts, storage, clipboard, loading, tabs, keyboard, restart banner, HTMX glue) into frontend/src/core, loaded as one module in place of mnw.js. The dispatcher resolves a typed register() first, then a window bridge so the not-yet-migrated static/*.js keep working. mnw.js deleted. Islands: custom elements that enhance server markup (auto-upgrade on HTMX swap, no manual re-init). <mnw-carousel>, <mnw-media-player> (the 7x-inlined gapless-timeline offset math extracted to one tested module), and <mnw-image-uploader> (unifies the byte-identical wizard image uploaders behind a typed S3 primitive + presignPutConfirm). Heavy islands load per-page via a build.rs-generated _island.html macro; carousel.js / media-player.js and the two wizard uploaders deleted. Gates: tsc + node --test (25 tests) + a frontend_globals seal that ratchets the window.* count down (115 -> 113).
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-08 01:46 UTC
Signed with PGP, not checked
Commit: ace9caa63805a8bbcca886bf994b3d77fdf5e9c6
Parent: 446b939
58 files changed, +2279 insertions, -1732 deletions
M .gitignore +6 -1
@@ -2,6 +2,10 @@
2 2 /target
3 3 **/target
4 4
5 + # Frontend (regenerable — tsc output + installed deps; rebuilt by build.rs)
6 + server/static/dist/
7 + server/frontend/node_modules/
8 +
5 9 # Environment files (contain secrets)
6 10 server/.env
7 11 server/.env.local
@@ -31,8 +35,9 @@
31 35 # SQLx offline mode cache
32 36 .sqlx/
33 37
34 - # Generated template partial (build.rs output)
38 + # Generated template partials (build.rs output)
35 39 server/templates/_head_assets.html
40 + server/templates/_island.html
36 41
37 42 # Generated rustdoc output
38 43 server/rustdoc-out/
M server/build.rs +93 -6
@@ -18,6 +18,9 @@
18 18 // Only re-run when HEAD changes
19 19 println!("cargo::rerun-if-changed=.git/HEAD");
20 20
21 + // Compile the TypeScript frontend to static/dist/ (best-effort — see fn).
22 + build_frontend();
23 +
21 24 // --- Static asset fingerprinting ---
22 25 // Hash the content of key static files to produce a version suffix.
23 26 // When any watched file changes, URLs in templates get a new ?v= param,
@@ -37,6 +40,10 @@
37 40 content.hash(&mut hasher);
38 41 }
39 42 }
43 + // Fold the emitted frontend bundles into the version so `?v=` busts when
44 + // the TypeScript changes. Read-only: the inputs under frontend/src are the
45 + // watched trigger (in build_frontend); watching the outputs would loop.
46 + hash_dir_js(Path::new("static/dist"), &mut hasher);
40 47 let static_hash = format!("{:016x}", hasher.finish());
41 48 let version = &static_hash[..8];
42 49
@@ -48,16 +55,96 @@
48 55 <link rel="stylesheet" href="/static/style.css?v={v}">
49 56 <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
50 57 <script src="/static/htmx.min.js"></script>
51 - <script src="/static/upload.js?v={v}"></script>"#,
58 + <script src="/static/upload.js?v={v}"></script>
59 + <script type="module" src="/static/dist/core/index.js?v={v}"></script>"#,
52 60 v = version,
53 61 );
54 62
55 - let out_path = Path::new("templates/_head_assets.html");
56 - // Only write if content changed (avoids unnecessary recompilation)
57 - let needs_write = fs::read_to_string(out_path)
58 - .map(|existing| existing != partial)
63 + write_if_changed(Path::new("templates/_head_assets.html"), &partial);
64 +
65 + // Per-page island loader macro. Heavy/page-specific islands (media player,
66 + // uploader, …) load on the pages that use them via
67 + // `{% import "_island.html" as island %}{% call island::island("name") %}`,
68 + // cache-busted by the same content hash as the head assets.
69 + let island_partial = r#"{% macro island(name) -%}
70 + <script type="module" src="/static/dist/islands/{{ name }}.js?v=__VER__"></script>
71 + {%- endmacro %}
72 + "#
73 + .replace("__VER__", version);
74 + write_if_changed(Path::new("templates/_island.html"), &island_partial);
75 + }
76 +
77 + /// Write `contents` to `path` only if it differs, to avoid needless rebuilds.
78 + fn write_if_changed(path: &Path, contents: &str) {
79 + let needs_write = fs::read_to_string(path)
80 + .map(|existing| existing != contents)
59 81 .unwrap_or(true);
60 82 if needs_write {
61 - fs::write(out_path, &partial).expect("failed to write _head_assets.html");
83 + fs::write(path, contents).unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
84 + }
85 + }
86 +
87 + /// Compile the TypeScript frontend (`frontend/`) to browser ESM in
88 + /// `static/dist/` via `npm run build` (which runs `tsc`).
89 + ///
90 + /// Best-effort and non-fatal: a missing `node_modules` (deps not installed),
91 + /// an absent Node, or a compile error only emits a `cargo::warning` and leaves
92 + /// the Rust build to succeed against whatever `static/dist/` already holds.
93 + /// Dependency install (`npm install` in `frontend/`) is a manual/deploy step,
94 + /// not build.rs's job. Set `MNW_SKIP_FRONTEND_BUILD=1` to opt out entirely.
95 + ///
96 + /// Prereq for the deploy pipeline once a template references `static/dist/`:
97 + /// the build host must have Node and have run `npm install` in `frontend/`.
98 + fn build_frontend() {
99 + // Re-run the whole build script when the TS sources or its config change.
100 + println!("cargo::rerun-if-changed=frontend/src");
101 + println!("cargo::rerun-if-changed=frontend/package.json");
102 + println!("cargo::rerun-if-changed=frontend/tsconfig.json");
103 +
104 + if std::env::var_os("MNW_SKIP_FRONTEND_BUILD").is_some() {
105 + println!("cargo::warning=frontend build skipped (MNW_SKIP_FRONTEND_BUILD set)");
106 + return;
107 + }
108 + if !Path::new("frontend/node_modules").is_dir() {
109 + println!(
110 + "cargo::warning=frontend/node_modules missing — run `npm install` in server/frontend; \
111 + skipping TypeScript build (serving existing static/dist)"
112 + );
113 + return;
114 + }
115 + match Command::new("npm")
116 + .args(["run", "build"])
117 + .current_dir("frontend")
118 + .status()
119 + {
120 + Ok(s) if s.success() => {}
121 + Ok(s) => println!(
122 + "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist",
123 + s.code()
124 + ),
125 + Err(e) => println!(
126 + "cargo::warning=could not run npm for the frontend build ({e}); is Node installed? \
127 + skipping (serving existing static/dist)"
128 + ),
129 + }
130 + }
131 +
132 + /// Recursively hash every `.js` file under `dir` into `hasher`, in a
133 + /// deterministic order. Missing directory is a no-op (first build before the
134 + /// frontend has been compiled).
135 + fn hash_dir_js(dir: &Path, hasher: &mut DefaultHasher) {
136 + let Ok(entries) = fs::read_dir(dir) else {
137 + return;
138 + };
139 + let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
140 + paths.sort();
141 + for path in paths {
142 + if path.is_dir() {
143 + hash_dir_js(&path, hasher);
144 + } else if path.extension().and_then(|e| e.to_str()) == Some("js") {
145 + if let Ok(content) = fs::read(&path) {
146 + content.hash(hasher);
147 + }
148 + }
62 149 }
63 150 }
@@ -1,11 +1,11 @@
1 1 // Delegated event handlers extracted from inline on* attributes in the
2 2 // dashboard, dashboard editor, embed, and wizard templates, moved here for CSP
3 3 // compliance (so script-src can drop 'unsafe-inline'). The dispatcher in
4 - // mnw.js invokes each of these by name with `this` bound to the dispatching
4 + // the core module invokes each of these by name with `this` bound to the dispatching
5 5 // element and any data-arg/data-arg2 attributes passed as arguments. Bodies
6 6 // are preserved verbatim from the original inline handlers.
7 7
8 - // setActiveTab is defined in mnw.js and takes the clicked button as its
8 + // setActiveTab is defined in the core module and takes the clicked button as its
9 9 // argument; the inline handlers called setActiveTab(this), so wrap it.
10 10 window.onSetActiveTab = function () {
11 11 setActiveTab(this);
@@ -1,7 +1,7 @@
1 1 // Delegated handlers extracted from inline on* attributes for CSP compliance.
2 2 // These are the named wrappers referenced by data-action / data-change /
3 3 // data-input / data-submit in the pages templates. The action dispatcher in
4 - // mnw.js invokes each with `this` bound to the dispatching element, so the
4 + // the core module invokes each with `this` bound to the dispatching element, so the
5 5 // bodies read args from the element (this.value, this.dataset, this itself)
6 6 // exactly as the original inline handlers did.
7 7
@@ -4,7 +4,7 @@
4 4 * Delegated event handlers extracted from inline on* attributes and
5 5 * href="javascript:" links in templates/partials/*.html, for CSP compliance
6 6 * (lets script-src drop 'unsafe-inline'). The delegated dispatcher lives in
7 - * static/mnw.js; it looks these up by name as globals and invokes them with
7 + * static/the core module; it looks these up by name as globals and invokes them with
8 8 * `this` bound to the dispatching element and args from data-arg/data-arg2.
9 9 *
10 10 * Each function preserves the original inline behavior verbatim.
@@ -1,7 +1,7 @@
1 1 // actions-tabs.js
2 2 // Delegated handler functions extracted from inline on* attributes in
3 3 // templates/partials/tabs/**, for CSP compliance (no script-src 'unsafe-inline').
4 - // The dispatcher in mnw.js invokes each named function with `this` === the
4 + // The dispatcher in the core module invokes each named function with `this` === the
5 5 // dispatching element and args drawn from data-arg / data-arg2. Bodies are the
6 6 // verbatim former inline handlers, adjusted only to read args from the element.
7 7
@@ -174,7 +174,7 @@
174 174 };
175 175
176 176 // --- Non-dispatcher delegated listeners ---
177 - // The mnw.js dispatcher only covers click/change/input/submit. The handlers
177 + // The the core module dispatcher only covers click/change/input/submit. The handlers
178 178 // below cover drag-and-drop (user_media) and conditional submit (user_account),
179 179 // keyed on data attributes so the inline handlers can be removed.
180 180
@@ -3,7 +3,7 @@
3 3 *
4 4 * Full page (not HTMX partial), no re-init needed.
5 5 * Reads project/post data from data attributes on #blog-editor.
6 - * Depends on: mnw.js (csrfHeaders).
6 + * Depends on: the core module (csrfHeaders).
7 7 */
8 8 (function() {
9 9 var editor = document.getElementById('blog-editor');
@@ -27,7 +27,7 @@
27 27
28 28 // Wire the play/seek controls without inline on* handlers (the embed page runs
29 29 // under the same CSP that forbids script-src 'unsafe-inline'). This page loads
30 - // only this file, not mnw.js's data-action dispatcher, so bind directly.
30 + // only this file, not the core module's data-action dispatcher, so bind directly.
31 31 document.addEventListener('DOMContentLoaded', function () {
32 32 var play = document.getElementById('play');
33 33 if (play) play.addEventListener('click', togglePlay);
@@ -3,7 +3,7 @@
3 3 *
4 4 * Loaded once in dashboard-item.html. Re-initializes on HTMX tab swap.
5 5 * Reads item ID from data-item-id on the container element.
6 - * Depends on: upload.js (S3Uploader, initDropzone), mnw.js (csrfHeaders, showToast).
6 + * Depends on: upload.js (S3Uploader, initDropzone), the core module (csrfHeaders, showToast).
7 7 */
8 8 (function() {
9 9 function init() {
@@ -3,7 +3,7 @@
3 3 (function() {
4 4 'use strict';
5 5
6 - // csrfHeaders() is the global from mnw.js (loaded first in base.html); it
6 + // csrfHeaders() is the global from the core module (loaded first in base.html); it
7 7 // reads the csrf-token meta live on each call, which matters because the
8 8 // token rotates mid-session. Don't shadow it with a local copy.
9 9
@@ -6367,7 +6367,10 @@
6367 6367 outline-offset: 2px;
6368 6368 }
6369 6369
6370 - .tier-option.is-selected {
6370 + /* Selected tier — driven purely by the checked radio (no JS toggling a
6371 + class). The `.is-selected` alias is retained for any server-rendered use. */
6372 + .tier-option.is-selected,
6373 + .tier-option:has(input[type="radio"]:checked) {
6371 6374 border-color: var(--action);
6372 6375 border-width: 2px;
6373 6376 padding: calc(1rem - 1px);
@@ -11284,6 +11287,12 @@
11284 11287 font-weight: bold;
11285 11288 }
11286 11289
11290 + /* <mnw-carousel> is a custom element (default display:inline); make it lay out
11291 + like the block container it replaced. */
11292 + mnw-carousel {
11293 + display: block;
11294 + }
11295 +
11287 11296 .carousel {
11288 11297 position: relative;
11289 11298 margin: 0 auto;
@@ -44,10 +44,11 @@
44 44 <!-- Toast notification container -->
45 45 <div id="notifications" class="toast-container" role="alert" aria-live="polite"></div>
46 46
47 - <script src="/static/mnw.js?v=0701"></script>
48 47 <!-- Delegated on* handlers, extracted from inline attributes so the CSP can
49 - drop script-src 'unsafe-inline'. Loaded globally after mnw.js (which
50 - defines the data-action dispatcher). -->
48 + drop script-src 'unsafe-inline'. The data-action dispatcher + core
49 + primitives now live in the typed core module (frontend/src/core),
50 + loaded as an ES module in <head> via _head_assets.html; these classic
51 + shims resolve through its registry / window bridge. -->
51 52 <script src="/static/actions-pages.js?v=0701"></script>
52 53 <script src="/static/actions-partials.js?v=0701"></script>
53 54 <script src="/static/actions-tabs.js?v=0701"></script>
@@ -224,6 +224,5 @@
224 224 {% endblock %}
225 225
226 226 {% block scripts %}
227 - <script src="/static/carousel.js?v=0605"></script>
228 227 <script src="/static/page-index.js?v=0623" defer></script>
229 228 {% endblock %}