From 5d4b37c36ae2a83ce0bb6d2b7c91d43a6d1f3cda Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Thu, 15 Oct 2026 23:02:51 -0400 Subject: [PATCH 1/3] feat(motion): chapters in video exports and on the site's player --- CHANGELOG.md | 1 + dev/test/motion.test.mjs | 16 ++++- dev/test/site.test.mjs | 9 ++- docs/scripts.md | 2 +- examples/releases-weekly-digests/motion.mp4 | Bin 907678 -> 908838 bytes examples/releases-weekly-digests/motion.vtt | 33 +++++++++ examples/sunny-walkthrough-videos/motion.mp4 | Bin 851345 -> 852446 bytes examples/sunny-walkthrough-videos/motion.vtt | 29 ++++++++ examples/uploads-before-after/motion.mp4 | Bin 754706 -> 755841 bytes examples/uploads-before-after/motion.vtt | 29 ++++++++ site/build.mjs | 71 ++++++++++++++++++- skills/buildnotes/references/motion.md | 2 +- skills/buildnotes/scripts/export.mjs | 23 ++++-- skills/buildnotes/scripts/motion.mjs | 36 ++++++++-- 14 files changed, 235 insertions(+), 16 deletions(-) create mode 100644 examples/releases-weekly-digests/motion.vtt create mode 100644 examples/sunny-walkthrough-videos/motion.vtt create mode 100644 examples/uploads-before-after/motion.vtt diff --git a/CHANGELOG.md b/CHANGELOG.md index 891e77e..522f660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Until the first release, changes are recorded under [Unreleased]. ### Added +- Video chapters: a whole-cut MP4 or WebM from `motion.mjs` carries one chapter per scene, named by its label (scenes in a row with the same label make one), and `-motion.vtt` beside it holds the same chapters. `export.mjs` takes them as `--chapters `. The example videos carry them, and the site's example pages draw them under the player as a segmented bar and a list that jump the video. Tests in `dev/test/motion.test.mjs` and `dev/test/site.test.mjs`. - Brands: a per-repo `brand.json` next to `house-style.md` (colours by role in light and dark, display, body and mono fonts with a stylesheet, and a logo) that the post, the motion cut and the deck follow. `scripts/brand.mjs` checks it (unknown keys, hex colours, and a contrast warning below 4.5:1), derives the roles it implies, and writes it into a page as a style block after the template's own, so pages without a brand are unchanged. `viz.mjs build`, `motion.mjs` and `deck.mjs` apply it; `motion.json` takes `"brand"` overrides, and `--brand`/`--no-brand` pick another file or none. `workdir.mjs` prints its path. Docs in `references/visuals.md#brand`, `motion.md`, `deck.md` and `SKILL.md`; tests in `dev/test/brand.test.mjs`. - The buildnotes skill (named build-story until 2026-10-15): `SKILL.md` and the references in `references/` (brief, evidence, story, visuals, modules, craft). It turns issues, pull requests, commits, reviews, and screenshots into a post, in three flavors (release story, build story, and technical deep dive), and it says when a change is only a note. - `scripts/gather.mjs`, which writes `dossier.md` and `dossier.json` from `git` and `gh`: issues, pull requests, commits, review threads, nearby work, and prior work. diff --git a/dev/test/motion.test.mjs b/dev/test/motion.test.mjs index 1827506..fdb88f8 100644 --- a/dev/test/motion.test.mjs +++ b/dev/test/motion.test.mjs @@ -6,7 +6,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from import { tmpdir } from "node:os"; import { join, dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { check, build, clipSprite, exportPlan, imageSize, readingTime } from "../../skills/buildnotes/scripts/motion.mjs"; +import { check, build, chaptersVtt, clipSprite, exportPlan, imageSize, readingTime } from "../../skills/buildnotes/scripts/motion.mjs"; import { spawnSync } from "node:child_process"; import { chromePath, openPage } from "../../skills/buildnotes/scripts/chrome.mjs"; @@ -112,6 +112,20 @@ test("a clip is one scene: it starts where the scene starts and stops before its assert.throws(() => exportPlan(cut, { size: 20 }), /a width from 160/); }); +test("the whole reel has a chapter per scene, named by its label, starting where the scene starts", () => { + const { cut } = check(example, base); + const { chapters, seconds } = exportPlan(cut); + const starts = cut.scenes.map((d, i) => Math.round(cut.scenes.slice(0, i).reduce((a, x) => a + x.dur, 0) * 10) / 10); + // Scenes 5 and 6 share "02 · What counts as impact": one chapter, starting at the first. + assert.equal(chapters.length, cut.scenes.length - 1); + assert.deepEqual(chapters.map((c) => c.start), starts.filter((_, i) => i !== 5)); + assert.deepEqual(chapters.map((c) => c.title), cut.scenes.filter((_, i) => i !== 5).map((d) => d.label)); + assert.equal(exportPlan(cut, { scene: 2 }).chapters, undefined, "a one-scene clip has no chapters"); + const vtt = chaptersVtt(chapters, seconds); + assert.match(vtt, /^WEBVTT\n\n1\n00:00:00\.000 --> /); + assert.ok(vtt.trimEnd().endsWith(`--> 00:00:${seconds.toFixed(3)}\nTry it`), "the last cue ends with the video"); +}); + test("the page embeds the images, carries the script safely and drops the authoring notes", () => { const { cut, images } = check({ ...example, title: "Ends here & now" }, base); const html = build(cut, images); diff --git a/dev/test/site.test.mjs b/dev/test/site.test.mjs index bd680e5..537de5f 100644 --- a/dev/test/site.test.mjs +++ b/dev/test/site.test.mjs @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { build, SHARE, EXAMPLES, wrapPost, footer, exampleCard, siteFonts } from "../../site/build.mjs"; +import { build, SHARE, EXAMPLES, videoChapters, wrapPost, footer, exampleCard, siteFonts } from "../../site/build.mjs"; import { drawing, NAMES } from "../../site/iso.mjs"; test("the site builds: markers filled, files copied, every command copyable", () => { @@ -42,6 +42,13 @@ test("the site builds: markers filled, files copied, every command copyable", () assert.match(post, /
/); // the video sits under the headline, and the rail lists the examples and every chapter assert.match(post, /<\/header>\s*
"); if (mast < 0) throw new Error(`examples/${slug}: no to put the video under`); + const marks = videoChapters(slug); const watch = ` `; body = body.slice(0, mast + 9) + watch + body.slice(mast + 9); @@ -202,6 +238,34 @@ ${footer("../../")} for (const a of links) { const t = document.getElementById(a.hash.slice(1)); if (t) io.observe(t); } if (matchMedia("(prefers-reduced-motion: reduce)").matches) document.querySelector(".site-watch video")?.pause(); })(); +// The video's chapters: fill each segment as far as the video has played, mark the chapter playing, and jump on a click. +(() => { + const video = document.querySelector(".site-watch video"); + const nav = document.querySelector(".site-chapters"); + if (!video || !nav) return; + const segs = [...nav.querySelectorAll(".site-scrub button")]; + const items = [...nav.querySelectorAll("ol button")]; + const starts = items.map((b) => Number(b.dataset.at)); + let current = -1; + const draw = () => { + const t = video.currentTime; + const end = (i) => starts[i + 1] ?? (video.duration || starts[i] + 1); + segs.forEach((s, i) => s.firstChild.style.setProperty("--played", Math.min(100, Math.max(0, ((t - starts[i]) / (end(i) - starts[i])) * 100)) + "%")); + const on = starts.findLastIndex((a) => a <= t + 0.05); + if (on !== current) { items[current]?.removeAttribute("aria-current"); items[on]?.setAttribute("aria-current", "true"); current = on; } + }; + let raf = 0; + const tick = () => { draw(); raf = video.paused ? 0 : requestAnimationFrame(tick); }; + video.addEventListener("play", () => { if (!raf) raf = requestAnimationFrame(tick); }); + for (const e of ["seeked", "timeupdate", "loadedmetadata"]) video.addEventListener(e, draw); + nav.addEventListener("click", (e) => { + const b = e.target.closest("button[data-at]"); + if (!b) return; + video.currentTime = Number(b.dataset.at); + if (video.paused) video.play().catch(() => {}); + }); + draw(); +})(); @@ -394,6 +458,7 @@ export function build({ out = join(here, "dist"), url } = {}) { for (const ex of EXAMPLES) { files.set(`examples/${ex.slug}/index.html`, join(root, "examples", ex.slug, ex.file)); files.set(`examples/${ex.slug}/motion.mp4`, join(root, "examples", ex.slug, "motion.mp4")); + if (videoChapters(ex.slug).length) files.set(`examples/${ex.slug}/motion.vtt`, join(root, "examples", ex.slug, "motion.vtt")); if (ex.deck) for (const f of ["deck.html", "deck.pdf"]) files.set(`examples/${ex.slug}/${f}`, join(root, "examples", ex.slug, f)); files.set(exampleCard(ex), join(here, exampleCard(ex))); // its link-preview card } diff --git a/skills/buildnotes/references/motion.md b/skills/buildnotes/references/motion.md index 89d31c2..f0d17d1 100644 --- a/skills/buildnotes/references/motion.md +++ b/skills/buildnotes/references/motion.md @@ -101,7 +101,7 @@ node /scripts/motion.mjs /motion.json --gif --scene 5 --siz Building writes `motion.html` with the images embedded (a `clip`'s footage becomes one sheet of frames, which needs `ffmpeg`), then opens it in headless Chrome to check that every scene's text fits the frame. A headline shrinks to fit before it is reported; a reported scene needs fewer words. -`--mp4` (or `--webm`, or `--gif` at half size) writes `/export/-motion.mp4` at 1920×1080 and 30 fps. `` is the work dir's name without its date (`walkthrough-videos`), or the cut's title when the dir has a generic name; `--name ` sets it, so exports dragged out of the folder still say which story they belong to. `--poster` writes a still from the end of the title scene, for a thumbnail; `--at ` picks another moment. The video opens on that same frame, held for one frame, because GitHub and most players show the first frame before play and ignore a poster; the MP4 also carries it as cover art for Finder and QuickTime. `--theme dark` exports the dark theme. Export needs Chrome and `ffmpeg` and takes a couple of minutes for a one-minute cut. +`--mp4` (or `--webm`, or `--gif` at half size) writes `/export/-motion.mp4` at 1920×1080 and 30 fps. `` is the work dir's name without its date (`walkthrough-videos`), or the cut's title when the dir has a generic name; `--name ` sets it, so exports dragged out of the folder still say which story they belong to. `--poster` writes a still from the end of the title scene, for a thumbnail; `--at ` picks another moment. The video opens on that same frame, held for one frame, because GitHub and most players show the first frame before play and ignore a poster; the MP4 also carries it as cover art for Finder and QuickTime. A whole-reel MP4 or WebM carries one chapter per scene, named by its `label` (else its headline), so QuickTime, VLC and IINA can jump between them; scenes in a row with the same label make one chapter. The same chapters go to `-motion.vtt` beside it, for a web page's ``: browsers' own video controls don't show chapters, so a page that wants them draws them from that file. `--theme dark` exports the dark theme. Export needs Chrome and `ffmpeg` and takes a couple of minutes for a one-minute cut. `--scene ` exports one scene alone, counted from 1, as `export/-motion-scene-.`. It stops just before the scene fades out, so a GIF loops on the scene. A clip is the right size for a README, a reply in a thread or a changelog entry; the `morph` and `flow` scenes make the best ones. `--size ` sets the width (default 1920, or 960 for a GIF): `--gif --scene 5 --size 720` gives a clip of a few hundred kilobytes. With `--poster`, `--scene` gives that scene's last frame. diff --git a/skills/buildnotes/scripts/export.mjs b/skills/buildnotes/scripts/export.mjs index 1e7f9a9..52c0cff 100644 --- a/skills/buildnotes/scripts/export.mjs +++ b/skills/buildnotes/scripts/export.mjs @@ -29,6 +29,8 @@ // --pad page background around the figure; default 16 // --transparent for --svg: no background rectangle // --frames for GIF and video: keep the PNG frames next to the output +// --chapters for video: a JSON list of { "start": , "title" } from the start of the loop, written into +// the file as chapters (QuickTime, VLC and IINA list them; browsers' own controls don't) // // GIF and video need ffmpeg. Frames are stepped, not recorded: SVG and CSS motion is paused and moved // to each frame's time, and the page's scripts run on a virtual clock (requestAnimationFrame, @@ -38,7 +40,7 @@ // --seconds. Needs Chrome (set CHROME_PATH if it isn't found). import { spawnSync } from "node:child_process"; -import { copyFileSync, mkdirSync, writeFileSync, mkdtempSync, rmSync, existsSync, renameSync } from "node:fs"; +import { copyFileSync, mkdirSync, readFileSync, writeFileSync, mkdtempSync, rmSync, existsSync, renameSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -178,12 +180,25 @@ try { copyFileSync(name(cover), name(0)); } const input = ["-framerate", String(fps), "-i", join(frames, "frame-%04d.png")]; - const art = cover ? ["-i", join(frames, "cover.png"), "-map", "0", "-map", "1", "-c:v:1", "png", "-disposition:v:1", "attached_pic"] : []; + // Chapters go in as an ffmetadata file, each one ending where the next starts. + let marks = []; + let toc = []; + if (opt("chapters") && format !== "gif") { + const list = JSON.parse(readFileSync(opt("chapters"), "utf8")).filter((c) => c.start >= 0 && c.start < seconds).sort((a, b) => a.start - b.start); + const esc = (t) => String(t).replace(/[=;#\\\n]/g, (m) => `\\${m}`); + const meta = list.map((c, i) => `[CHAPTER]\nTIMEBASE=1/1000\nSTART=${Math.round(c.start * 1000)}\nEND=${Math.round((list[i + 1]?.start ?? seconds) * 1000)}\ntitle=${esc(c.title)}\n`).join(""); + writeFileSync(join(frames, "chapters.txt"), `;FFMETADATA1\n${meta}`); + marks = ["-f", "ffmetadata", "-i", join(frames, "chapters.txt")]; + toc = ["-map_chapters", "1"]; + } + // The cover art is the next input after the frames and any chapters; inputs come before output options. + const pic = toc.length ? 2 : 1; + const art = cover ? ["-i", join(frames, "cover.png"), "-map", "0", "-map", String(pic), "-c:v:1", "png", "-disposition:v:1", "attached_pic"] : []; const even = "pad=ceil(iw/2)*2:ceil(ih/2)*2"; const args = { gif: [...input, "-vf", "split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle", "-loop", "0"], - mp4: [...input, ...art, "-filter:v:0", even, "-c:v:0", "libx264", "-pix_fmt:v:0", "yuv420p", "-crf", "18", "-movflags", "+faststart"], - webm: [...input, "-vf", even, "-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "32", "-pix_fmt", "yuv420p"], + mp4: [...input, ...marks, ...art, ...toc, "-filter:v:0", even, "-c:v:0", "libx264", "-pix_fmt:v:0", "yuv420p", "-crf", "18", "-movflags", "+faststart"], + webm: [...input, ...marks, ...toc, "-vf", even, "-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "32", "-pix_fmt", "yuv420p"], }[format]; const run = spawnSync("ffmpeg", ["-y", "-loglevel", "error", ...args, out], { encoding: "utf8" }); if (run.status !== 0) { rmSync(frames, { recursive: true, force: true }); await done(1, `ffmpeg failed: ${run.stderr.trim().split("\n").pop()}`); } diff --git a/skills/buildnotes/scripts/motion.mjs b/skills/buildnotes/scripts/motion.mjs index 002fdfa..e0a64a4 100644 --- a/skills/buildnotes/scripts/motion.mjs +++ b/skills/buildnotes/scripts/motion.mjs @@ -32,7 +32,7 @@ // in. Video is 1920×1080: export.mjs steps the stage frame by frame on a virtual clock, so it needs Chrome // and ffmpeg. Warnings go to stderr. -import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { spawnSync } from "node:child_process"; import { basename, dirname, extname, join, resolve } from "node:path"; @@ -258,12 +258,13 @@ export function build(cut, images, { brand = null, template = readFileSync(TEMPL } // What to export: the whole reel, or one scene as a clip; the clip's place on the timeline, and the scale -// that gives the asked-for width. Scene numbers count from 1. +// that gives the asked-for width. Scene numbers count from 1. The whole reel also gets chapters, one per +// scene, named by its label (else its headline), which a video carries for the players that show them. export function exportPlan(cut, { scene, size, gif = false } = {}) { const width = Number(size ?? (gif ? 960 : 1920)); if (!(width >= 160 && width <= 3840)) throw new Error(`--size ${size}: a width from 160 to 3840`); const plan = { scale: (EXPORT.scale * width) / 1920, from: 0, seconds: cut.total, at: round(cut.scenes[0].dur - 0.5), suffix: "" }; - if (scene == null) return plan; + if (scene == null) return { ...plan, chapters: chapters(cut) }; const n = Number(scene); if (!Number.isInteger(n) || n < 1 || n > cut.scenes.length) throw new Error(`--scene ${scene}: a scene from 1 to ${cut.scenes.length}`); const from = cut.scenes.slice(0, n - 1).reduce((a, d) => a + d.dur, 0); @@ -272,6 +273,25 @@ export function exportPlan(cut, { scene, size, gif = false } = {}) { return { ...plan, from: round(from), seconds, at: round(from + seconds - 0.1), suffix: `-scene-${n}` }; } +// Scenes in a row with the same label (a chapter told in two scenes) make one chapter. +export function chapters(cut) { + const list = []; + let at = 0; + for (const d of cut.scenes) { + const title = String(d.label || d.headline || d.kind).replace(/\*/g, "").trim(); + if (list.at(-1)?.title !== title) list.push({ start: round(at), title }); + at += d.dur; + } + return list; +} + +// The chapters as WebVTT, for a beside the video: each cue ends where the next starts. +export function chaptersVtt(list, total) { + const ts = (t) => new Date(Math.round(t * 1000)).toISOString().slice(11, 23); + const text = (t) => t.replace(/&/g, "&").replace(//g, ">"); + return `WEBVTT\n\n${list.map((c, i) => `${i + 1}\n${ts(c.start)} --> ${ts(list[i + 1]?.start ?? total)}\n${text(c.title)}\n`).join("\n")}`; +} + // A clip's footage as one sprite sheet: its frames from "from" to "to" at its own rate up to 15 a second, at most // 640 px wide, tiled left to right and top to bottom, in one JPEG. Returns the sheet's file and how to step through it. export function clipSprite(d) { @@ -350,8 +370,9 @@ async function main(argv) { let plan; try { plan = exportPlan(cut, { scene: opt("scene"), size: set("size"), gif: video === "gif" }); } catch (e) { console.error(`error: ${e.message}`); return 2; } const stem = exportStem({ name: set("name"), dir: base, title: cut.title }); + const outFor = (fmt) => join(dirname(page), "export", exportName(stem, `motion${plan.suffix}`, fmt)); const exportOne = (fmt, extra) => { - const out = join(dirname(page), "export", exportName(stem, `motion${plan.suffix}`, fmt)); + const out = outFor(fmt); const args = [join(here, "export.mjs"), page, "--select", "#stage", `--${fmt}`, "--width", String(EXPORT.width), "--pad", "0", "--theme", theme, "--out", out, ...extra]; const r = spawnSync(process.execPath, args, { stdio: ["ignore", "inherit", "inherit"] }); return r.status ?? 1; @@ -359,8 +380,13 @@ async function main(argv) { if (video) { // A video opens on the poster frame, since GitHub and most players show frame 0 before play. // A GIF at full size is tens of megabytes; half size at 15 fps keeps it shareable. - const status = exportOne(video, ["--scale", String(plan.scale), "--fps", String(set("fps") ?? (video === "gif" ? 15 : 30)), "--from", String(plan.from), "--seconds", String(plan.seconds), ...(video === "gif" ? [] : ["--cover", opt("at") ?? String(plan.at)])]); + const marks = plan.chapters && video !== "gif" ? join(mkdtempSync(join(tmpdir(), "buildnotes-chapters-")), "chapters.json") : null; + if (marks) writeFileSync(marks, JSON.stringify(plan.chapters)); + const status = exportOne(video, ["--scale", String(plan.scale), "--fps", String(set("fps") ?? (video === "gif" ? 15 : 30)), "--from", String(plan.from), "--seconds", String(plan.seconds), ...(video === "gif" ? [] : ["--cover", opt("at") ?? String(plan.at)]), ...(marks ? ["--chapters", marks] : [])]); + if (marks) rmSync(dirname(marks), { recursive: true, force: true }); if (status) return status; + // The same chapters as WebVTT next to the video, for a web player to list and jump between. + if (marks) { writeFileSync(outFor("vtt"), chaptersVtt(plan.chapters, plan.seconds)); console.log(`wrote ${outFor("vtt")} (${plan.chapters.length} chapters)`); } } if (has("poster")) { const status = exportOne("png", ["--scale", String(plan.scale), "--at", opt("at") ?? String(plan.at)]); From b0268bae2978444187cbf3dad21a31e5f38ccb2d Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Thu, 15 Oct 2026 23:14:20 -0400 Subject: [PATCH 2/3] feat(site): own player controls with a chapter-segmented scrubber --- .gitattributes | 1 + CHANGELOG.md | 3 +- dev/test/site.test.mjs | 8 +- site/build.mjs | 232 ++++++++++++++++++++++++++++++++++------- 4 files changed, 204 insertions(+), 40 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/CHANGELOG.md b/CHANGELOG.md index 522f660..9be59f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ Until the first release, changes are recorded under [Unreleased]. ### Added -- Video chapters: a whole-cut MP4 or WebM from `motion.mjs` carries one chapter per scene, named by its label (scenes in a row with the same label make one), and `-motion.vtt` beside it holds the same chapters. `export.mjs` takes them as `--chapters `. The example videos carry them, and the site's example pages draw them under the player as a segmented bar and a list that jump the video. Tests in `dev/test/motion.test.mjs` and `dev/test/site.test.mjs`. +- `.gitattributes`: shell scripts (`*.sh`) check out with LF line endings on every platform. +- Video chapters: a whole-cut MP4 or WebM from `motion.mjs` carries one chapter per scene, named by its label (scenes in a row with the same label make one), and `-motion.vtt` beside it holds the same chapters. `export.mjs` takes them as `--chapters `. The example videos carry them, and the site's example pages play it in their own player: its scrubber has a segment per chapter (click, drag, arrow keys, Page Up and Page Down by chapter) with the chapter's name on hover, plus play, mute and full screen, and the chapters are listed under it. Without JavaScript the browser's own controls stay. Tests in `dev/test/motion.test.mjs` and `dev/test/site.test.mjs`. - Brands: a per-repo `brand.json` next to `house-style.md` (colours by role in light and dark, display, body and mono fonts with a stylesheet, and a logo) that the post, the motion cut and the deck follow. `scripts/brand.mjs` checks it (unknown keys, hex colours, and a contrast warning below 4.5:1), derives the roles it implies, and writes it into a page as a style block after the template's own, so pages without a brand are unchanged. `viz.mjs build`, `motion.mjs` and `deck.mjs` apply it; `motion.json` takes `"brand"` overrides, and `--brand`/`--no-brand` pick another file or none. `workdir.mjs` prints its path. Docs in `references/visuals.md#brand`, `motion.md`, `deck.md` and `SKILL.md`; tests in `dev/test/brand.test.mjs`. - The buildnotes skill (named build-story until 2026-10-15): `SKILL.md` and the references in `references/` (brief, evidence, story, visuals, modules, craft). It turns issues, pull requests, commits, reviews, and screenshots into a post, in three flavors (release story, build story, and technical deep dive), and it says when a change is only a note. - `scripts/gather.mjs`, which writes `dossier.md` and `dossier.json` from `git` and `gh`: issues, pull requests, commits, review threads, nearby work, and prior work. diff --git a/dev/test/site.test.mjs b/dev/test/site.test.mjs index 537de5f..53c7738 100644 --- a/dev/test/site.test.mjs +++ b/dev/test/site.test.mjs @@ -41,13 +41,17 @@ test("the site builds: markers filled, files copied, every command copyable", () assert.equal(post.match(//g).length, 1); assert.match(post, /<header class="site-bar site-top">/); // the video sits under the headline, and the rail lists the examples and every chapter - assert.match(post, /<\/header>\s*<aside class="site-bar site-watch" id="site-video"[^>]*>\s*<video src="motion\.mp4"/); + assert.match(post, /<\/header>\s*<aside class="site-bar site-watch" id="site-video"[^>]*>\s*<div class="site-player"[^>]*>\s*<video src="motion\.mp4"[^>]* controls /); // its chapters: a track for the player, and a segment and a list entry per cue in motion.vtt assert.ok(files.includes(`examples/${ex.slug}/motion.vtt`), ex.slug); assert.match(post, /<track kind="chapters" src="motion\.vtt"/); const cues = videoChapters(ex.slug).length; assert.ok(cues >= 5, ex.slug); - assert.equal(post.match(/<div class="site-scrub"[\s\S]*?<\/div>/)[0].match(/data-at=/g).length, cues, ex.slug); + // the player's own controls (shown by its script; the browser's stay without it): a scrubber with a segment + // per chapter, play, mute and full screen, and the chapters listed under the player + assert.match(post, /<div class="site-controls" hidden>/); + assert.equal(post.match(/<div class="site-scrub" role="slider"[\s\S]*?<\/div>/)[0].match(/class="sp-seg"/g).length, cues, ex.slug); + for (const b of ["sp-play", "sp-mute", "sp-full"]) assert.match(post, new RegExp(`<button type="button" class="${b}" aria-label="[^"]+">`), b); assert.equal(post.match(/<nav class="site-chapters"[\s\S]*?<\/nav>/)[0].match(/<li>/g).length, cues, ex.slug); assert.match(post, new RegExp(`<a href="\\.\\./${ex.slug}/" aria-current="page">`), ex.slug); assert.equal((post.match(/<nav class="site-bar site-rail"[\s\S]*?<\/nav>/)?.[0].match(/href="#c\d+"/g) ?? []).length, (post.match(/<section class="ch" id="c\d+">/g) ?? []).length, ex.slug); diff --git a/site/build.mjs b/site/build.mjs index 23c7f4c..3cd9720 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -19,8 +19,9 @@ // for the Artifact viewer, which wraps them in a document, so the build wraps each one itself (doctype, // charset, viewport) and adds a site shell around it: a bar naming the example with a way back, and a // footer with the example's video, its plan, and the other examples. The video's chapters come from the -// motion.vtt beside it, shown as a segmented bar and a list under the player. examples/index.html lists -// them all, built from site/src/examples.html with the home page's stylesheet. Nothing to install; Node 22 or later. +// motion.vtt beside it: the player has its own controls, with a segment per chapter on its scrubber, and +// lists them under it. examples/index.html lists them all, built from site/src/examples.html with the home +// page's stylesheet. Nothing to install; Node 22 or later. import { readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync, existsSync, readdirSync } from "node:fs"; import { dirname, join, resolve, basename } from "node:path"; @@ -96,18 +97,37 @@ const SHELL_CSS = ` /* The video, in a box under the post's headline. */ .site-watch { display: grid; gap: 0; border: 1px solid #e3e3dd; border-radius: 6px; background: #f5f5f1; padding: 6px; } .site-watch video { width: 100%; aspect-ratio: 16 / 9; background: #000; border-radius: 3px; display: block; } -/* The chapters: a bar of segments (one per chapter, the gaps are the chapter marks) and a list under it. */ -.site-chapters { padding: 10px 6px 2px; } -.site-scrub { display: flex; gap: 3px; height: 14px; align-items: center; } -.site-scrub button { flex: 1 1 0; min-width: 4px; height: 100%; padding: 0; border: 0; background: none; cursor: pointer; display: flex; align-items: center; } -.site-scrub i { display: block; width: 100%; height: 4px; border-radius: 2px; background: linear-gradient(#1f1f1d, #1f1f1d) no-repeat 0 0 / var(--played, 0%) 100%, #dcdcd5; transition: height 120ms ease; } -.site-scrub button:hover i { height: 8px; } -.site-chapters ol { list-style: none; margin: 10px 0 0; padding: 0; columns: 2 15rem; column-gap: 16px; } +/* The player: the video with its own controls (the page's script swaps out the browser's), whose scrubber is + one segment per chapter, and the chapters as a list under it. */ +.site-player { position: relative; border-radius: 3px; overflow: hidden; background: #000; } +.site-player video { border-radius: 0; } +.site-player:fullscreen { display: flex; align-items: center; border-radius: 0; } +.site-player:fullscreen video { height: 100%; aspect-ratio: auto; object-fit: contain; } +.site-controls { position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center; gap: 10px; padding: 32px 12px 8px; background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.62)); color: #fff; transition: opacity 200ms ease; } +.site-controls[hidden] { display: none; } +.site-player.sp-idle .site-controls { opacity: 0; } +.site-player.sp-idle { cursor: none; } +.site-controls button { all: unset; flex: none; width: 30px; height: 30px; display: grid; place-items: center; cursor: pointer; border-radius: 3px; } +.site-controls button:focus-visible, .site-scrub:focus-visible { outline: 2px solid #fff; outline-offset: 1px; } +.site-controls svg { width: 20px; height: 20px; fill: currentColor; } +.site-player[data-paused] .sp-i-pause, .site-player:not([data-paused]) .sp-i-play, +.site-player[data-muted] .sp-i-sound, .site-player:not([data-muted]) .sp-i-muted, +.site-player[data-full] .sp-i-full, .site-player:not([data-full]) .sp-i-exit { display: none; } +.site-scrub { flex: 1; min-width: 0; position: relative; display: flex; gap: 3px; align-items: center; height: 22px; cursor: pointer; touch-action: none; border-radius: 3px; } +.sp-seg { flex: 1 1 0; min-width: 3px; height: 4px; border-radius: 2px; background: linear-gradient(#fff, #fff) no-repeat 0 0 / var(--played, 0%) 100%, rgba(255, 255, 255, 0.32); transition: height 120ms ease; } +.site-scrub:hover .sp-seg, .site-scrub.sp-drag .sp-seg { height: 6px; } +.site-scrub .sp-seg.sp-hover { height: 8px; } +.sp-tip { position: absolute; bottom: 26px; left: 0; max-width: 18rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 4px 8px; border-radius: 3px; background: rgba(24, 24, 22, 0.92); color: #fff; font-size: 0.75rem; line-height: 1.3; pointer-events: none; } +.sp-tip[hidden] { display: none; } +.site-player .sp-tip b { font-family: "Martian Mono", ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 400; font-size: 0.6875rem; margin-right: 8px; color: #fff; } +.sp-time { flex: none; font-family: "Martian Mono", ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.6875rem; font-stretch: 87.5%; font-variant-numeric: tabular-nums; white-space: nowrap; } +@media (max-width: 30rem) { .sp-time { display: none; } } +.site-chapters ol { list-style: none; margin: 0; padding: 10px 6px 2px; columns: 2 15rem; column-gap: 16px; } .site-chapters li { break-inside: avoid; } -.site-chapters ol button { all: unset; box-sizing: border-box; display: flex; gap: 10px; align-items: baseline; width: 100%; padding: 3px 0; cursor: pointer; color: #64645f; line-height: 1.35; } -.site-chapters ol button:hover { color: #1f1f1d; } -.site-chapters ol button:focus-visible { outline: 2px solid #3358d4; outline-offset: 2px; border-radius: 2px; } -.site-chapters ol button[aria-current] { color: #1f1f1d; font-weight: 600; } +.site-chapters button { all: unset; box-sizing: border-box; display: flex; gap: 10px; align-items: baseline; width: 100%; padding: 3px 0; cursor: pointer; color: #64645f; line-height: 1.35; } +.site-chapters button:hover { color: #1f1f1d; } +.site-chapters button:focus-visible { outline: 2px solid #3358d4; outline-offset: 2px; border-radius: 2px; } +.site-chapters button[aria-current] { color: #1f1f1d; font-weight: 600; } .site-chapters .site-label { flex: none; min-width: 2.6em; font-weight: 400; font-variant-numeric: tabular-nums; } .site-watch .site-watch-bar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 4px 16px; padding: 10px 6px 4px; } .site-watch b { color: #1f1f1d; font-weight: 600; } @@ -148,15 +168,37 @@ export function videoChapters(slug) { return [...readFileSync(file, "utf8").matchAll(/^([\d:.]+) --> ([\d:.]+)\n(.+)$/gm)].map(([, a, b, title]) => ({ start: secs(a), end: secs(b), title })); } -// Under the video: a bar with one segment per chapter, filled as the video plays, and the chapters as a list. -// Either one jumps the video there. The cue text is already escaped for HTML (WebVTT escapes the same way). -function chapterNav(marks) { - if (!marks.length) return ""; +const clock = (t) => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`; +const ICON = (cls, d) => `<svg class="${cls}" viewBox="0 0 20 20" aria-hidden="true">${d}</svg>`; +const SPEAKER = `<path d="M3 7.5h3l4.5-3.5v12L6 12.5H3z"/>`; +const ICONS = { + play: ICON("sp-i-play", `<path d="M6 4.2v11.6L15.5 10z"/>`), + pause: ICON("sp-i-pause", `<path d="M5.5 4h3v12h-3zM11.5 4h3v12h-3z"/>`), + sound: ICON("sp-i-sound", `${SPEAKER}<path d="M13.2 7.2a4 4 0 0 1 0 5.6M15.4 5a7 7 0 0 1 0 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>`), + muted: ICON("sp-i-muted", `${SPEAKER}<path d="M13.5 8l4 4M17.5 8l-4 4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>`), + full: ICON("sp-i-full", `<path d="M3.5 8V3.5H8M12 3.5h4.5V8M16.5 12v4.5H12M8 16.5H3.5V12" fill="none" stroke="currentColor" stroke-width="1.8"/>`), + exit: ICON("sp-i-exit", `<path d="M8 3.5V8H3.5M16.5 8H12V3.5M12 16.5V12h4.5M3.5 12H8v4.5" fill="none" stroke="currentColor" stroke-width="1.8"/>`), +}; + +// The video and its controls. With chapters (the motion.vtt beside it), the page's script replaces the +// browser's controls with its own, whose scrubber has one segment per chapter, filled as the video plays; +// the chapters are also listed under it. Without the script, the browser's controls stay. The cue text is +// already escaped for HTML (WebVTT escapes the same way). +function player(marks) { + const video = (track) => `<video src="motion.mp4" autoplay muted loop playsinline controls preload="metadata" aria-label="The video cut of this post.">${track}</video>`; + if (!marks.length) return video(""); const total = marks.at(-1).end; - const clock = (t) => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`; - return ` + return `<div class="site-player" data-paused data-muted> + ${video(`<track kind="chapters" src="motion.vtt" srclang="en" label="Chapters" default>`)} + <div class="site-controls" hidden> + <button type="button" class="sp-play" aria-label="Play">${ICONS.play}${ICONS.pause}</button> + <div class="site-scrub" role="slider" tabindex="0" aria-label="Seek" aria-valuemin="0" aria-valuemax="${total}" aria-valuenow="0" aria-valuetext="0:00">${marks.map((c) => `<span class="sp-seg" data-at="${c.start}" data-title="${c.title}" style="flex-grow: ${(c.end - c.start).toFixed(2)}"></span>`).join("")}<span class="sp-tip" hidden></span></div> + <span class="sp-time"><span class="sp-now">0:00</span> / ${clock(total)}</span> + <button type="button" class="sp-mute" aria-label="Unmute">${ICONS.sound}${ICONS.muted}</button> + <button type="button" class="sp-full" aria-label="Full screen">${ICONS.full}${ICONS.exit}</button> + </div> + </div> <nav class="site-chapters" aria-label="Chapters"> - <div class="site-scrub" aria-hidden="true">${marks.map((c) => `<button type="button" tabindex="-1" data-at="${c.start}" style="flex-grow: ${(c.end - c.start).toFixed(2)}" title="${c.title}"><i></i></button>`).join("")}</div> <ol>${marks.map((c) => `<li><button type="button" data-at="${c.start}"><span class="site-label">${clock(c.start)}</span>${c.title}</button></li>`).join("")}</ol> </nav>`; } @@ -176,7 +218,7 @@ export function wrapPost(slug, post, url) { const marks = videoChapters(slug); const watch = ` <aside class="site-bar site-watch" id="site-video" aria-label="The video"> - <video src="motion.mp4" autoplay muted loop playsinline controls preload="metadata" aria-label="The video cut of this post.">${marks.length ? `<track kind="chapters" src="motion.vtt" srclang="en" label="Chapters" default>` : ""}</video>${chapterNav(marks)} + ${player(marks)} <div class="site-watch-bar"><b>The same story as a video</b><span class="site-links"><a href="motion.mp4" download>Download</a>${ex.deck ? `<a href="deck.html">Slides</a>` : ""}<a href="${REPO}/blob/main/examples/${ex.slug}/plan.md">The plan behind it</a></span></div> </aside>`; body = body.slice(0, mast + 9) + watch + body.slice(mast + 9); @@ -238,33 +280,149 @@ ${footer("../../")} for (const a of links) { const t = document.getElementById(a.hash.slice(1)); if (t) io.observe(t); } if (matchMedia("(prefers-reduced-motion: reduce)").matches) document.querySelector(".site-watch video")?.pause(); })(); -// The video's chapters: fill each segment as far as the video has played, mark the chapter playing, and jump on a click. +// The player's own controls: a scrubber with a segment per chapter (click, drag, or the arrow keys; Page Up and +// Page Down move a chapter), play, mute and full screen. They fade while the video plays and the pointer rests. (() => { - const video = document.querySelector(".site-watch video"); - const nav = document.querySelector(".site-chapters"); - if (!video || !nav) return; - const segs = [...nav.querySelectorAll(".site-scrub button")]; - const items = [...nav.querySelectorAll("ol button")]; - const starts = items.map((b) => Number(b.dataset.at)); + const box = document.querySelector(".site-player"); + if (!box) return; + const video = box.querySelector("video"); + const bar = box.querySelector(".site-controls"); + const scrub = bar.querySelector(".site-scrub"); + const tip = scrub.querySelector(".sp-tip"); + const now = bar.querySelector(".sp-now"); + const [play, mute, full] = [".sp-play", ".sp-mute", ".sp-full"].map((s) => bar.querySelector(s)); + const segs = [...scrub.querySelectorAll(".sp-seg")]; + const items = [...document.querySelectorAll(".site-chapters button[data-at]")]; + const starts = segs.map((s) => Number(s.dataset.at)); + const titles = segs.map((s) => s.dataset.title); + const total = () => video.duration || Number(scrub.getAttribute("aria-valuemax")); + const end = (i) => starts[i + 1] ?? total(); + const clamp = (x, a, b) => Math.min(b, Math.max(a, x)); + const clock = (t) => Math.floor(t / 60) + ":" + String(Math.floor(t % 60)).padStart(2, "0"); + const chapterAt = (t) => Math.max(0, starts.findLastIndex((a) => a <= t + 0.05)); + video.controls = false; + bar.hidden = false; + let current = -1; const draw = () => { const t = video.currentTime; - const end = (i) => starts[i + 1] ?? (video.duration || starts[i] + 1); - segs.forEach((s, i) => s.firstChild.style.setProperty("--played", Math.min(100, Math.max(0, ((t - starts[i]) / (end(i) - starts[i])) * 100)) + "%")); - const on = starts.findLastIndex((a) => a <= t + 0.05); + segs.forEach((s, i) => s.style.setProperty("--played", clamp(((t - starts[i]) / (end(i) - starts[i])) * 100, 0, 100) + "%")); + now.textContent = clock(t); + const on = chapterAt(t); + scrub.setAttribute("aria-valuenow", t.toFixed(1)); + scrub.setAttribute("aria-valuetext", clock(t) + ", " + titles[on]); if (on !== current) { items[current]?.removeAttribute("aria-current"); items[on]?.setAttribute("aria-current", "true"); current = on; } }; + const state = () => { + box.toggleAttribute("data-paused", video.paused); + box.toggleAttribute("data-muted", video.muted); + play.setAttribute("aria-label", video.paused ? "Play" : "Pause"); + mute.setAttribute("aria-label", video.muted ? "Unmute" : "Mute"); + }; let raf = 0; const tick = () => { draw(); raf = video.paused ? 0 : requestAnimationFrame(tick); }; - video.addEventListener("play", () => { if (!raf) raf = requestAnimationFrame(tick); }); + video.addEventListener("play", () => { state(); wake(); if (!raf) raf = requestAnimationFrame(tick); }); + video.addEventListener("pause", () => { state(); wake(); }); + video.addEventListener("volumechange", state); for (const e of ["seeked", "timeupdate", "loadedmetadata"]) video.addEventListener(e, draw); - nav.addEventListener("click", (e) => { - const b = e.target.closest("button[data-at]"); - if (!b) return; - video.currentTime = Number(b.dataset.at); - if (video.paused) video.play().catch(() => {}); + const toggle = () => (video.paused ? video.play().catch(() => {}) : video.pause()); + const seekTo = (t) => { video.currentTime = clamp(t, 0, total() - 0.05); draw(); }; + + // A pointer's place on the scrubber, as a time: the segment under it (or the nearest), and how far into it. + const timeAt = (x) => { + let i = 0, best = Infinity, frac = 0; + segs.forEach((s, j) => { + const r = s.getBoundingClientRect(); + const d = x < r.left ? r.left - x : x > r.right ? x - r.right : 0; + if (d < best) { best = d; i = j; frac = clamp((x - r.left) / r.width, 0, 1); } + }); + return { i, t: starts[i] + frac * (end(i) - starts[i]) }; + }; + const showTip = (x) => { + const { i, t } = timeAt(x); + tip.replaceChildren(Object.assign(document.createElement("b"), { textContent: clock(t) }), titles[i]); + tip.hidden = false; + const r = scrub.getBoundingClientRect(); + tip.style.left = clamp(x - r.left - tip.offsetWidth / 2, 0, r.width - tip.offsetWidth) + "px"; + segs.forEach((s, j) => s.classList.toggle("sp-hover", j === i)); + }; + const hideTip = () => { tip.hidden = true; segs.forEach((s) => s.classList.remove("sp-hover")); }; + let dragging = false; + let resume = false; + scrub.addEventListener("pointerdown", (e) => { + if (e.button) return; + dragging = true; + resume = !video.paused; + scrub.setPointerCapture(e.pointerId); + scrub.classList.add("sp-drag"); + video.pause(); + seekTo(timeAt(e.clientX).t); + showTip(e.clientX); + }); + scrub.addEventListener("pointermove", (e) => { + if (dragging) seekTo(timeAt(e.clientX).t); + if (dragging || e.pointerType === "mouse") showTip(e.clientX); }); + const stop = (e) => { + if (!dragging) return; + dragging = false; + scrub.classList.remove("sp-drag"); + if (resume) video.play().catch(() => {}); + if (e.pointerType !== "mouse") hideTip(); + }; + scrub.addEventListener("pointerup", stop); + scrub.addEventListener("pointercancel", stop); + scrub.addEventListener("pointerleave", () => { if (!dragging) hideTip(); }); + scrub.addEventListener("keydown", (e) => { + const t = video.currentTime; + const i = chapterAt(t); + const to = { + ArrowLeft: t - 5, ArrowDown: t - 5, ArrowRight: t + 5, ArrowUp: t + 5, + PageDown: t - starts[i] > 1 ? starts[i] : starts[Math.max(0, i - 1)], + PageUp: starts[Math.min(starts.length - 1, i + 1)], + Home: 0, End: starts.at(-1), + }[e.key]; + if (to !== undefined) { e.preventDefault(); seekTo(to); } + else if (e.key === " " || e.key === "Enter") { e.preventDefault(); toggle(); } + }); + + play.addEventListener("click", toggle); + mute.addEventListener("click", () => { video.muted = !video.muted; }); + const fullscreen = () => document.fullscreenElement || document.webkitFullscreenElement; + full.addEventListener("click", () => { + if (fullscreen()) (document.exitFullscreen || document.webkitExitFullscreen).call(document); + else if (box.requestFullscreen) box.requestFullscreen().catch(() => video.webkitEnterFullscreen?.()); + else if (box.webkitRequestFullscreen) box.webkitRequestFullscreen(); + else video.webkitEnterFullscreen?.(); + }); + const onFull = () => { box.toggleAttribute("data-full", !!fullscreen()); full.setAttribute("aria-label", fullscreen() ? "Exit full screen" : "Full screen"); }; + document.addEventListener("fullscreenchange", onFull); + document.addEventListener("webkitfullscreenchange", onFull); + + // The controls fade after a moment of play with the pointer still, and come back on any move, tap or key. + // A tap on a faded video brings the controls back rather than pausing it. + let idle = 0; + let wasIdle = false; + function wake() { + box.classList.remove("sp-idle"); + clearTimeout(idle); + idle = setTimeout(() => { if (!video.paused && !dragging && !bar.contains(document.activeElement)) box.classList.add("sp-idle"); }, 2500); + } + box.addEventListener("pointerdown", () => { wasIdle = box.classList.contains("sp-idle"); wake(); }, true); + box.addEventListener("pointermove", wake); + box.addEventListener("focusin", wake); + box.addEventListener("pointerleave", (e) => { if (e.pointerType === "mouse" && !video.paused && !dragging) box.classList.add("sp-idle"); }); + video.addEventListener("click", (e) => { if (e.pointerType !== "mouse" && wasIdle) return; toggle(); }); + video.addEventListener("dblclick", () => full.click()); + box.addEventListener("keydown", (e) => { + if (e.target.closest("button, .site-scrub")) return; + if (e.key === "k" || e.key === " ") { e.preventDefault(); toggle(); } + }); + + for (const b of items) b.addEventListener("click", () => { seekTo(Number(b.dataset.at)); if (video.paused) video.play().catch(() => {}); }); + state(); draw(); + wake(); })(); </script> </body> From 16e87954375ff2af98b750c0d42cc8fbeede0c9d Mon Sep 17 00:00:00 2001 From: Zach Dunn <zach@buildinternet.com> Date: Thu, 15 Oct 2026 23:18:19 -0400 Subject: [PATCH 3/3] feat(site): write sitemap.xml and robots.txt when built with --url --- CHANGELOG.md | 1 + dev/test/site.test.mjs | 12 ++++++++++++ docs/developing.md | 2 +- site/build.mjs | 25 ++++++++++++++++++++++++- 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9be59f2..d7cb7e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Until the first release, changes are recorded under [Unreleased]. ### Added +- The website's build writes `sitemap.xml` (the home page, the examples page, each example and its slides) and a `robots.txt` that names it, when given `--url`, as the deploy is. - `.gitattributes`: shell scripts (`*.sh`) check out with LF line endings on every platform. - Video chapters: a whole-cut MP4 or WebM from `motion.mjs` carries one chapter per scene, named by its label (scenes in a row with the same label make one), and `<story>-motion.vtt` beside it holds the same chapters. `export.mjs` takes them as `--chapters <file>`. The example videos carry them, and the site's example pages play it in their own player: its scrubber has a segment per chapter (click, drag, arrow keys, Page Up and Page Down by chapter) with the chapter's name on hover, plus play, mute and full screen, and the chapters are listed under it. Without JavaScript the browser's own controls stay. Tests in `dev/test/motion.test.mjs` and `dev/test/site.test.mjs`. - Brands: a per-repo `brand.json` next to `house-style.md` (colours by role in light and dark, display, body and mono fonts with a stylesheet, and a logo) that the post, the motion cut and the deck follow. `scripts/brand.mjs` checks it (unknown keys, hex colours, and a contrast warning below 4.5:1), derives the roles it implies, and writes it into a page as a style block after the template's own, so pages without a brand are unchanged. `viz.mjs build`, `motion.mjs` and `deck.mjs` apply it; `motion.json` takes `"brand"` overrides, and `--brand`/`--no-brand` pick another file or none. `workdir.mjs` prints its path. Docs in `references/visuals.md#brand`, `motion.md`, `deck.md` and `SKILL.md`; tests in `dev/test/brand.test.mjs`. diff --git a/dev/test/site.test.mjs b/dev/test/site.test.mjs index 53c7738..11462af 100644 --- a/dev/test/site.test.mjs +++ b/dev/test/site.test.mjs @@ -146,8 +146,20 @@ test("link previews: description, Open Graph and Twitter tags, favicon", () => { assert.match(html, /<link rel="icon" href="assets\/favicon\.svg" type="image\/svg\+xml">/); assert.ok(files.includes("assets/og.png") && files.includes("assets/favicon.svg")); assert.ok(existsSync(join(out, "assets/og.png"))); + assert.ok(!existsSync(join(out, "sitemap.xml")) && !existsSync(join(out, "robots.txt")), "no sitemap without --url"); // with --url: canonical, og:url, and an absolute image inside the site ({ html } = build({ out, url: "https://example.com/buildnotes" })); + // with --url: a sitemap of every page, under that address, and a robots.txt naming it + const map = readFileSync(join(out, "sitemap.xml"), "utf8"); + const locs = [...map.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]); + assert.equal(locs[0], "https://example.com/buildnotes/"); + assert.ok(locs.includes("https://example.com/buildnotes/examples/")); + for (const ex of EXAMPLES) assert.ok(locs.includes(`https://example.com/buildnotes/examples/${ex.slug}/`), ex.slug); + for (const l of locs) { + const path = l.slice("https://example.com/buildnotes/".length); + assert.ok(existsSync(join(out, path.endsWith("/") || !path ? path + "index.html" : path)), `${l} is a built page`); + } + assert.match(readFileSync(join(out, "robots.txt"), "utf8"), /^Sitemap: https:\/\/example\.com\/buildnotes\/sitemap\.xml$/m); assert.match(html, /<link rel="canonical" href="https:\/\/example\.com\/buildnotes\/">/); assert.match(readFileSync(join(out, "examples/index.html"), "utf8"), /<link rel="canonical" href="https:\/\/example\.com\/buildnotes\/examples\/">/); assert.equal(meta(html, "property", "og:url"), "https://example.com/buildnotes/"); diff --git a/docs/developing.md b/docs/developing.md index 2169118..9eb7574 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -30,7 +30,7 @@ To keep a public run as a committed example, check it, then copy its page, plan ```bash node site/build.mjs # or: npm run site -node site/build.mjs --url https://buildnotes.releases.sh # canonical link, og:url, og:image there +node site/build.mjs --url https://buildnotes.releases.sh # canonical link, og:url, og:image there, sitemap.xml and robots.txt node site/card.mjs # re-render the link-preview images in site/assets (needs Chrome) open site/dist/index.html npm run site:dev # build, then serve it as the Worker does at http://localhost:8787 diff --git a/site/build.mjs b/site/build.mjs index 3cd9720..48fa501 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -4,7 +4,8 @@ // Usage: // node site/build.mjs build into site/dist // node site/build.mjs --out <dir> build somewhere else -// node site/build.mjs --url <url> where it will be served: sets the canonical link, og:url and an og:image there +// node site/build.mjs --url <url> where it will be served: sets the canonical link, og:url and an og:image there, +// and writes sitemap.xml and a robots.txt that names it // // site/src/index.html is the page. The build fills in its markers (<!-- share --> and <!-- description --> // become the link-preview tags, from SHARE below), including these: @@ -574,6 +575,20 @@ function examplesPage(homeHtml, url) { return page; } +// The site's pages, as paths from its root: the folders end in a slash, as auto-trailing-slash serves them. +export function sitePages() { + return ["", "examples/", ...EXAMPLES.flatMap((ex) => [`examples/${ex.slug}/`, ...(ex.deck ? [`examples/${ex.slug}/deck.html`] : [])])]; +} + +export function sitemap(url, pages) { + const loc = (p) => new URL(p, url).href.replace(/&/g, "&").replace(/</g, "<"); + return `<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> +${pages.map((p) => ` <url><loc>${loc(p)}</loc></url>`).join("\n")} +</urlset> +`; +} + // url: where site/dist will be served, e.g. https://example.com/buildnotes/ (a trailing slash is added). export function build({ out = join(here, "dist"), url } = {}) { if (url) { @@ -635,6 +650,14 @@ export function build({ out = join(here, "dist"), url } = {}) { } writeFileSync(join(out, "examples/index.html"), siteFonts(examples, "../", "src/examples.html")); files.set("examples/index.html", null); + // With --url, a sitemap of every page (the home page, the examples page, each example and its slides) and a + // robots.txt that points to it. A sitemap needs absolute URLs, so a build without --url writes neither. + if (url) { + writeFileSync(join(out, "sitemap.xml"), sitemap(url, sitePages())); + writeFileSync(join(out, "robots.txt"), `User-agent: *\nAllow: /\n\nSitemap: ${new URL("sitemap.xml", url).href}\n`); + files.set("sitemap.xml", null); + files.set("robots.txt", null); + } return { html, examples, files: [...files.keys()], warnings, out }; }