diff --git a/.agent/plans/2026-07-26-syncdeck-permalinks-plan.md b/.agent/plans/2026-07-26-syncdeck-permalinks-plan.md new file mode 100644 index 0000000..05770f2 --- /dev/null +++ b/.agent/plans/2026-07-26-syncdeck-permalinks-plan.md @@ -0,0 +1,185 @@ +# Stable Presentation Permalinks Plan + +## Summary +Presentation URLs today are just their file path under `Decks/` as published to +the site root (e.g. `/CSA/Lists/lists.html`). Reorganizing folders breaks any +link built on that path, and the paths can get long. This plan adds a second, +stable link per deck: a short hash-based permalink, generated once and stored +in the deck's HTML, that survives file moves/renames because it never changes +once assigned. + +Shape of the system: +- Each deck gets a `` tag, + written once by a generator script and never hand-edited. +- The hash is derived from the deck's filename stem at generation time, with a + deterministic collision-resolution step (not from the live file path, so + moving/renaming the deck later doesn't change it). +- A committed manifest (`config/permalinks.json`) maps `hash -> current public + path`, acting as the single source of truth for collision detection and for + building redirect targets at deploy time. +- At build time, one small static redirect stub is emitted per manifest entry + at `/p/.html`. It does an immediate `location.replace()` to the deck's + real (current) URL. Confirmed with the user that ActiveBits pings the iframe + again on each `load` event, so the redirect's second `load` still produces a + successful ping against the fully-initialized deck, i.e. this permalink is + safe to feed directly into the ActiveBits launcher, not just for humans. +- CI fails the build if any deck is missing the meta tag, if the manifest has + duplicate hashes, or if the committed manifest disagrees with what's + derivable from the decks themselves (e.g. a deck moved without the manifest + being regenerated). +- The existing "Get permalink" button (which builds an ActiveBits + `permalinkPath` URL wrapping the deck's real URL) is relabeled "Syncdeck + link" to disambiguate it from the new permalink. A third button/link + surfaces the new short permalink for copying. + +## Implementation Changes + +### 1. Hash generation +- Seed string: the deck's filename stem (basename without extension) at the + time the generator script is first run against it, e.g. `lists` for + `lists.html`. +- Hash function: FNV-1a (32-bit), base36-encoded. No dependency needed: + ```js + function fnv1a(str) { + let hash = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + hash ^= str.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(36); + } + ``` +- Collision handling: if the resulting hash already exists in + `config/permalinks.json` pointing at a *different* deck, re-seed as + `${stem}#2`, `${stem}#3`, ... and rehash until the result is free. The + winning seed/hash is then fixed permanently in the deck's meta tag. + +### 2. Manifest — `config/permalinks.json` +- Committed JSON file, sibling to `config/site-map.mjs`. +- Shape: `{ "": { "path": "", "title": "" } }`. + `path` is the same public-path convention `site-indexes.mjs` already uses + (e.g. `CSA/Lists/lists.html`), so it can be turned directly into a redirect + target. +- Acts as the single source of truth for collision checks (the generator + script only needs to consult this file, not scan every deck), and doubles as + a lookup table for any future feature (analytics, search, a client-side + resolver) that wants `hash -> deck` without scanning the tree. + +### 3. Generator script — `scripts/generate-permalink.mjs` +- Usage: `node scripts/generate-permalink.mjs Decks/CSA/Lists/lists.html`. +- If the deck already has a `syncdeck-permalink` meta tag: no-op, unless the + manifest's recorded `path` for that hash no longer matches the deck's actual + location, in which case update the manifest's `path` (the hash itself never + changes on a move/rename). +- If the deck has no tag: compute the hash (with collision resolution per + above), insert the meta tag into ``, and add/update the manifest + entry. +- `--check` mode (used by CI, see below): walk all deck HTML files under + `Decks/`, and fail with a clear message if: + - any deck is missing the `syncdeck-permalink` meta tag, + - any two decks share a hash, + - the committed manifest has an entry whose `path` doesn't match the deck + that currently owns that hash (stale manifest, e.g. after a move that + didn't re-run the script). + - Exit non-zero with a message telling the author which command to run to + fix it. + +### 4. Redirect stub generation — `scripts/generate-permalink-redirects.mjs` +- Usage: `node scripts/generate-permalink-redirects.mjs .build/site` (same + invocation pattern as `generate-site-indexes.mjs`). +- Reads `config/permalinks.json` and, for each entry, writes + `.build/site/p/.html`. +- Stub content: a script-based `location.replace()` fired as early as + possible in ``, plus a `` fallback for the + no-JS case. Redirect target is computed as a path relative to `p/`, e.g. + `../CSA/Lists/lists.html`, so it works unmodified under both the local dev + server (root `/`) and the GitHub Pages project prefix + (`/Presentations/...`) without hardcoding `siteBaseUrl`. + +### 5. CI wiring +- `.github/workflows/static.yml`: add a step before "Stage publishable site": + `node scripts/generate-permalink.mjs --check` — fails the workflow (and + therefore blocks deploy) on any of the violations above. Add a step after + staging (alongside "Generate index.html"): + `node scripts/generate-permalink-redirects.mjs .build/site`. +- New `.github/workflows/permalinks-check.yml`, triggered on `pull_request`: + checkout + `node scripts/generate-permalink.mjs --check` only, no build or + deploy steps. Catches a missing/broken permalink at PR time rather than + only at merge-to-main deploy time. + +### 6. Index page changes — `scripts/site-indexes.mjs` +- Rename the existing "Get permalink" button (the one building an ActiveBits + `permalinkPath` URL) to "Syncdeck link" in the rendered markup. Internal + `data-launch="permalink"` attribute can be renamed to + `data-launch="syncdeck"` for clarity. +- Add a third action that surfaces the new short permalink: + - Read `config/permalinks.json` (or the deck's own meta tag) when building + each file entry, alongside the existing `defaultTitleForFile` lookup. + - Render a "Permalink" link/copy-button pointing at `p/.html`, + resolved relative to the current index page's folder depth using the same + `pathPrefix`/relative-`../` mechanism `renderTree` already uses for + per-folder back-links, so it resolves correctly regardless of which + folder's `index.html` is rendering it. + +### 7. Docs — `CLAUDE.md` +- Add a step to "Adding a New Presentation": after creating the deck file, + run `node scripts/generate-permalink.mjs Decks//.html` before + committing, so the deck always ships with a permalink from day one. +- Document the new meta tag and manifest file in the architecture notes table. + +## Test Plan +- Unit-style check: run `generate-permalink.mjs` against a fresh deck with no + tag, confirm meta tag + manifest entry are created and are stable on a + second run (idempotent). +- Collision test: force two decks to hash to the same value (mock/seed), + confirm the second gets a `#2`-seeded hash instead and both remain unique. +- `--check` test: manually remove a meta tag from one deck and confirm + `--check` fails with a clear message; restore it and confirm it passes. +- `--check` test: hand-edit the manifest to point a hash at the wrong path + and confirm `--check` catches the mismatch. +- Build test: run `stage-site.mjs` + `generate-permalink-redirects.mjs` and + open `.build/site/p/.html` directly in a browser, confirm it lands on + the correct deck with no console errors and correct relative asset loading. +- ActiveBits integration smoke test: launch a session using a permalink URL + (`/p/.html`) as the `presentationUrl` fed to + `launchPath`/`permalinkPath`, confirm the ping/pong handshake still + succeeds after the redirect (validates the "second `load` event" assumption + in a real ActiveBits session rather than just in principle). +- Index page smoke test: confirm "Start as instructor", "Syncdeck link", and + the new "Permalink" all resolve to working URLs from both the root index + and a nested folder index. + +## Assumptions +- ActiveBits re-arms its iframe `load` listener rather than using a one-shot + listener, so a client-side redirect still produces a ping against the final + page. Flagged for confirmation in the test plan above since ActiveBits is + external to this repo. +- Hash seed is the filename stem only (per the original request), not the + full path; the manifest, not the seed, is what's authoritative once a hash + is assigned, so this choice only affects the *first* assignment, not + stability afterward. +- No cryptographic hash is needed since collisions are actively resolved and + detected, not merely made unlikely. +- Redirect stubs are static files, not real filesystem symlinks (GitHub + Pages' static artifact upload isn't a reliable place to depend on symlink + semantics, and relative asset URLs would break under a symlink regardless, + since the browser resolves relative paths against the URL path, not the + file the symlink points to). + +## Rollout Sequence +1. Add `config/permalinks.json` (empty `{}`) and + `scripts/generate-permalink.mjs`. +2. Run the generator once across every existing deck under `Decks/` to + backfill meta tags + manifest entries for all current presentations. +3. Add `scripts/generate-permalink-redirects.mjs` and wire it into + `static.yml` alongside the existing index-generation step. +4. Add the `--check` step to `static.yml` before staging, and add the new + `permalinks-check.yml` `pull_request` workflow. +5. Update `scripts/site-indexes.mjs` (rename + new permalink button, + `data-launch="permalink"` → `data-launch="syncdeck"`) and `CLAUDE.md` + (new-deck step + architecture notes). +6. Validate a full local build (`stage-site.mjs` → + `generate-permalink-redirects.mjs` → `generate-site-indexes.mjs`) and spot + check several permalinks in a browser. +7. Commit, push, and confirm the GitHub Pages deploy succeeds with the new + CI check in place, and that a test PR triggers `permalinks-check.yml`. diff --git a/.github/workflows/permalinks-check.yml b/.github/workflows/permalinks-check.yml new file mode 100644 index 0000000..6890b5b --- /dev/null +++ b/.github/workflows/permalinks-check.yml @@ -0,0 +1,18 @@ +name: Check presentation permalinks + +on: + pull_request: + branches: ["main"] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Check permalinks + run: node scripts/generate-permalink.mjs --check diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 3ba2db9..1c7ceb4 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -53,6 +53,8 @@ jobs: cache: npm - name: Install workspace dependencies run: npm ci + - name: Check permalinks + run: node scripts/generate-permalink.mjs --check - name: Build SyncDeck runtime run: npm run build --workspace vendor/SyncDeck-Reveal - name: Stage publishable site @@ -68,6 +70,8 @@ jobs: echo ".build/site directory was not created." exit 1 fi + - name: Generate permalink redirects + run: node scripts/generate-permalink-redirects.mjs .build/site - name: Generate index.html run: node scripts/generate-site-indexes.mjs .build/site - name: Setup Pages diff --git a/CLAUDE.md b/CLAUDE.md index 0e97c12..aa1015e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,6 +173,31 @@ Full message schema: `vendor/SyncDeck-Reveal/reveal-iframe-sync-message-schema.m - Use **`px`** for all font sizes and spacing in CSS custom properties (not `em`/`clamp`/`vw`) — Reveal scales the canvas via CSS transform; `em` values double-scale - Never set `position` on `.reveal .slides > section` — Reveal needs `position: absolute` there for fade transitions; put padding/centering in a `.slide-inner` div inside each section instead 5. Check the style tokens for the chosen preset in `.agent/skills/STYLE_PRESETS_EXTENDED.md` (full library) or `.agent/skills/vendor/syncdeck/references/STYLE_PRESETS.md` (short reference). +6. Run `node scripts/generate-permalink.mjs Decks//.html` to give the new deck a stable permalink before committing. See "Presentation Permalinks" below. + +## Presentation Permalinks + +Every deck under `Decks/` carries a `` tag, +written once by `scripts/generate-permalink.mjs` and never hand-edited. The hash is derived +from the deck's filename stem when first generated (with automatic collision resolution) and +never changes afterward, even if the deck is later moved or renamed, so it's safe to share as +a stable short link. + +- `config/permalinks.json` is the committed manifest (`hash -> { path, title }`) and the single + source of truth for collision detection. +- `node scripts/generate-permalink.mjs Decks//.html` assigns a permalink to a new + deck, or re-syncs the manifest's cached path if an existing deck was moved. +- `node scripts/generate-permalink.mjs --all` does the same across every deck; safe to re-run. +- `node scripts/generate-permalink.mjs --check` (run in CI on every PR and before deploy) fails + if any deck is missing the tag, two decks share a hash, or the manifest disagrees with the + decks themselves. +- At build time, `scripts/generate-permalink-redirects.mjs` emits a static redirect stub per + manifest entry at `/p/.html` that immediately `location.replace()`s to the deck's real, + current URL — this is what makes the permalink stable across reorganization. +- The index pages (`scripts/site-indexes.mjs`) surface three links per deck: "Start as + instructor" and "Syncdeck link" (both ActiveBits-hosted, built from the deck's real current + URL) and "Permalink" (the short `/p/.html` link, safe to share or feed into the + ActiveBits launcher directly). --- @@ -188,6 +213,7 @@ Full message schema: `vendor/SyncDeck-Reveal/reveal-iframe-sync-message-schema.m | Overview → storyboard | `overview: true` in any synced state is intercepted and routed to `reveal-storyboard-set` rather than `deck.setState()`, so students see the custom strip, not Reveal's grid. | | No `chalkboard.storage` | The vendored chalkboard plugin does not write to `sessionStorage`. The host page is the source of truth (snapshot + delta buffer). Setting `storage` would cause divergence on reload. | | Role starts as `standalone` | `reveal-iframe-sync.js` always initialises in `standalone` mode. The host must send `setRole` to promote to `instructor` or `student`. Never rely on the `role` config field. | +| Never hand-edit `syncdeck-permalink` meta tag or `config/permalinks.json` | The hash is generated once by `scripts/generate-permalink.mjs` and must stay stable across moves/renames. CI (`--check`) fails if a deck's tag and the manifest disagree. | ## Code Tracing Convention diff --git a/Decks/AR1/DCCircuits/EX10_Relays.html b/Decks/AR1/DCCircuits/EX10_Relays.html index f771feb..fe43103 100644 --- a/Decks/AR1/DCCircuits/EX10_Relays.html +++ b/Decks/AR1/DCCircuits/EX10_Relays.html @@ -1,6 +1,7 @@ + Exercise 10: DC Relays diff --git a/Decks/AR1/DCCircuits/EX1_Intro_DC_Circuits.html b/Decks/AR1/DCCircuits/EX1_Intro_DC_Circuits.html index e359a59..4666779 100644 --- a/Decks/AR1/DCCircuits/EX1_Intro_DC_Circuits.html +++ b/Decks/AR1/DCCircuits/EX1_Intro_DC_Circuits.html @@ -1,6 +1,7 @@ + Electronics & DC Circuits Exercise 1 diff --git a/Decks/AR1/DCCircuits/EX2_Switches.html b/Decks/AR1/DCCircuits/EX2_Switches.html index ce394c8..3b62379 100644 --- a/Decks/AR1/DCCircuits/EX2_Switches.html +++ b/Decks/AR1/DCCircuits/EX2_Switches.html @@ -1,6 +1,7 @@ + Exercise 2: Switches diff --git a/Decks/AR1/DCCircuits/EX3_Series_and_Parallel_Circuits.html b/Decks/AR1/DCCircuits/EX3_Series_and_Parallel_Circuits.html index c1f33c0..29cd5bc 100644 --- a/Decks/AR1/DCCircuits/EX3_Series_and_Parallel_Circuits.html +++ b/Decks/AR1/DCCircuits/EX3_Series_and_Parallel_Circuits.html @@ -1,6 +1,7 @@ + Exercise 3: Series and Parallel Circuits diff --git a/Decks/AR1/DCCircuits/EX4_Voltage_Current_and_Measuring_Instruments.html b/Decks/AR1/DCCircuits/EX4_Voltage_Current_and_Measuring_Instruments.html index 8a564ae..64dc7a5 100644 --- a/Decks/AR1/DCCircuits/EX4_Voltage_Current_and_Measuring_Instruments.html +++ b/Decks/AR1/DCCircuits/EX4_Voltage_Current_and_Measuring_Instruments.html @@ -1,6 +1,7 @@ + Exercise 4: Voltage, Current, and Measuring Instruments diff --git a/Decks/AR1/DCCircuits/EX5_Resistance_and_Ohms_Law.html b/Decks/AR1/DCCircuits/EX5_Resistance_and_Ohms_Law.html index 8b4ef8b..e5087a1 100644 --- a/Decks/AR1/DCCircuits/EX5_Resistance_and_Ohms_Law.html +++ b/Decks/AR1/DCCircuits/EX5_Resistance_and_Ohms_Law.html @@ -1,6 +1,7 @@ + Exercise 5: Resistance and Ohm's Law diff --git a/Decks/AR1/DCCircuits/EX6_Series_Circuits.html b/Decks/AR1/DCCircuits/EX6_Series_Circuits.html index 211a443..5e40adb 100644 --- a/Decks/AR1/DCCircuits/EX6_Series_Circuits.html +++ b/Decks/AR1/DCCircuits/EX6_Series_Circuits.html @@ -1,6 +1,7 @@ + Exercise 6: Solving Series Circuits and Kirchhoff's Voltage Law diff --git a/Decks/AR1/DCCircuits/EX7_Parallel_and_Mixed_Circuits.html b/Decks/AR1/DCCircuits/EX7_Parallel_and_Mixed_Circuits.html index e2f5e1a..ecea18b 100644 --- a/Decks/AR1/DCCircuits/EX7_Parallel_and_Mixed_Circuits.html +++ b/Decks/AR1/DCCircuits/EX7_Parallel_and_Mixed_Circuits.html @@ -1,6 +1,7 @@ + Exercise 7: Parallel & Mixed Circuits diff --git a/Decks/AR1/DCCircuits/EX8_DC_Capacitors.html b/Decks/AR1/DCCircuits/EX8_DC_Capacitors.html index d29fb48..6f5f0a5 100644 --- a/Decks/AR1/DCCircuits/EX8_DC_Capacitors.html +++ b/Decks/AR1/DCCircuits/EX8_DC_Capacitors.html @@ -1,6 +1,7 @@ + Exercise 8: DC Capacitors diff --git a/Decks/AR1/DCCircuits/EX9_Electromagnetism.html b/Decks/AR1/DCCircuits/EX9_Electromagnetism.html index 780ec90..cdf190c 100644 --- a/Decks/AR1/DCCircuits/EX9_Electromagnetism.html +++ b/Decks/AR1/DCCircuits/EX9_Electromagnetism.html @@ -1,6 +1,7 @@ + Exercise 9: Electromagnetism diff --git a/Decks/AR1/DCCircuits/Soldering_and_Measuring_Resistance.html b/Decks/AR1/DCCircuits/Soldering_and_Measuring_Resistance.html index 5c087b0..b3b791f 100644 --- a/Decks/AR1/DCCircuits/Soldering_and_Measuring_Resistance.html +++ b/Decks/AR1/DCCircuits/Soldering_and_Measuring_Resistance.html @@ -1,6 +1,7 @@ + Soldering & Measuring Resistance diff --git a/Decks/AR1/Engineering Communication/2 - Communicating_with_Words.html b/Decks/AR1/Engineering Communication/2 - Communicating_with_Words.html index 2e558fc..fd8d9f6 100644 --- a/Decks/AR1/Engineering Communication/2 - Communicating_with_Words.html +++ b/Decks/AR1/Engineering Communication/2 - Communicating_with_Words.html @@ -1,6 +1,7 @@ + 2 - Communicating with Words diff --git a/Decks/AR1/Engineering Communication/3 - More_than_Just_Words.html b/Decks/AR1/Engineering Communication/3 - More_than_Just_Words.html index 06ba248..af2c95f 100644 --- a/Decks/AR1/Engineering Communication/3 - More_than_Just_Words.html +++ b/Decks/AR1/Engineering Communication/3 - More_than_Just_Words.html @@ -1,6 +1,7 @@ + 3 - More than Just Words diff --git a/Decks/AR1/Engineering Communication/Disruptus.html b/Decks/AR1/Engineering Communication/Disruptus.html index 113d255..64603e1 100644 --- a/Decks/AR1/Engineering Communication/Disruptus.html +++ b/Decks/AR1/Engineering Communication/Disruptus.html @@ -1,6 +1,7 @@ + Disruptus diff --git a/Decks/AR1/Engineering Communication/Intro_to_Mechatronics.html b/Decks/AR1/Engineering Communication/Intro_to_Mechatronics.html index 274a4af..4b26b06 100644 --- a/Decks/AR1/Engineering Communication/Intro_to_Mechatronics.html +++ b/Decks/AR1/Engineering Communication/Intro_to_Mechatronics.html @@ -1,6 +1,7 @@ + Intro to Mechatronics & Engineering Communication diff --git a/Decks/AR1/Final Project/FP1_Signals_and_Motion.html b/Decks/AR1/Final Project/FP1_Signals_and_Motion.html index 5e9a420..6caad0d 100644 --- a/Decks/AR1/Final Project/FP1_Signals_and_Motion.html +++ b/Decks/AR1/Final Project/FP1_Signals_and_Motion.html @@ -1,6 +1,7 @@ + 1. Signals and Motion diff --git a/Decks/AR1/Final Project/FP2_Design_and_Simulation.html b/Decks/AR1/Final Project/FP2_Design_and_Simulation.html index df32272..220696a 100644 --- a/Decks/AR1/Final Project/FP2_Design_and_Simulation.html +++ b/Decks/AR1/Final Project/FP2_Design_and_Simulation.html @@ -1,6 +1,7 @@ + 2. Design and Simulation diff --git a/Decks/AR1/Final Project/FP3_Tinkercad_Build.html b/Decks/AR1/Final Project/FP3_Tinkercad_Build.html index 9e1497f..401ca21 100644 --- a/Decks/AR1/Final Project/FP3_Tinkercad_Build.html +++ b/Decks/AR1/Final Project/FP3_Tinkercad_Build.html @@ -1,6 +1,7 @@ + 3. Capturing the Circuit diff --git a/Decks/AR1/Welcome/Welcome_2026_AR1.html b/Decks/AR1/Welcome/Welcome_2026_AR1.html index 8e12f80..1205a8c 100644 --- a/Decks/AR1/Welcome/Welcome_2026_AR1.html +++ b/Decks/AR1/Welcome/Welcome_2026_AR1.html @@ -1,6 +1,7 @@ + Welcome to Honors Robotics & Engineering Technology 1 — 2026 diff --git a/Decks/AR2/ShopSafety/Industrial_Warning_Signs.html b/Decks/AR2/ShopSafety/Industrial_Warning_Signs.html index 88f8005..0cbfdf2 100644 --- a/Decks/AR2/ShopSafety/Industrial_Warning_Signs.html +++ b/Decks/AR2/ShopSafety/Industrial_Warning_Signs.html @@ -1,6 +1,7 @@ + Industrial Warning Signs diff --git a/Decks/AR2/ShopSafety/Making_Warnings_Unnecessary.html b/Decks/AR2/ShopSafety/Making_Warnings_Unnecessary.html index c28eccd..40c291c 100644 --- a/Decks/AR2/ShopSafety/Making_Warnings_Unnecessary.html +++ b/Decks/AR2/ShopSafety/Making_Warnings_Unnecessary.html @@ -1,6 +1,7 @@ + Making Warning Signs Unnecessary diff --git a/Decks/AR2/Welcome/Welcome_2026_AR2.html b/Decks/AR2/Welcome/Welcome_2026_AR2.html index 5e967dc..f27fb3e 100644 --- a/Decks/AR2/Welcome/Welcome_2026_AR2.html +++ b/Decks/AR2/Welcome/Welcome_2026_AR2.html @@ -1,6 +1,7 @@ + Welcome to Honors Robotics & Engineering Technology 2 — 2026 diff --git a/Decks/CSA/2DArrays/2d-arrays.html b/Decks/CSA/2DArrays/2d-arrays.html index c87b3e4..2fdd9c7 100644 --- a/Decks/CSA/2DArrays/2d-arrays.html +++ b/Decks/CSA/2DArrays/2d-arrays.html @@ -1,6 +1,7 @@ + 2D Arrays — CSA diff --git a/Decks/CSA/APReview/practice-frq-scoring.html b/Decks/CSA/APReview/practice-frq-scoring.html index a77170e..445f9de 100644 --- a/Decks/CSA/APReview/practice-frq-scoring.html +++ b/Decks/CSA/APReview/practice-frq-scoring.html @@ -1,6 +1,7 @@ + Practice FRQ Scoring — AP CSA Practice Exam 1 diff --git a/Decks/CSA/Inheritance/champions-arena-intro.html b/Decks/CSA/Inheritance/champions-arena-intro.html index d127bae..d5032d9 100644 --- a/Decks/CSA/Inheritance/champions-arena-intro.html +++ b/Decks/CSA/Inheritance/champions-arena-intro.html @@ -1,6 +1,7 @@ + Champions Arena — CSA diff --git a/Decks/CSA/Inheritance/inheritance.html b/Decks/CSA/Inheritance/inheritance.html index 9a82a02..d052752 100644 --- a/Decks/CSA/Inheritance/inheritance.html +++ b/Decks/CSA/Inheritance/inheritance.html @@ -1,6 +1,7 @@ + Inheritance — CSA diff --git a/Decks/CSA/Lists/lists.html b/Decks/CSA/Lists/lists.html index 78fef08..6e4bd2e 100644 --- a/Decks/CSA/Lists/lists.html +++ b/Decks/CSA/Lists/lists.html @@ -1,6 +1,7 @@ + ArrayLists — CSA diff --git a/Decks/CSP/Algorithms/algorithm-efficiency.html b/Decks/CSP/Algorithms/algorithm-efficiency.html index dc87919..f98409f 100644 --- a/Decks/CSP/Algorithms/algorithm-efficiency.html +++ b/Decks/CSP/Algorithms/algorithm-efficiency.html @@ -1,6 +1,7 @@ + Algorithm Efficiency — AP CSP Unit 8 Lesson 2 diff --git a/Decks/CSP/Algorithms/algorithms-solve-problems.html b/Decks/CSP/Algorithms/algorithms-solve-problems.html index 0e66c0e..0bcb9e0 100644 --- a/Decks/CSP/Algorithms/algorithms-solve-problems.html +++ b/Decks/CSP/Algorithms/algorithms-solve-problems.html @@ -1,6 +1,7 @@ + Algorithms Solve Problems — AP CSP Unit 8 Lesson 1 diff --git a/Decks/CSP/Algorithms/distributed-algorithms.html b/Decks/CSP/Algorithms/distributed-algorithms.html index ae7f02a..e8f9c07 100644 --- a/Decks/CSP/Algorithms/distributed-algorithms.html +++ b/Decks/CSP/Algorithms/distributed-algorithms.html @@ -1,6 +1,7 @@ + Distributed Algorithms — AP CSP Unit 8 Lesson 5 diff --git a/Decks/CSP/Algorithms/limits-of-algorithms.html b/Decks/CSP/Algorithms/limits-of-algorithms.html index e59c396..64861f4 100644 --- a/Decks/CSP/Algorithms/limits-of-algorithms.html +++ b/Decks/CSP/Algorithms/limits-of-algorithms.html @@ -1,6 +1,7 @@ + The Limits of Algorithms — AP CSP Unit 8 Lesson 4 diff --git a/Decks/CSP/Algorithms/unreasonable-time.html b/Decks/CSP/Algorithms/unreasonable-time.html index c18207b..2fa617b 100644 --- a/Decks/CSP/Algorithms/unreasonable-time.html +++ b/Decks/CSP/Algorithms/unreasonable-time.html @@ -1,6 +1,7 @@ + Unreasonable Time — AP CSP Unit 8 Lesson 3 diff --git a/Decks/CSP/CreateTask/create-task-survival-guide.html b/Decks/CSP/CreateTask/create-task-survival-guide.html index fe32238..82ab7f2 100644 --- a/Decks/CSP/CreateTask/create-task-survival-guide.html +++ b/Decks/CSP/CreateTask/create-task-survival-guide.html @@ -1,6 +1,7 @@ + Create Task Survival Guide — AP CSP diff --git a/Decks/CSP/CreateTask/ppr-guide.html b/Decks/CSP/CreateTask/ppr-guide.html index f819079..a3276c8 100644 --- a/Decks/CSP/CreateTask/ppr-guide.html +++ b/Decks/CSP/CreateTask/ppr-guide.html @@ -1,6 +1,7 @@ + Create Task Submission Guide — AP CSP diff --git a/Decks/CSP/Cybersecurity/data-policies-privacy.html b/Decks/CSP/Cybersecurity/data-policies-privacy.html index 052d071..8ea416d 100644 --- a/Decks/CSP/Cybersecurity/data-policies-privacy.html +++ b/Decks/CSP/Cybersecurity/data-policies-privacy.html @@ -1,6 +1,7 @@ + Data Policies and Privacy — AP CSP Unit 10 Lesson 3 diff --git a/Decks/CSP/Cybersecurity/protecting-data.html b/Decks/CSP/Cybersecurity/protecting-data.html index e13af2b..679f59e 100644 --- a/Decks/CSP/Cybersecurity/protecting-data.html +++ b/Decks/CSP/Cybersecurity/protecting-data.html @@ -1,6 +1,7 @@ + Protecting Data — AP CSP Unit 10 Lessons 9–10 diff --git a/Decks/CSP/Cybersecurity/public-private-key-lab.html b/Decks/CSP/Cybersecurity/public-private-key-lab.html index f84f973..5e8052b 100644 --- a/Decks/CSP/Cybersecurity/public-private-key-lab.html +++ b/Decks/CSP/Cybersecurity/public-private-key-lab.html @@ -1,6 +1,7 @@ + Public / Private Key Lab - AP CSP Cybersecurity diff --git a/Decks/CSP/Cybersecurity/security-risks-part-1.html b/Decks/CSP/Cybersecurity/security-risks-part-1.html index c960d77..c74d56e 100644 --- a/Decks/CSP/Cybersecurity/security-risks-part-1.html +++ b/Decks/CSP/Cybersecurity/security-risks-part-1.html @@ -1,6 +1,7 @@ + Security Risks Part 1 — AP CSP Unit 10 Lesson 6 diff --git a/Decks/CSP/Cybersecurity/security-risks-part-2.html b/Decks/CSP/Cybersecurity/security-risks-part-2.html index bcb58da..934d847 100644 --- a/Decks/CSP/Cybersecurity/security-risks-part-2.html +++ b/Decks/CSP/Cybersecurity/security-risks-part-2.html @@ -1,6 +1,7 @@ + Security Risks Part 2 — AP CSP Unit 10 Lesson 7 diff --git a/Decks/CSP/Cybersecurity/value-of-privacy.html b/Decks/CSP/Cybersecurity/value-of-privacy.html index e8c6fed..55fd1a6 100644 --- a/Decks/CSP/Cybersecurity/value-of-privacy.html +++ b/Decks/CSP/Cybersecurity/value-of-privacy.html @@ -1,6 +1,7 @@ + The Value of Privacy — AP CSP Unit 10 Lesson 4 diff --git a/Decks/CSP/Unit 1 - Digital Information/1.2 - Representing Information/1.2-representing-information.html b/Decks/CSP/Unit 1 - Digital Information/1.2 - Representing Information/1.2-representing-information.html index 635ca0a..e48af43 100644 --- a/Decks/CSP/Unit 1 - Digital Information/1.2 - Representing Information/1.2-representing-information.html +++ b/Decks/CSP/Unit 1 - Digital Information/1.2 - Representing Information/1.2-representing-information.html @@ -1,6 +1,7 @@ + 1.2 Representing Information diff --git a/Decks/CSP/Unit 1 - Digital Information/1.3 - Patterns/1.3-patterns.html b/Decks/CSP/Unit 1 - Digital Information/1.3 - Patterns/1.3-patterns.html index a73dad4..19879eb 100644 --- a/Decks/CSP/Unit 1 - Digital Information/1.3 - Patterns/1.3-patterns.html +++ b/Decks/CSP/Unit 1 - Digital Information/1.3 - Patterns/1.3-patterns.html @@ -1,6 +1,7 @@ + 1.3 Patterns diff --git a/Decks/CSP/Unit 1 - Digital Information/1.4 - Binary Numbers/1.4-binary-numbers.html b/Decks/CSP/Unit 1 - Digital Information/1.4 - Binary Numbers/1.4-binary-numbers.html index 2d939a4..f32e7d6 100644 --- a/Decks/CSP/Unit 1 - Digital Information/1.4 - Binary Numbers/1.4-binary-numbers.html +++ b/Decks/CSP/Unit 1 - Digital Information/1.4 - Binary Numbers/1.4-binary-numbers.html @@ -1,6 +1,7 @@ + 1.4 Binary Numbers diff --git a/Decks/CSP/Unit 1 - Digital Information/1.6 - Representing Text/1.6-representing-text.html b/Decks/CSP/Unit 1 - Digital Information/1.6 - Representing Text/1.6-representing-text.html index 7f227e9..1468be5 100644 --- a/Decks/CSP/Unit 1 - Digital Information/1.6 - Representing Text/1.6-representing-text.html +++ b/Decks/CSP/Unit 1 - Digital Information/1.6 - Representing Text/1.6-representing-text.html @@ -1,6 +1,7 @@ + 1.6 Representing Text diff --git a/Decks/CSP/Unit 1 - Digital Information/1.7 - Black and White Images/1.7-black-and-white-images.html b/Decks/CSP/Unit 1 - Digital Information/1.7 - Black and White Images/1.7-black-and-white-images.html index e80db62..0eb46c9 100644 --- a/Decks/CSP/Unit 1 - Digital Information/1.7 - Black and White Images/1.7-black-and-white-images.html +++ b/Decks/CSP/Unit 1 - Digital Information/1.7 - Black and White Images/1.7-black-and-white-images.html @@ -1,6 +1,7 @@ + 1.7 Black and White Images diff --git a/Decks/CSP/Unit 1 - Digital Information/1.8 - Color Images/1.8-color-images.html b/Decks/CSP/Unit 1 - Digital Information/1.8 - Color Images/1.8-color-images.html index df18c35..9c3e5e3 100644 --- a/Decks/CSP/Unit 1 - Digital Information/1.8 - Color Images/1.8-color-images.html +++ b/Decks/CSP/Unit 1 - Digital Information/1.8 - Color Images/1.8-color-images.html @@ -1,6 +1,7 @@ + 1.8 - Color Images diff --git a/Decks/CSP/Welcome/welcome-2026-csp.html b/Decks/CSP/Welcome/welcome-2026-csp.html index 78d6618..2145415 100644 --- a/Decks/CSP/Welcome/welcome-2026-csp.html +++ b/Decks/CSP/Welcome/welcome-2026-csp.html @@ -1,6 +1,7 @@ + Welcome to AP CSP — 2026 diff --git a/Decks/HPy/Unit 1 - Basic Programming Constructs/1.2 - Data, Expressions, and Variables/1.2-data-expressions-and-variables.html b/Decks/HPy/Unit 1 - Basic Programming Constructs/1.2 - Data, Expressions, and Variables/1.2-data-expressions-and-variables.html index dfee773..b89e030 100644 --- a/Decks/HPy/Unit 1 - Basic Programming Constructs/1.2 - Data, Expressions, and Variables/1.2-data-expressions-and-variables.html +++ b/Decks/HPy/Unit 1 - Basic Programming Constructs/1.2 - Data, Expressions, and Variables/1.2-data-expressions-and-variables.html @@ -1,6 +1,7 @@ s + 1.2 Data, Expressions & Variables diff --git a/Decks/HPy/Unit 1 - Basic Programming Constructs/1.3 - Functions/1.3-functions.html b/Decks/HPy/Unit 1 - Basic Programming Constructs/1.3 - Functions/1.3-functions.html index b473aee..8598712 100644 --- a/Decks/HPy/Unit 1 - Basic Programming Constructs/1.3 - Functions/1.3-functions.html +++ b/Decks/HPy/Unit 1 - Basic Programming Constructs/1.3 - Functions/1.3-functions.html @@ -1,6 +1,7 @@ + 1.3 Functions diff --git a/Decks/HPy/Unit 1 - Basic Programming Constructs/1.4 - Conditionals/1.4-conditionals.html b/Decks/HPy/Unit 1 - Basic Programming Constructs/1.4 - Conditionals/1.4-conditionals.html index 291de25..16f0142 100644 --- a/Decks/HPy/Unit 1 - Basic Programming Constructs/1.4 - Conditionals/1.4-conditionals.html +++ b/Decks/HPy/Unit 1 - Basic Programming Constructs/1.4 - Conditionals/1.4-conditionals.html @@ -1,6 +1,7 @@ + 1.4 Conditionals diff --git a/Decks/HPy/Unit 2 - Loops and Strings/2.1-2.2 - Loops/2.1-2.2-loops.html b/Decks/HPy/Unit 2 - Loops and Strings/2.1-2.2 - Loops/2.1-2.2-loops.html index c1f5ef3..e011566 100644 --- a/Decks/HPy/Unit 2 - Loops and Strings/2.1-2.2 - Loops/2.1-2.2-loops.html +++ b/Decks/HPy/Unit 2 - Loops and Strings/2.1-2.2 - Loops/2.1-2.2-loops.html @@ -1,6 +1,7 @@ + 2.1-2.2 Loops diff --git a/Decks/HPy/Welcome/Welcome_2026_HPy.html b/Decks/HPy/Welcome/Welcome_2026_HPy.html index a27917c..b385367 100644 --- a/Decks/HPy/Welcome/Welcome_2026_HPy.html +++ b/Decks/HPy/Welcome/Welcome_2026_HPy.html @@ -1,6 +1,7 @@ + Welcome to Honors Applied Programming in Python — 2026 diff --git a/Decks/Testing/MobCode/mobcode-test.html b/Decks/Testing/MobCode/mobcode-test.html index bd24466..4092084 100644 --- a/Decks/Testing/MobCode/mobcode-test.html +++ b/Decks/Testing/MobCode/mobcode-test.html @@ -1,6 +1,7 @@ + MobCode — Test Deck diff --git a/Decks/Testing/StagedResonance/staged-resonance.html b/Decks/Testing/StagedResonance/staged-resonance.html index 1c7f975..0094648 100644 --- a/Decks/Testing/StagedResonance/staged-resonance.html +++ b/Decks/Testing/StagedResonance/staged-resonance.html @@ -1,6 +1,7 @@ + Staged Resonance — Test Deck diff --git a/config/permalinks.json b/config/permalinks.json new file mode 100644 index 0000000..ff659ef --- /dev/null +++ b/config/permalinks.json @@ -0,0 +1,218 @@ +{ + "10a8wd7": { + "path": "CSP/Cybersecurity/protecting-data.html", + "title": "Protecting Data — AP CSP Unit 10 Lessons 9–10" + }, + "117n6al": { + "path": "AR1/Engineering Communication/3 - More_than_Just_Words.html", + "title": "3 - More than Just Words" + }, + "117ukjj": { + "path": "AR1/DCCircuits/EX1_Intro_DC_Circuits.html", + "title": "Electronics & DC Circuits Exercise 1" + }, + "139lnms": { + "path": "AR1/DCCircuits/EX7_Parallel_and_Mixed_Circuits.html", + "title": "Exercise 7: Parallel & Mixed Circuits" + }, + "14hcbr9": { + "path": "CSP/Unit 1 - Digital Information/1.6 - Representing Text/1.6-representing-text.html", + "title": "1.6 Representing Text" + }, + "1fah8bb": { + "path": "CSP/CreateTask/create-task-survival-guide.html", + "title": "Create Task Survival Guide — AP CSP" + }, + "1fqfuco": { + "path": "CSA/2DArrays/2d-arrays.html", + "title": "2D Arrays — CSA" + }, + "1fx2vue": { + "path": "CSP/Unit 1 - Digital Information/1.7 - Black and White Images/1.7-black-and-white-images.html", + "title": "1.7 Black and White Images" + }, + "1hf273e": { + "path": "CSA/APReview/practice-frq-scoring.html", + "title": "Practice FRQ Scoring — AP CSA Practice Exam 1" + }, + "1iaz5g8": { + "path": "AR1/DCCircuits/EX10_Relays.html", + "title": "Exercise 10: DC Relays" + }, + "1iptk2g": { + "path": "CSP/CreateTask/ppr-guide.html", + "title": "Create Task Submission Guide — AP CSP" + }, + "1irhmhi": { + "path": "CSA/Inheritance/champions-arena-intro.html", + "title": "Champions Arena — CSA" + }, + "1j581ek": { + "path": "CSP/Algorithms/unreasonable-time.html", + "title": "Unreasonable Time — AP CSP Unit 8 Lesson 3" + }, + "1kn5gec": { + "path": "AR1/DCCircuits/EX4_Voltage_Current_and_Measuring_Instruments.html", + "title": "Exercise 4: Voltage, Current, and Measuring Instruments" + }, + "1lxwc0p": { + "path": "HPy/Unit 1 - Basic Programming Constructs/1.2 - Data, Expressions, and Variables/1.2-data-expressions-and-variables.html", + "title": "1.2 Data, Expressions & Variables" + }, + "1ndepug": { + "path": "AR1/Engineering Communication/Intro_to_Mechatronics.html", + "title": "Intro to Mechatronics & Engineering Communication" + }, + "1nvf3vh": { + "path": "AR2/ShopSafety/Making_Warnings_Unnecessary.html", + "title": "Making Warning Signs Unnecessary" + }, + "1qih5w7": { + "path": "CSP/Algorithms/distributed-algorithms.html", + "title": "Distributed Algorithms — AP CSP Unit 8 Lesson 5" + }, + "1tf8dje": { + "path": "AR1/DCCircuits/Soldering_and_Measuring_Resistance.html", + "title": "Soldering & Measuring Resistance" + }, + "1tiv2yn": { + "path": "CSP/Unit 1 - Digital Information/1.4 - Binary Numbers/1.4-binary-numbers.html", + "title": "1.4 Binary Numbers" + }, + "1wsmncz": { + "path": "AR1/DCCircuits/EX2_Switches.html", + "title": "Exercise 2: Switches" + }, + "1y9akbl": { + "path": "HPy/Unit 2 - Loops and Strings/2.1-2.2 - Loops/2.1-2.2-loops.html", + "title": "2.1-2.2 Loops" + }, + "1z1vuw": { + "path": "AR1/DCCircuits/EX5_Resistance_and_Ohms_Law.html", + "title": "Exercise 5: Resistance and Ohm's Law" + }, + "3hkdzz": { + "path": "CSP/Cybersecurity/data-policies-privacy.html", + "title": "Data Policies and Privacy — AP CSP Unit 10 Lesson 3" + }, + "4obyzl": { + "path": "AR1/Final Project/FP1_Signals_and_Motion.html", + "title": "1. Signals and Motion" + }, + "4w2b57": { + "path": "CSA/Inheritance/inheritance.html", + "title": "Inheritance — CSA" + }, + "79rjii": { + "path": "CSP/Cybersecurity/public-private-key-lab.html", + "title": "Public / Private Key Lab - AP CSP Cybersecurity" + }, + "7wnw27": { + "path": "CSP/Cybersecurity/security-risks-part-2.html", + "title": "Security Risks Part 2 — AP CSP Unit 10 Lesson 7" + }, + "86nhr6": { + "path": "CSP/Cybersecurity/security-risks-part-1.html", + "title": "Security Risks Part 1 — AP CSP Unit 10 Lesson 6" + }, + "a8rqfy": { + "path": "CSP/Algorithms/limits-of-algorithms.html", + "title": "The Limits of Algorithms — AP CSP Unit 8 Lesson 4" + }, + "afeu70": { + "path": "CSP/Unit 1 - Digital Information/1.2 - Representing Information/1.2-representing-information.html", + "title": "1.2 Representing Information" + }, + "bqgwsf": { + "path": "Testing/MobCode/mobcode-test.html", + "title": "MobCode — Test Deck" + }, + "bsnstb": { + "path": "CSP/Welcome/welcome-2026-csp.html", + "title": "Welcome to AP CSP — 2026" + }, + "c3twrg": { + "path": "AR1/DCCircuits/EX8_DC_Capacitors.html", + "title": "Exercise 8: DC Capacitors" + }, + "c9v2mk": { + "path": "AR2/ShopSafety/Industrial_Warning_Signs.html", + "title": "Industrial Warning Signs" + }, + "dvh3cz": { + "path": "AR1/DCCircuits/EX6_Series_Circuits.html", + "title": "Exercise 6: Solving Series Circuits and Kirchhoff's Voltage Law" + }, + "dy8mib": { + "path": "AR1/Engineering Communication/2 - Communicating_with_Words.html", + "title": "2 - Communicating with Words" + }, + "dztqvd": { + "path": "AR1/Final Project/FP3_Tinkercad_Build.html", + "title": "3. Capturing the Circuit" + }, + "e2w4yt": { + "path": "CSP/Unit 1 - Digital Information/1.8 - Color Images/1.8-color-images.html", + "title": "1.8 - Color Images" + }, + "eo325x": { + "path": "CSP/Unit 1 - Digital Information/1.3 - Patterns/1.3-patterns.html", + "title": "1.3 Patterns" + }, + "f4t2e3": { + "path": "HPy/Unit 1 - Basic Programming Constructs/1.3 - Functions/1.3-functions.html", + "title": "1.3 Functions" + }, + "fu7dkn": { + "path": "AR1/Welcome/Welcome_2026_AR1.html", + "title": "Welcome to Honors Robotics & Engineering Technology 1 — 2026" + }, + "g46z9m": { + "path": "AR2/Welcome/Welcome_2026_AR2.html", + "title": "Welcome to Honors Robotics & Engineering Technology 2 — 2026" + }, + "i0c266": { + "path": "HPy/Welcome/Welcome_2026_HPy.html", + "title": "Welcome to Honors Applied Programming in Python — 2026" + }, + "kpfojy": { + "path": "AR1/Final Project/FP2_Design_and_Simulation.html", + "title": "2. Design and Simulation" + }, + "mhd815": { + "path": "CSP/Cybersecurity/value-of-privacy.html", + "title": "The Value of Privacy — AP CSP Unit 10 Lesson 4" + }, + "oqlljt": { + "path": "AR1/DCCircuits/EX9_Electromagnetism.html", + "title": "Exercise 9: Electromagnetism" + }, + "ovfz90": { + "path": "HPy/Unit 1 - Basic Programming Constructs/1.4 - Conditionals/1.4-conditionals.html", + "title": "1.4 Conditionals" + }, + "r3nncm": { + "path": "CSA/Lists/lists.html", + "title": "ArrayLists — CSA" + }, + "r4onqe": { + "path": "Testing/StagedResonance/staged-resonance.html", + "title": "Staged Resonance — Test Deck" + }, + "rmkmzc": { + "path": "CSP/Algorithms/algorithm-efficiency.html", + "title": "Algorithm Efficiency — AP CSP Unit 8 Lesson 2" + }, + "u1fk3e": { + "path": "AR1/Engineering Communication/Disruptus.html", + "title": "Disruptus" + }, + "waswu2": { + "path": "AR1/DCCircuits/EX3_Series_and_Parallel_Circuits.html", + "title": "Exercise 3: Series and Parallel Circuits" + }, + "xtcizg": { + "path": "CSP/Algorithms/algorithms-solve-problems.html", + "title": "Algorithms Solve Problems — AP CSP Unit 8 Lesson 1" + } +} diff --git a/scripts/dev-server.mjs b/scripts/dev-server.mjs index 1f3de67..ee22199 100644 --- a/scripts/dev-server.mjs +++ b/scripts/dev-server.mjs @@ -4,7 +4,8 @@ import path from 'node:path'; import http from 'node:http'; import { fileURLToPath } from 'node:url'; -import { buildIndexPages, defaultTitleForFile } from './site-indexes.mjs'; +import { buildIndexPages, defaultTitleForFile, permalinkHashForFile } from './site-indexes.mjs'; +import { loadManifest, redirectHtml, toRedirectTarget } from './permalink-utils.mjs'; import { collectHtmlFiles, createExclusionChecker, @@ -256,14 +257,41 @@ async function main() { return; } + const permalinkMatch = pathname.match(/^\/p\/([0-9a-z]+)\.html$/i); + if (permalinkMatch) { + const manifest = await loadManifest(rootDir); + const entry = manifest[permalinkMatch[1]]; + if (entry) { + res.writeHead(200, { + 'cache-control': 'no-store', + 'content-type': 'text/html; charset=utf-8', + }); + res.end(injectLiveReload(redirectHtml(toRedirectTarget(entry.path)))); + } else { + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + res.end(`Not found: ${pathname}`); + } + return; + } + const htmlFiles = await collectHtmlFiles(rootDir, manifestRules); - const indexPages = await buildIndexPages(htmlFiles, async (publicPath) => { - const sourcePath = resolvePublicPathToSource(publicPath, mounts); - if (!sourcePath) { - return publicPath; + const indexPages = await buildIndexPages( + htmlFiles, + async (publicPath) => { + const sourcePath = resolvePublicPathToSource(publicPath, mounts); + if (!sourcePath) { + return publicPath; + } + return defaultTitleForFile(sourcePath); + }, + async (publicPath) => { + const sourcePath = resolvePublicPathToSource(publicPath, mounts); + if (!sourcePath) { + return null; + } + return permalinkHashForFile(sourcePath); } - return defaultTitleForFile(sourcePath); - }); + ); const indexKey = pathname === '/' diff --git a/scripts/generate-permalink-redirects.mjs b/scripts/generate-permalink-redirects.mjs new file mode 100644 index 0000000..58d316d --- /dev/null +++ b/scripts/generate-permalink-redirects.mjs @@ -0,0 +1,27 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { loadManifest, redirectHtml, toRedirectTarget } from './permalink-utils.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, '..'); +const siteDir = path.resolve(rootDir, process.argv[2] || '.build/site'); + +async function main() { + const manifest = await loadManifest(rootDir); + const permalinkDir = path.join(siteDir, 'p'); + await fs.mkdir(permalinkDir, { recursive: true }); + + let count = 0; + for (const [hash, entry] of Object.entries(manifest)) { + const target = toRedirectTarget(entry.path); + const outputPath = path.join(permalinkDir, `${hash}.html`); + await fs.writeFile(outputPath, redirectHtml(target), 'utf8'); + count += 1; + } + + console.log(`Generated ${count} permalink redirect(s) in ${path.relative(rootDir, permalinkDir)}/`); +} + +await main(); diff --git a/scripts/generate-permalink.mjs b/scripts/generate-permalink.mjs new file mode 100644 index 0000000..2514a73 --- /dev/null +++ b/scripts/generate-permalink.mjs @@ -0,0 +1,170 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + collectDeckFiles, + computeUniqueHash, + extractPermalinkHash, + extractTitle, + insertPermalinkTag, + loadManifest, + saveManifest, +} from './permalink-utils.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, '..'); + +function usageAndExit() { + console.error( + 'Usage:\n' + + ' node scripts/generate-permalink.mjs [ ...]\n' + + ' node scripts/generate-permalink.mjs --all\n' + + ' node scripts/generate-permalink.mjs --check' + ); + process.exit(1); +} + +function stemFor(publicPath) { + return path.posix.basename(publicPath, path.posix.extname(publicPath)); +} + +// Idempotent: safe to re-run against a deck that already has a permalink. +// Assigns a new hash only when the deck has none yet; otherwise just keeps +// the manifest's cached path/title in sync with the deck's current location. +async function ensurePermalink(deck, manifest) { + const html = await fs.readFile(deck.absolutePath, 'utf8'); + const existingHash = extractPermalinkHash(html); + const title = extractTitle(html) || stemFor(deck.publicPath); + + if (existingHash) { + const entry = manifest[existingHash]; + if (!entry || entry.path !== deck.publicPath || entry.title !== title) { + manifest[existingHash] = { path: deck.publicPath, title }; + console.log(`Synced manifest for existing permalink ${existingHash} -> ${deck.publicPath}`); + } + return; + } + + const stem = stemFor(deck.publicPath); + const hash = computeUniqueHash(stem, manifest, { excludePath: deck.publicPath }); + const updatedHtml = insertPermalinkTag(html, hash); + await fs.writeFile(deck.absolutePath, updatedHtml, 'utf8'); + manifest[hash] = { path: deck.publicPath, title }; + console.log(`Assigned permalink ${hash} -> ${deck.publicPath}`); +} + +async function runAdd(targetPaths) { + const manifest = await loadManifest(rootDir); + + const decks = targetPaths.map((targetPath) => { + const absolutePath = path.resolve(process.cwd(), targetPath); + const relFromRoot = path.relative(rootDir, absolutePath).split(path.sep).join('/'); + if (!relFromRoot.startsWith('Decks/')) { + console.error(`Not a deck under Decks/: ${targetPath}`); + process.exit(1); + } + return { publicPath: relFromRoot.slice('Decks/'.length), absolutePath }; + }); + + for (const deck of decks) { + await ensurePermalink(deck, manifest); + } + + await saveManifest(rootDir, manifest); +} + +async function runAll() { + const manifest = await loadManifest(rootDir); + const decks = await collectDeckFiles(rootDir); + + for (const deck of decks) { + await ensurePermalink(deck, manifest); + } + + await saveManifest(rootDir, manifest); +} + +async function runCheck() { + const manifest = await loadManifest(rootDir); + const decks = await collectDeckFiles(rootDir); + const failures = []; + const hashToDecks = new Map(); + + for (const deck of decks) { + const html = await fs.readFile(deck.absolutePath, 'utf8'); + const hash = extractPermalinkHash(html); + + if (!hash) { + failures.push( + `Missing syncdeck-permalink meta tag: ${deck.publicPath}\n` + + ` Fix: node scripts/generate-permalink.mjs Decks/${deck.publicPath}` + ); + continue; + } + + if (!hashToDecks.has(hash)) { + hashToDecks.set(hash, []); + } + hashToDecks.get(hash).push(deck.publicPath); + + const entry = manifest[hash]; + if (!entry) { + failures.push( + `Deck has permalink "${hash}" but no manifest entry: ${deck.publicPath}\n` + + ` Fix: node scripts/generate-permalink.mjs --all` + ); + } else if (entry.path !== deck.publicPath) { + failures.push( + `Manifest entry for "${hash}" points at "${entry.path}" but that deck is now at ` + + `"${deck.publicPath}"\n` + + ` Fix: node scripts/generate-permalink.mjs --all` + ); + } + } + + for (const [hash, paths] of hashToDecks) { + if (paths.length > 1) { + failures.push(`Duplicate permalink hash "${hash}" used by: ${paths.join(', ')}`); + } + } + + for (const hash of Object.keys(manifest)) { + const entry = manifest[hash]; + const deckExists = decks.some((deck) => deck.publicPath === entry.path); + if (!deckExists) { + failures.push( + `Manifest entry "${hash}" points at "${entry.path}", which no longer exists.\n` + + ` Fix: remove the entry from config/permalinks.json if the deck was deleted intentionally.` + ); + } + } + + if (failures.length) { + console.error(`Permalink check failed with ${failures.length} issue(s):\n`); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); + } + + console.log(`Permalink check passed for ${decks.length} deck(s).`); +} + +async function main() { + const args = process.argv.slice(2); + if (args.length === 0) { + usageAndExit(); + } + if (args.includes('--check')) { + await runCheck(); + return; + } + if (args.includes('--all')) { + await runAll(); + return; + } + await runAdd(args); +} + +await main(); diff --git a/scripts/generate-site-indexes.mjs b/scripts/generate-site-indexes.mjs index d7c9861..14de74c 100644 --- a/scripts/generate-site-indexes.mjs +++ b/scripts/generate-site-indexes.mjs @@ -2,7 +2,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { buildIndexPages, defaultTitleForFile } from './site-indexes.mjs'; +import { buildIndexPages, defaultTitleForFile, permalinkHashForFile } from './site-indexes.mjs'; import { createExclusionChecker, loadManifestRules } from './site-utils.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -41,9 +41,11 @@ async function main() { const isExcludedFromIndex = createExclusionChecker(rootDir, manifestRules); const htmlFiles = (await collectHtmlFiles(siteDir)) .filter((relPath) => !isExcludedFromIndex(relPath, false)); - const pages = await buildIndexPages(htmlFiles, async (publicPath) => { - return defaultTitleForFile(path.join(siteDir, publicPath)); - }); + const pages = await buildIndexPages( + htmlFiles, + async (publicPath) => defaultTitleForFile(path.join(siteDir, publicPath)), + async (publicPath) => permalinkHashForFile(path.join(siteDir, publicPath)) + ); for (const [relativePath, html] of pages) { const outputPath = path.join(siteDir, relativePath); diff --git a/scripts/permalink-utils.mjs b/scripts/permalink-utils.mjs new file mode 100644 index 0000000..6f5f94a --- /dev/null +++ b/scripts/permalink-utils.mjs @@ -0,0 +1,140 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export const manifestRelativePath = 'config/permalinks.json'; + +const PERMALINK_META_NAME = 'syncdeck-permalink'; +const PERMALINK_META_RE = new RegExp( + ``, + 'i' +); +const HEAD_OPEN_RE = /]*>/i; +const TITLE_RE = /(.*?)<\/title>/is; + +export function fnv1aHash(str) { + let hash = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + hash ^= str.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(36); +} + +// Seeded from the deck's filename stem, incrementing the seed on collision +// against the manifest until a free hash is found. `excludePath` lets a +// deck that already owns a hash re-derive the same hash without tripping +// over its own manifest entry. +export function computeUniqueHash(stem, manifest, { excludePath } = {}) { + let attempt = 1; + let seed = stem; + for (;;) { + const hash = fnv1aHash(seed); + const existing = manifest[hash]; + if (!existing || existing.path === excludePath) { + return hash; + } + attempt += 1; + seed = `${stem}#${attempt}`; + } +} + +export function extractPermalinkHash(html) { + const match = html.match(PERMALINK_META_RE); + return match ? match[1] : null; +} + +export function insertPermalinkTag(html, hash) { + if (!HEAD_OPEN_RE.test(html)) { + throw new Error('No <head> tag found'); + } + const tag = `<meta name="${PERMALINK_META_NAME}" content="${hash}">`; + return html.replace(HEAD_OPEN_RE, (match) => `${match}\n${tag}`); +} + +export function extractTitle(html) { + const match = html.match(TITLE_RE); + if (!match) { + return null; + } + const value = match[1].replace(/\s+/g, ' ').trim(); + return value || null; +} + +export async function loadManifest(rootDir) { + const manifestPath = path.join(rootDir, manifestRelativePath); + try { + const raw = await fs.readFile(manifestPath, 'utf8'); + return JSON.parse(raw); + } catch (err) { + if (err.code === 'ENOENT') { + return {}; + } + throw err; + } +} + +export async function saveManifest(rootDir, manifest) { + const manifestPath = path.join(rootDir, manifestRelativePath); + const sorted = {}; + for (const key of Object.keys(manifest).sort()) { + sorted[key] = manifest[key]; + } + await fs.writeFile(manifestPath, JSON.stringify(sorted, null, 2) + '\n', 'utf8'); +} + +// Walks Decks/ directly (source files, not the staged/published site) since +// the generator needs to edit the actual deck HTML in place. +// Percent-encode each path segment so the target is safe to embed in both +// a JS string literal and an HTML attribute, and resolve it relative to +// p/<hash>.html so it works unmodified under the GitHub Pages project +// prefix and the local dev server alike. +export function toRedirectTarget(publicPath) { + return '../' + publicPath.split('/').map(encodeURIComponent).join('/'); +} + +export function redirectHtml(target) { + return `<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<title>Redirecting... + + + + +

