Skip to content

Commit 3c09230

Browse files
committed
walkthrough(filenames): rename part files to title-word slugs
Each part now emits <filename>.html instead of walkthrough-part-<n>.html (e.g. anatomy.html, building.html, parsing.html, validation.html, conversion.html, ecosystems.html, comparison.html, security.html). Public URLs on GH Pages are now short and speakable — /socket-packageurl-js/anatomy.html instead of /socket-packageurl-js/walkthrough-part-1.html. The filename lives in walkthrough.json per part. A validator at the top of the generate pipeline enforces that every part has a filename, matches [a-z]+, and is unique — failing with all violations reported in one pass per the ERROR MESSAGES doctrine in CLAUDE.md. applyBasePath() and routeToFile() both consult the same part-id → filename map, so prod builds rewrite /<slug>/part/<n> hrefs to the flat file, and the local dev server translates the same URL shape back to the renamed file on disk.
1 parent 8b92097 commit 3c09230

2 files changed

Lines changed: 170 additions & 14 deletions

File tree

scripts/walkthrough.mts

Lines changed: 162 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
readFileSync,
2222
readdirSync,
2323
statSync,
24+
unlinkSync,
2425
writeFileSync,
2526
} from 'node:fs'
2627
import { createServer } from 'node:http'
@@ -104,6 +105,63 @@ function normalizeBasePath(raw: string): string {
104105
return p
105106
}
106107

