max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
58 files changed,
+2279 insertions,
-1732 deletions
| @@ -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/ |
| @@ -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 %} |