Redirecting to the presentation...

+ + +`; +} + +export async function collectDeckFiles(rootDir) { + const decksRoot = path.join(rootDir, 'Decks'); + const results = []; + + async function walk(relDir) { + const absDir = path.join(decksRoot, relDir); + const entries = await fs.readdir(absDir, { withFileTypes: true }); + for (const entry of entries) { + const rel = relDir ? path.posix.join(relDir, entry.name) : entry.name; + if (entry.isDirectory()) { + await walk(rel); + continue; + } + if ( + entry.isFile() && + rel.toLowerCase().endsWith('.html') && + path.posix.basename(rel) !== 'index.html' + ) { + results.push({ + publicPath: rel, + absolutePath: path.join(decksRoot, rel), + }); + } + } + } + + await walk(''); + results.sort((a, b) => a.publicPath.localeCompare(b.publicPath)); + return results; +} diff --git a/scripts/site-indexes.mjs b/scripts/site-indexes.mjs index a3eb83d..7475bbb 100644 --- a/scripts/site-indexes.mjs +++ b/scripts/site-indexes.mjs @@ -2,6 +2,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { syncDeckHosting } from '../config/site-map.mjs'; +import { extractPermalinkHash } from './permalink-utils.mjs'; const NATURAL_COLLATOR = new Intl.Collator(undefined, { numeric: true, @@ -132,10 +133,10 @@ export const PAGE_STYLE = ` .file-actions a { color: inherit; } .file-actions a:hover { color: var(--accent); text-decoration: underline; } .file-actions .sep { color: #3d537e; } - .file-actions button.copy-url { + .tree button.copy-url { display: inline-flex; align-items: center; - color: inherit; + color: var(--muted); background: none; border: none; padding: 2px; @@ -144,12 +145,13 @@ export const PAGE_STYLE = ` border-radius: 4px; cursor: pointer; } - .file-actions button.copy-url svg { width: 14px; height: 14px; display: block; } - .file-actions button.copy-url:hover { color: var(--accent); } - .file-actions button.copy-url:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; } - .file-actions button.copy-url.copied { color: #7cd992; } - .file-actions button.copy-url.failed { color: #ff8a80; } - .file-actions button.copy-url:disabled { cursor: default; } + .tree button.copy-url svg { width: 14px; height: 14px; display: block; } + .tree button.copy-url:hover { color: var(--accent); } + .tree button.copy-url:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; } + .tree button.copy-url.copied { color: #7cd992; } + .tree button.copy-url.failed { color: #ff8a80; } + .tree button.copy-url:disabled { cursor: default; } + .tree button.copy-url-labeled { gap: 4px; } @media (max-width: 640px) { .tree li.file { flex-direction: column; @@ -201,9 +203,10 @@ export const PAGE_SCRIPT = ` document.querySelectorAll('a[data-launch-path]').forEach((a) => { const presentationUrl = resolveUrl(a.getAttribute('data-launch-path') || ''); - if (a.getAttribute('data-launch') === 'instructor') { + const launchMode = a.getAttribute('data-launch'); + if (launchMode === 'instructor') { a.href = buildActiveBitsUrl(launchPath, presentationUrl, { mode: 'instructor' }); - } else { + } else if (launchMode === 'syncdeck') { a.href = buildActiveBitsUrl(permalinkPath, presentationUrl); } }); @@ -277,6 +280,15 @@ export async function defaultTitleForFile(filePath) { return path.basename(filePath, path.extname(filePath)).replace(/[-_]/g, ' ').trim(); } +export async function permalinkHashForFile(filePath) { + try { + const text = await fs.readFile(filePath, 'utf8'); + return extractPermalinkHash(text); + } catch { + return null; + } +} + function makePage(titleText, heading, description, listing, generatedAt, generatedAtIso, backLink = null) { const backHtml = backLink ? `

← Back

` @@ -307,19 +319,31 @@ ${listing} `; } -function renderTree(node, indent = ' ', pathPrefix = '') { +function renderTree(node, indent = ' ', pathPrefix = '', pageDepth = 0) { const lines = []; + // pageDepth is how many directories the *page being rendered* sits below + // the site root (constant across this recursion), not how deep `pathPrefix` + // has nested within that single page's listing — those are different axes. + const rootPrefix = '../'.repeat(pageDepth); for (const file of [...node.files].sort((a, b) => NATURAL_COLLATOR.compare(a.title, b.title))) { const relativeHref = `${pathPrefix}${file.name}`; + const permalinkHref = file.permalinkHash ? `${rootPrefix}p/${file.permalinkHash}.html` : null; + // The syncdeck link is fed to ActiveBits as presentationUrl, so route it + // through our own stable permalink when one exists rather than the raw + // (reorg-fragile) path. + const syncdeckLaunchPath = permalinkHref || relativeHref; lines.push( `${indent}
  • ${escapeHtml(file.title)}` + + `` + `` + `Start as instructor` + `·` + - `Get permalink` + - `·` + - `` + + `Syncdeck link` + + (permalinkHref + ? `·` + + `` + : '') + `
  • ` ); } @@ -332,7 +356,7 @@ function renderTree(node, indent = ' ', pathPrefix = '') { `${escapeHtml(dirname)}/` ); lines.push(`${indent}
      `); - lines.push(...renderTree(child, indent + ' ', `${pathPrefix}${dirname}/`)); + lines.push(...renderTree(child, indent + ' ', `${pathPrefix}${dirname}/`, pageDepth)); lines.push(`${indent}
    `); lines.push(`${indent} `); lines.push(`${indent}`); @@ -341,7 +365,7 @@ function renderTree(node, indent = ' ', pathPrefix = '') { return lines; } -export async function buildIndexPages(htmlFiles, getTitleForPublicPath) { +export async function buildIndexPages(htmlFiles, getTitleForPublicPath, getPermalinkForPublicPath) { const generatedAtDt = new Date(); const generatedAt = generatedAtDt.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC'); const generatedAtIso = generatedAtDt.toISOString(); @@ -356,6 +380,7 @@ export async function buildIndexPages(htmlFiles, getTitleForPublicPath) { titledEntries.push({ rel, title: await getTitleForPublicPath(rel), + permalinkHash: getPermalinkForPublicPath ? await getPermalinkForPublicPath(rel) : null, }); } @@ -376,6 +401,7 @@ export async function buildIndexPages(htmlFiles, getTitleForPublicPath) { name: parsed.base, rel: relPath, title: entry.title, + permalinkHash: entry.permalinkHash, }); } @@ -401,7 +427,8 @@ export async function buildIndexPages(htmlFiles, getTitleForPublicPath) { function addFolderPages(node, folder = '.') { for (const [dirname, child] of [...node.dirs.entries()].sort((a, b) => NATURAL_COLLATOR.compare(a[0], b[0]))) { const childFolder = folder === '.' ? dirname : `${folder}/${dirname}`; - const listingLines = renderTree(child); + const pageDepth = childFolder.split('/').filter(Boolean).length; + const listingLines = renderTree(child, ' ', '', pageDepth); const listing = listingLines.length ? listingLines.join('\n') : '
  • No presentations found.
  • ';