108+
/**
109+
* Validate the `filename` field on every walkthrough.json part, then
110+
* build the part-id → filename map the post-processor uses to rename
111+
* emitted HTML and rewrite hrefs.
112+
*
113+
* Invariants:
114+
* - every part has `filename` set
115+
* - `filename` is [a-z]+ (lowercase ASCII letters, no digits, no
116+
* hyphens, no dots). Single-word nouns keep public URLs short and
117+
* speakable — see .claude/skills/content-filename-from-title.
118+
* - `filename` is unique across all parts
119+
*
120+
* Errors follow the ERROR MESSAGES doctrine in CLAUDE.md: what rule,
121+
* where, saw-vs-wanted, fix. Collects all violations before throwing
122+
* so the build reports every broken part in one pass, not just the
123+
* first one found.
124+
*/
125+
function validatePartFilenames(
126+
parts: ReadonlyArray<{ id: number; title: string; filename?: string }>,
127+
configPath: string,
128+
): Map<number, string> {
129+
const errors: string[] = []
130+
const seen = new Map<string, number>()
131+
for (const p of parts) {
132+
if (!p.filename) {
133+
errors.push(
134+
`${configPath}: part ${p.id} ("${p.title}") is missing "filename". Add a single-word lowercase filename (e.g. "anatomy") to this part — one per part is required to route /<slug>/part/${p.id} at publish time.`,
135+
)
136+
continue
137+
}
138+
if (!/^[a-z]+$/.test(p.filename)) {
139+
errors.push(
140+
`${configPath}: part ${p.id} ("${p.title}") has filename "${p.filename}" but filenames must match [a-z]+ (lowercase ASCII letters only — no digits, hyphens, or dots). Rewrite "${p.filename}" as a single lowercase word.`,
141+
)
142+
continue
143+
}
144+
const prior = seen.get(p.filename)
145+
if (prior !== undefined) {
146+
errors.push(
147+
`${configPath}: filename "${p.filename}" is used by both part ${prior} and part ${p.id} ("${p.title}"). Filenames must be unique — rename one of the two to a distinct single-word lowercase filename.`,
148+
)
149+
continue
150+
}
151+
seen.set(p.filename, p.id)
152+
}
153+
if (errors.length > 0) {
154+
throw new Error(
155+
`walkthrough.json has ${errors.length} invalid part filename(s):\n - ${errors.join('\n - ')}`,
156+
)
157+
}
158+
const map = new Map<number, string>()
159+
for (const p of parts) {
160+
map.set(p.id, p.filename!)
161+
}
162+
return map
163+
}
164+
107165
/**
108166
* Rewrite a generated HTML file for hosting under `basePath`. Two
109167
* categories of URL get prefixed:
@@ -112,24 +170,36 @@ function normalizeBasePath(raw: string): string {
112170
* (/walkthrough.css, /walkthrough-drag.js, /favicon.ico, etc.).
113171
* 2. Val-Town-shaped part links (/<slug>/part/<n>) — these don't
114172
* exist as files; rewrite to the real flat HTML name
115-
* (walkthrough-part-<n>.html) and prefix with basePath.
173+
* (<partFilenames[n]>.html, e.g. "anatomy.html") and prefix with
174+
* basePath.
116175
*
117176
* We do a narrowly-scoped regex pass rather than a full HTML parse:
118177
* the set of URL attributes we emit is small and known, and we don't
119178
* want to touch hrefs in the prose (external links, anchor jumps).
120179
*/
121-
function applyBasePath(html: string, basePath: string, slug: string): string {
180+
function applyBasePath(
181+
html: string,
182+
basePath: string,
183+
slug: string,
184+
partFilenames: ReadonlyMap<number, string>,
185+
): string {
122186
if (!basePath) {
123187
return html
124188
}
125189
// 1. Flat part link — rewrite first so step 2 doesn't double-prefix.
126190
// Matches href="/<slug>/part/<n>" and rewrites to
127-
// href="<basePath>/walkthrough-part-<n>.html".
191+
// href="<basePath>/<partFilenames[n]>.html". Part numbers without a
192+
// filename entry are left untouched; the validator that built the
193+
// map already guaranteed coverage for every configured part, so any
194+
// miss here is a stray href meander rendered for a removed part.
128195
const partLink = new RegExp(`(href=")/${slug}/part/(\\d+)/?(")`, 'g')
129-
let out = html.replace(
130-
partLink,
131-
(_m, pre, n, post) => `${pre}${basePath}/walkthrough-part-${n}.html${post}`,
132-
)
196+
let out = html.replace(partLink, (_m, pre, n, post) => {
197+
const filename = partFilenames.get(Number(n))
198+
if (!filename) {
199+
return `${pre}/${slug}/part/${n}${post}`
200+
}
201+
return `${pre}${basePath}/${filename}.html${post}`
202+
})
133203
// 2. Root-relative asset URLs. Match href="/..." and src="/..." and
134204
// ServiceWorker-style register('/...') — but skip:
135205
// - protocol-qualified URLs (https://, data:, mailto:, etc.)
@@ -459,7 +529,7 @@ async function generate(
459529
? (JSON.parse(readFileSync(path.resolve(configPath), 'utf8')) as {
460530
slug?: string
461531
commentBackend?: string
462-
parts?: Array<{ id: number; title: string }>
532+
parts?: Array<{ id: number; title: string; filename?: string }>
463533
})
464534
: {}
465535
const commentBackend = walkthroughConfig.commentBackend || ''
@@ -473,6 +543,16 @@ async function generate(
473543
partTitles.set(p.id, p.title)
474544
}
475545

546+
// Map part-id → filename (e.g. 1 → "anatomy"). Drives both the flat
547+
// HTML filenames on disk (<filename>.html) and the hrefs we rewrite
548+
// in applyBasePath(). Validator below enforces presence, shape, and
549+
// uniqueness — errors follow CLAUDE.md's ERROR MESSAGES doctrine so
550+
// the build fails with an actionable message, not a cryptic symptom.
551+
const partFilenames = validatePartFilenames(
552+
walkthroughConfig.parts ?? [],
553+
configPath ? path.resolve(configPath) : '<config>',
554+
)
555+
476556
// Pull per-part section counts off the emitted index page. Meander
477557
// computes them while rendering — cheaper + more reliable than
478558
// re-parsing every part page here. Matches the TOC row shape:
@@ -692,7 +772,33 @@ async function generate(
692772
// prefixed in one pass. No-op when --base-path is empty (local dev,
693773
// Val Town hosting, etc.).
694774
if (basePath && slug) {
695-
html = applyBasePath(html, basePath, slug)
775+
html = applyBasePath(html, basePath, slug, partFilenames)
776+
}
777+
778+
// Rename meander's walkthrough-part-<n>.html to the configured
779+
// <filename>.html (e.g. walkthrough-part-1.html → anatomy.html).
780+
// Flat public URLs replace /<slug>/part/<n>-style links once the
781+
// site is deployed. Other emitted HTML (index.html, documents.html
782+
// if any) retains its name. Idempotent: if the target file already
783+
// exists and entry is the legacy name, we write the fresh content
784+
// then unlink the old — both states are covered by the validator
785+
// running before the loop, which guarantees partFilenames has an
786+
// entry for every part meander rendered.
787+
const partMatch = /^walkthrough-part-(\d+)\.html$/.exec(entry)
788+
if (partMatch) {
789+
const n = Number(partMatch[1])
790+
const newName = partFilenames.get(n)
791+
if (!newName) {
792+
throw new Error(
793+
`walkthrough/${entry}: no filename configured for part ${n}. Add "filename" to part ${n} in walkthrough.json (e.g. "anatomy") — the validator should have caught this, so meander may have rendered a part that isn't in walkthrough.json.`,
794+
)
795+
}
796+
const newPath = path.join(walkthroughDir, `${newName}.html`)
797+
writeFileSync(newPath, html)
798+
if (newPath !== htmlPath) {
799+
unlinkSync(htmlPath)
800+
}
801+
continue
696802
}
697803

698804
writeFileSync(htmlPath, html)
@@ -836,12 +942,42 @@ function readSlug(): string {
836942
return manifest.slug
837943
}
838944

839-
function routeToFile(slug: string, urlPath: string): string | undefined {
945+
/**
946+
* Read the part-id → filename map from walkthrough.json at the repo
947+
* root. The dev server uses this to translate /<slug>/part/<n> URLs
948+
* to the renamed <filename>.html files on disk. Mirrors the rename
949+
* applied by the generate pipeline, so a build + serve round-trips
950+
* URLs to files correctly. Returns an empty map when walkthrough.json
951+
* isn't present (e.g. invoked from a fresh checkout without the
952+
* source config) — the route table falls back to the legacy shape.
953+
*/
954+
function readPartFilenames(): Map<number, string> {
955+
const configPath = path.join(repoRoot, 'walkthrough.json')
956+
if (!existsSync(configPath)) {
957+
return new Map()
958+
}
959+
const config = JSON.parse(readFileSync(configPath, 'utf8')) as {
960+
parts?: Array<{ id: number; filename?: string }>
961+
}
962+
const map = new Map<number, string>()
963+
for (const p of config.parts ?? []) {
964+
if (p.filename) {
965+
map.set(p.id, p.filename)
966+
}
967+
}
968+
return map
969+
}
970+
971+
function routeToFile(
972+
slug: string,
973+
urlPath: string,
974+
partFilenames: ReadonlyMap<number, string>,
975+
): string | undefined {
840976
// / → index.html
841977
// /<slug> or /<slug>/ → index.html (slug-prefixed root, same as the
842978
// URL GH Pages serves the site at; matches
843979
// the flat file name emitted by meander)
844-
// /<slug>/part/<n> → walkthrough-part-<n>.html
980+
// /<slug>/part/<n> → <partFilenames[n]>.html (e.g. anatomy.html)
845981
// /<slug>/documents → documents.html
846982
// anything else → as-is (e.g. /walkthrough.css)
847983
if (urlPath === '/' || urlPath === '') {
@@ -852,7 +988,11 @@ function routeToFile(slug: string, urlPath: string): string | undefined {
852988
}
853989
const partMatch = new RegExp(`^/${slug}/part/(\\d+)/?$`).exec(urlPath)
854990
if (partMatch) {
855-
return `walkthrough-part-${partMatch[1]}.html`
991+
const filename = partFilenames.get(Number(partMatch[1]))
992+
if (filename) {
993+
return `${filename}.html`
994+
}
995+
return undefined
856996
}
857997
if (urlPath === `/${slug}/documents` || urlPath === `/${slug}/documents/`) {
858998
return 'documents.html'
@@ -872,6 +1012,7 @@ function serve(basePath: string, args: readonly string[]): void {
8721012
}
8731013

8741014
const slug = readSlug()
1015+
const partFilenames = readPartFilenames()
8751016

8761017
const server = createServer((req, res) => {
8771018
const rawUrl = (req.url ?? '/').split('?')[0]!.split('#')[0]!
@@ -885,7 +1026,7 @@ function serve(basePath: string, args: readonly string[]): void {
8851026
} else if (basePath && decoded === basePath) {
8861027
decoded = '/'
8871028
}
888-
const relative = routeToFile(slug, decoded)
1029+
const relative = routeToFile(slug, decoded, partFilenames)
8891030
if (relative === undefined) {
8901031
res.writeHead(400).end('bad request')
8911032
return
@@ -919,7 +1060,14 @@ function serve(basePath: string, args: readonly string[]): void {
9191060
server.listen(port, '127.0.0.1', () => {
9201061
console.log(`Serving ${walkthroughDir} (slug: ${slug})`)
9211062
console.log(` index → http://127.0.0.1:${port}/`)
922-
console.log(` part 1 → http://127.0.0.1:${port}/${slug}/part/1`)
1063+
const firstFilename = partFilenames.get(1)
1064+
if (firstFilename) {
1065+
console.log(
1066+
` part 1 → http://127.0.0.1:${port}/${slug}/part/1 (alias of /${firstFilename}.html)`,
1067+
)
1068+
} else {
1069+
console.log(` part 1 → http://127.0.0.1:${port}/${slug}/part/1`)
1070+
}
9231071
console.log(` Press Ctrl+C to stop.`)
9241072
})
9251073
}

walkthrough.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
{
77
"id": 1,
88
"title": "Anatomy of a PURL",
9+
"filename": "anatomy",
910
"objective": "Understand the pkg:type/ns/name@version?q#sub shape and how it maps to the PackageURL class.",
1011
"keywords": ["purl", "package-url", "anatomy", "components"],
1112
"files": [
@@ -17,6 +18,7 @@
1718
{
1819
"id": 2,
1920
"title": "Building & Stringifying PURLs",
21+
"filename": "building",
2022
"objective": "Build PURLs with the fluent PurlBuilder API; serialize back to canonical `pkg:...` string form.",
2123
"keywords": [
2224
"builder",
@@ -30,6 +32,7 @@
3032
{
3133
"id": 3,
3234
"title": "Parsing & Normalization",
35+
"filename": "parsing",
3336
"objective": "Percent-encoding, name/namespace normalization, and the known-qualifiers catalogue (checksum, download_url, file_name, repository_url, vcs_url, vers).",
3437
"keywords": [
3538
"encode",
@@ -48,6 +51,7 @@
4851
{
4952
"id": 4,
5053
"title": "Validation, Errors & Results",
54+
"filename": "validation",
5155
"objective": "Shape + format validation, the Result<T,E> functional error pattern (Ok/Err/ResultUtils), typed errors (PurlError, PurlInjectionError), and per-ecosystem rule plumbing.",
5256
"keywords": ["validate", "result", "error", "ok", "err", "purl-type"],
5357
"files": [
@@ -60,13 +64,15 @@
6064
{
6165
"id": 5,
6266
"title": "URL ↔ PURL Conversion",
67+
"filename": "conversion",
6368
"objective": "Convert between repository/download URLs and PURLs — ~25 ecosystem URL parsers, toDownloadUrl, toRepositoryUrl, getAllUrls.",
6469
"keywords": ["url-converter", "url", "repository", "download", "vcs"],
6570
"files": ["src/url-converter.ts"]
6671
},
6772
{
6873
"id": 6,
6974
"title": "Ecosystems",
75+
"filename": "ecosystems",
7076
"objective": "Per-ecosystem normalize/validate/encode rules across all 41 supported package ecosystems.",
7177
"keywords": [
7278
"ecosystem",
@@ -127,6 +133,7 @@
127133
{
128134
"id": 7,
129135
"title": "Comparison, Matching & Existence",
136+
"filename": "comparison",
130137
"objective": "Compare + equal + match PURLs with wildcard patterns (ReDoS-protected); registry existence checks (npmExists, pypiExists, and friends).",
131138
"keywords": [
132139
"compare",
@@ -146,6 +153,7 @@
146153
{
147154
"id": 8,
148155
"title": "Security Primitives & VERS",
156+
"filename": "security",
149157
"objective": "Injection-character detection for safe PURL handling (containsInjectionCharacters, findInjectionCharCode), safe object freezing, and VERS — the pre-standard version-range specifier slated for Ecma submission in late 2026.",
150158
"keywords": [
151159
"security",

0 commit comments

Comments
 (0)