Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.sh text eol=lf
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ 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`.
- 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.
Expand Down
16 changes: 15 additions & 1 deletion dev/test/motion.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 </script> here & now" }, base);
const html = build(cut, images);
Expand Down
27 changes: 25 additions & 2 deletions dev/test/site.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -41,7 +41,18 @@ test("the site builds: markers filled, files copied, every command copyable", ()
assert.equal(post.match(/<title>/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);
// 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);
for (const o of EXAMPLES) assert.match(post, new RegExp(`href="\\.\\./\\.\\./examples/${o.slug}/"`), `${ex.slug} links ${o.slug}`);
Expand Down Expand Up @@ -135,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/");
Expand Down
2 changes: 1 addition & 1 deletion docs/developing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ The skill's scripts live in [`scripts/`](../skills/buildnotes/scripts). Each one
node skills/buildnotes/scripts/gather.mjs --out <work-dir>/evidence --pr 120,131,140 --download
node skills/buildnotes/scripts/lint.mjs <work-dir>/post.html --flavor build --product "Release Notes Index,Releases" --avoid "codename"
node skills/buildnotes/scripts/export.mjs <work-dir>/post.html --figure 4 --gif
node skills/buildnotes/scripts/motion.mjs examples/releases-weekly-digests/motion.json --mp4 # the whole cut → export/<story>-motion.mp4
node skills/buildnotes/scripts/motion.mjs examples/releases-weekly-digests/motion.json --mp4 # the whole cut → export/<story>-motion.mp4, with chapters, and <story>-motion.vtt
node skills/buildnotes/scripts/motion.mjs examples/releases-weekly-digests/motion.json --gif --scene 5 --size 720 # one scene → export/<story>-motion-scene-5.gif
node skills/buildnotes/scripts/deck.mjs examples/sunny-walkthrough-videos/motion.json --pdf # slides → deck.html, export/<story>-deck.pdf
```
Expand Down
Binary file modified examples/releases-weekly-digests/motion.mp4
Binary file not shown.
33 changes: 33 additions & 0 deletions examples/releases-weekly-digests/motion.vtt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
WEBVTT

1
00:00:00.000 --> 00:00:04.500
Build notes

2
00:00:04.500 --> 00:00:10.300
The problem

3
00:00:10.300 --> 00:00:17.700
What we added

4
00:00:17.700 --> 00:00:25.900
01 · The week at a glance

5
00:00:25.900 --> 00:00:39.100
02 · What counts as impact

6
00:00:39.100 --> 00:00:45.900
03 · Playing the week back

7
00:00:45.900 --> 00:00:54.100
04 · One email per reader per week

8
00:00:54.100 --> 00:00:59.900
Try it
Binary file modified examples/sunny-walkthrough-videos/motion.mp4
Binary file not shown.
29 changes: 29 additions & 0 deletions examples/sunny-walkthrough-videos/motion.vtt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
WEBVTT

1
00:00:00.000 --> 00:00:04.500
Build notes

2
00:00:04.500 --> 00:00:10.600
The problem

3
00:00:10.600 --> 00:00:23.000
01 · A camera that plans its own shots

4
00:00:23.000 --> 00:00:32.700
02 · The plan in the corner

5
00:00:32.700 --> 00:00:39.400
03 · Floors and projects

6
00:00:39.400 --> 00:00:46.000
04 · Nothing in the way

7
00:00:46.000 --> 00:00:52.000
Try it
Binary file modified examples/uploads-before-after/motion.mp4
Binary file not shown.
29 changes: 29 additions & 0 deletions examples/uploads-before-after/motion.vtt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
WEBVTT

1
00:00:00.000 --> 00:00:04.500
Build notes

2
00:00:04.500 --> 00:00:10.300
The problem

3
00:00:10.300 --> 00:00:19.700
01 · Pairs that find each other

4
00:00:19.700 --> 00:00:26.300
02 · A divider instead of thumbnails

5
00:00:26.300 --> 00:00:33.300
03 · Two shots that line up

6
00:00:33.300 --> 00:00:41.400
04 · The pair that overwrote itself

7
00:00:41.400 --> 00:00:47.100
Try it
Loading
Loading