From fc0f95f086d3d8530f0ab540aa5fc39150c7c294 Mon Sep 17 00:00:00 2001 From: Brian Dahlem Date: Sun, 26 Jul 2026 13:36:59 +0000 Subject: [PATCH 1/4] Add plan for stable hash-based presentation permalinks Deck URLs currently break on reorganization since they're just the file path. This plan adds a generated, stable per-deck permalink (meta tag + committed manifest) that survives moves/renames. --- .../2026-07-26-syncdeck-permalinks-plan.md | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 .agent/plans/2026-07-26-syncdeck-permalinks-plan.md 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`. From 7102b49a3c85bfdb5cd94fd9ed91d32b735954b1 Mon Sep 17 00:00:00 2001 From: Brian Dahlem Date: Sun, 26 Jul 2026 13:53:13 +0000 Subject: [PATCH 2/4] Add stable hash-based permalink generation for presentations Deck URLs today are just the file path, so reorganizing folders breaks any link built on them. Adds scripts/generate-permalink.mjs (assigns a per-deck hash, stored in a meta tag, with a committed manifest for collision detection), scripts/generate-permalink-redirects.mjs (emits a /p/.html redirect stub per deck at build time), and wires both into static.yml plus a new pull_request-triggered permalinks-check.yml so a missing/stale permalink fails CI. Also teaches the local dev server to resolve permalinks the same way the production build does. --- .github/workflows/permalinks-check.yml | 18 ++ .github/workflows/static.yml | 4 + config/permalinks.json | 218 +++++++++++++++++++++++ scripts/dev-server.mjs | 42 ++++- scripts/generate-permalink-redirects.mjs | 27 +++ scripts/generate-permalink.mjs | 170 ++++++++++++++++++ scripts/permalink-utils.mjs | 140 +++++++++++++++ 7 files changed, 612 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/permalinks-check.yml create mode 100644 config/permalinks.json create mode 100644 scripts/generate-permalink-redirects.mjs create mode 100644 scripts/generate-permalink.mjs create mode 100644 scripts/permalink-utils.mjs 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/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/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; +} From 4d25f35056d9a7e4172d230a482cb9b0088c2863 Mon Sep 17 00:00:00 2001 From: Brian Dahlem Date: Sun, 26 Jul 2026 13:53:24 +0000 Subject: [PATCH 3/4] Backfill syncdeck-permalink meta tags across all existing decks Ran scripts/generate-permalink.mjs --all once so every current presentation has a stable permalink from day one, matching the manifest committed in the previous commit. --- Decks/AR1/DCCircuits/EX10_Relays.html | 1 + Decks/AR1/DCCircuits/EX1_Intro_DC_Circuits.html | 1 + Decks/AR1/DCCircuits/EX2_Switches.html | 1 + Decks/AR1/DCCircuits/EX3_Series_and_Parallel_Circuits.html | 1 + .../EX4_Voltage_Current_and_Measuring_Instruments.html | 1 + Decks/AR1/DCCircuits/EX5_Resistance_and_Ohms_Law.html | 1 + Decks/AR1/DCCircuits/EX6_Series_Circuits.html | 1 + Decks/AR1/DCCircuits/EX7_Parallel_and_Mixed_Circuits.html | 1 + Decks/AR1/DCCircuits/EX8_DC_Capacitors.html | 1 + Decks/AR1/DCCircuits/EX9_Electromagnetism.html | 1 + Decks/AR1/DCCircuits/Soldering_and_Measuring_Resistance.html | 1 + .../Engineering Communication/2 - Communicating_with_Words.html | 1 + .../AR1/Engineering Communication/3 - More_than_Just_Words.html | 1 + Decks/AR1/Engineering Communication/Disruptus.html | 1 + Decks/AR1/Engineering Communication/Intro_to_Mechatronics.html | 1 + Decks/AR1/Final Project/FP1_Signals_and_Motion.html | 1 + Decks/AR1/Final Project/FP2_Design_and_Simulation.html | 1 + Decks/AR1/Final Project/FP3_Tinkercad_Build.html | 1 + Decks/AR1/Welcome/Welcome_2026_AR1.html | 1 + Decks/AR2/ShopSafety/Industrial_Warning_Signs.html | 1 + Decks/AR2/ShopSafety/Making_Warnings_Unnecessary.html | 1 + Decks/AR2/Welcome/Welcome_2026_AR2.html | 1 + Decks/CSA/2DArrays/2d-arrays.html | 1 + Decks/CSA/APReview/practice-frq-scoring.html | 1 + Decks/CSA/Inheritance/champions-arena-intro.html | 1 + Decks/CSA/Inheritance/inheritance.html | 1 + Decks/CSA/Lists/lists.html | 1 + Decks/CSP/Algorithms/algorithm-efficiency.html | 1 + Decks/CSP/Algorithms/algorithms-solve-problems.html | 1 + Decks/CSP/Algorithms/distributed-algorithms.html | 1 + Decks/CSP/Algorithms/limits-of-algorithms.html | 1 + Decks/CSP/Algorithms/unreasonable-time.html | 1 + Decks/CSP/CreateTask/create-task-survival-guide.html | 1 + Decks/CSP/CreateTask/ppr-guide.html | 1 + Decks/CSP/Cybersecurity/data-policies-privacy.html | 1 + Decks/CSP/Cybersecurity/protecting-data.html | 1 + Decks/CSP/Cybersecurity/public-private-key-lab.html | 1 + Decks/CSP/Cybersecurity/security-risks-part-1.html | 1 + Decks/CSP/Cybersecurity/security-risks-part-2.html | 1 + Decks/CSP/Cybersecurity/value-of-privacy.html | 1 + .../1.2-representing-information.html | 1 + .../1.3 - Patterns/1.3-patterns.html | 1 + .../1.4 - Binary Numbers/1.4-binary-numbers.html | 1 + .../1.6 - Representing Text/1.6-representing-text.html | 1 + .../1.7 - Black and White Images/1.7-black-and-white-images.html | 1 + .../1.8 - Color Images/1.8-color-images.html | 1 + Decks/CSP/Welcome/welcome-2026-csp.html | 1 + .../1.2-data-expressions-and-variables.html | 1 + .../1.3 - Functions/1.3-functions.html | 1 + .../1.4 - Conditionals/1.4-conditionals.html | 1 + .../2.1-2.2 - Loops/2.1-2.2-loops.html | 1 + Decks/HPy/Welcome/Welcome_2026_HPy.html | 1 + Decks/Testing/MobCode/mobcode-test.html | 1 + Decks/Testing/StagedResonance/staged-resonance.html | 1 + 54 files changed, 54 insertions(+) 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 From 1b97a9e1d6a349696a24fe5673b30c0a9d804a22 Mon Sep 17 00:00:00 2001 From: Brian Dahlem Date: Sun, 26 Jul 2026 13:53:36 +0000 Subject: [PATCH 4/4] Surface permalinks on index pages and rename the ActiveBits permalink Renames the old "Get permalink" action to "Syncdeck link" for clarity now that there are two kinds of permalink, and routes it through our own stable /p/.html link (rather than the raw, reorg-fragile deck path) when one exists. Moves the raw-URL copy button next to the deck title to reduce clutter, and adds a single labeled "Permalink" copy button in the right-aligned actions for the new short link. Documents the new system in CLAUDE.md with a new-deck step. --- CLAUDE.md | 26 +++++++++++++ scripts/generate-site-indexes.mjs | 10 +++-- scripts/site-indexes.mjs | 61 ++++++++++++++++++++++--------- 3 files changed, 76 insertions(+), 21 deletions(-) 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/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/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.
  • ';