@erclx/canon 4.76.0 → 4.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.76.0",
4
+ "version": "4.77.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -16,7 +16,7 @@ Some decisions are settled by looking rather than by reasoning, and no draft is
16
16
  ## Step 1: name the decision and the arms
17
17
 
18
18
  1. State the decision in one sentence, naming what changes between arms and what stays fixed.
19
- 2. Derive a kebab slug from that sentence. Call the folder every file this run writes to `<dest>` below. `<dest>` is `.canon/tmp/<slug>/`, per `.claude/rules/canon/core/055-scratch.md`. Running inside a live `plan-groundwork` track is the one exception: `<dest>` is the track's own `evidence/<slug>/` instead, since a candidate render is evidence the track's decision file cites rather than spike input.
19
+ 2. Derive a kebab slug from that sentence. Call the folder every file this run writes to `<dest>` below. `<dest>` is `.canon/tmp/<slug>/`, a nested `<slug>/` folder rather than a flat `<slug>-<file>.md`, which is the shape every temporary write in this project takes. Running inside a live `plan-groundwork` track is the one exception: `<dest>` is the track's own `evidence/<slug>/` instead, since a candidate render is evidence the track's decision file cites rather than spike input.
20
20
  3. Write one arm per candidate, each carrying an id, a label, and what the arm costs. An arm with no stated cost is not an option.
21
21
  4. Make the current state arm `0`, so the baseline is a candidate rather than an absence. A decision with nothing shipped yet says so and starts at arm `1`.
22
22
  5. Stop at three to five arms. Two is a comparison the operator can hold in prose, and past five the pick stops being a look and becomes a sort.
@@ -48,7 +48,7 @@ canon capture <dest>/candidates.html --selector <element>
48
48
 
49
49
  ## Step 4: take the pick
50
50
 
51
- Put the choice to the operator through the structured question surface, per `.claude/rules/canon/core/005-behavior.md`.
51
+ Put the choice to the operator through the structured question surface, since a call the operator's preference decides always routes through it rather than through prose.
52
52
 
53
53
  - One option per arm, labeled with the arm's id and carrying its cost as the description.
54
54
  - Rank the recommendation first and mark it `(Recommended)`.
@@ -68,7 +68,7 @@ Put the choice to the operator through the structured question surface, per `.cl
68
68
  1. Apply the winning arm to the real surface, in one change.
69
69
  2. Close out whatever document stated the decision as open, in the same change, naming the arm that won and the ones that stayed defensible. A pick that changes a surface and records nothing about why leaves the next reader to re-derive it from a diff. Skip this where nothing stated the decision.
70
70
  3. Batch-capture the final round's arm files, when `<dest>` is the scratch path: `canon capture <dest>/arms --selector <wrapper-class> --out <archive-dir>`, naming Step 2's chosen class. This is the directory-batch convention `draft-identity` Step 6 already uses.
71
- 4. Resolve `<archive-dir>` as `.canon/review/evidence/<slug>/` against the main worktree root, per `.claude/rules/canon/core/085-worktrees.md`, never against a linked worktree this run happens to be building in. The capture is what keeps every arm past the pick, the losing ones included, as a durable revert record distinct from the live comparison page.
71
+ 4. Resolve `<archive-dir>` as `.canon/review/evidence/<slug>/` against the main worktree root, since shared session scratch resolves there rather than against a linked worktree this run happens to be building in. The capture is what keeps every arm past the pick, the losing ones included, as a durable revert record distinct from the live comparison page.
72
72
  5. Delete `<dest>` and every file inside it, when `<dest>` is the scratch path, now that every arm sits at the durable path above. A variant left behind there is a second design nobody maintains.
73
73
  6. Leave `<dest>` in place when it is a live track's `evidence/<slug>/`: `plan-groundwork`'s write scope treats evidence as durable rather than as scratch a session may delete, and the arms already sit at a durable path there.
74
74
  7. Report `<dest>` as still standing when the scratch-path delete is refused, naming the path for the operator to remove, rather than closing on a report the tree contradicts. The pick is applied either way, so the run has done its work and the folder is what outlives it.
@@ -24,7 +24,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
24
24
  | `canon docs [topic]` | Emit toolkit reference docs (`list`, or a topic by name) |
25
25
  | `canon design render` | Render `.claude/DESIGN.md` tokens to HTML and CSS |
26
26
  | `canon design regen` | Rewrite this repository's `.claude/DESIGN.md` and `src/design/base.css` from `src/design/tokens.ts` |
27
- | `canon design board` | Generate a static page set indexing tokens, surfaces, wireframes, and past candidates, against the toolkit checkout like `regen` (`--out`) |
27
+ | `canon design board` | Generate a static page set indexing tokens, surfaces, wireframes, past candidates, and components, against the toolkit checkout like `regen` (`--out`) |
28
28
  | `canon design css` | Emit the design tokens and components as CSS on stdout (`--no-components` for properties alone) |
29
29
  | `canon design install` | Install the base stylesheet into a project at `.claude/design/base.css` |
30
30
  | `canon design sync` | Update a base stylesheet already installed under `.claude/design/` |
@@ -5,7 +5,7 @@ description: Generating a static index over the toolkit checkout's own design su
5
5
 
6
6
  # Design board
7
7
 
8
- `canon design board [--out <path>]` generates a static page set indexing four design surfaces and reports the path a reader opens with `canon serve`. Like `canon design regen`, it runs against the toolkit checkout the CLI resolves its own root from, never against a target project's own files: a target holds none of the sources below, since none of `.claude/DESIGN.md`, `.claude/wireframes/`, `web/`, or the gitignored `.canon/` records ship with the published package. Running the installed CLI from inside a checkout of this project prints a mismatch warning when the caller's working directory disagrees with the root it resolved.
8
+ `canon design board [--out <path>]` generates a static page set indexing five design surfaces and reports the path a reader opens with `canon serve`. Like `canon design regen`, it runs against the toolkit checkout the CLI resolves its own root from, never against a target project's own files: a target holds none of the sources below, since none of `.claude/DESIGN.md`, `.claude/wireframes/`, `web/`, or the gitignored `.canon/` records ship with the published package. Running the installed CLI from inside a checkout of this project prints a mismatch warning when the caller's working directory disagrees with the root it resolved.
9
9
 
10
10
  Each panel reads what is already on disk and reports its own missing source rather than failing the run, so an absent build or an empty corpus renders an empty-state message instead of a broken frame.
11
11
 
@@ -24,6 +24,7 @@ canon serve .canon/review/board
24
24
  - **Surfaces** iframes the built landing page from `web/dist/` and a teach workspace from `.canon/teach/`, copying each whole into the board's own tree. Either reports its own missing build or absent workspace rather than rendering a broken frame.
25
25
  - **Wireframes** renders each of the six files under `.claude/wireframes/` as-is inside a `<pre>`, beside a line naming the surface it describes.
26
26
  - **Past candidates** lists an arm capture image per folder under `.canon/review/evidence/`, and states the corpus carries none rather than rendering an empty grid.
27
+ - **Components** iframes the gallery built by `bun run web:gallery`, a second Astro config at `web/gallery.config.mjs` reading `web/gallery-src/` and writing `web/gallery-dist/`. That config's `srcDir` is never read by `web:build`'s own config, so the gallery never reaches the published `web/dist/`. The gallery page renders every component under `web/src/components/` except two whose props carry no defaults, which it names rather than filling with invented data. Reports a missing gallery build rather than rendering a broken frame.
27
28
 
28
29
  ## What it does not do
29
30
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.76.0",
4
+ "version": "4.77.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -46,6 +46,7 @@
46
46
  "web:favicon": "bun scripts/core/regen-web-favicon.ts",
47
47
  "web:previews": "bun scripts/core/regen-web-previews.ts",
48
48
  "web:build": "bun run web:tokens && bun run web:favicon && bun run web:previews && cd web && astro check && astro build",
49
+ "web:gallery": "cd web && astro build --config gallery.config.mjs",
49
50
  "web:preview": "cd web && astro preview",
50
51
  "web:e2e": "cd web && playwright test"
51
52
  },
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+ set -o pipefail
4
+
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
7
+
8
+ WEB_DIST="$PROJECT_ROOT/web/dist"
9
+ GALLERY_DIST="$PROJECT_ROOT/web/gallery-dist"
10
+ MARKER='name="canon-gallery-marker"'
11
+
12
+ # Nothing to grep without a build. Callers run this after `bun run web:build`
13
+ # and `bun run web:gallery`, so an absent dist here means a build step itself
14
+ # failed rather than something this check can verify.
15
+ if [ ! -d "$WEB_DIST" ]; then
16
+ echo "No $WEB_DIST. Run bun run web:build first." >&2
17
+ exit 1
18
+ fi
19
+
20
+ if [ ! -d "$GALLERY_DIST" ]; then
21
+ echo "No $GALLERY_DIST. Run bun run web:gallery first." >&2
22
+ exit 1
23
+ fi
24
+
25
+ # A prose notice is not load-bearing: rewording it silently retires the leak
26
+ # check it backed with every workflow still green. The marker is a fixed
27
+ # `<meta>` tag `web/gallery-src/pages/index.astro` carries for exactly this,
28
+ # so asserting it survives in the gallery's own build is what turns a dropped
29
+ # marker into a loud failure here rather than a blind spot in the check below.
30
+ if ! grep -rl "$MARKER" "$GALLERY_DIST" >/dev/null 2>&1; then
31
+ echo "The gallery build at $GALLERY_DIST carries no $MARKER. The gallery page dropped its own marker, which is what the leak check below reads for." >&2
32
+ exit 1
33
+ fi
34
+
35
+ # `web/gallery.config.mjs` keeps the components-panel gallery out of the
36
+ # published build by never pointing its srcDir at it. A later config change
37
+ # that widens srcDir, or a gallery file moving under web/src/, would publish
38
+ # this repository's component internals to a live domain with every other
39
+ # check still green, which is what this guards against.
40
+ if find "$WEB_DIST" -iname '*gallery*' | grep -q .; then
41
+ echo "A file named for the gallery reached $WEB_DIST. See web/gallery.config.mjs's srcDir." >&2
42
+ exit 1
43
+ fi
44
+
45
+ if grep -rl "$MARKER" "$WEB_DIST" >/dev/null 2>&1; then
46
+ echo "The gallery's own marker ($MARKER) reached $WEB_DIST. See web/gallery.config.mjs's srcDir." >&2
47
+ exit 1
48
+ fi
49
+
50
+ echo "check-gallery-exclusion: gallery marker present in $GALLERY_DIST, absent from $WEB_DIST"
@@ -25,6 +25,15 @@ export const WIREFRAME_DIR = join('.claude', 'wireframes')
25
25
  const WEB_DIST = join('web', 'dist')
26
26
  const WEB_DIST_ENTRY = 'index.html'
27
27
 
28
+ /**
29
+ * The gallery build, generated by `bun run web:gallery` through
30
+ * `web/gallery.config.mjs`. That config's own `srcDir` is `web/gallery-src`,
31
+ * which `web:build`'s config never reads, so this output never reaches
32
+ * `web/dist`.
33
+ */
34
+ const WEB_GALLERY_DIST = join('web', 'gallery-dist')
35
+ const WEB_GALLERY_DIST_ENTRY = 'index.html'
36
+
28
37
  interface WireframeEntry {
29
38
  readonly path: string
30
39
  readonly describes: string
@@ -338,6 +347,19 @@ function writeSurfacesPanel(root: string, outDir: string): void {
338
347
  )
339
348
  }
340
349
 
350
+ function writeComponentsPanel(root: string, outDir: string): void {
351
+ const dir = join(outDir, 'components')
352
+ mkdirSync(dir, { recursive: true })
353
+
354
+ const gallerySource = join(root, WEB_GALLERY_DIST)
355
+ const body = existsSync(join(gallerySource, WEB_GALLERY_DIST_ENTRY))
356
+ ? (copyBuilt(gallerySource, join(dir, 'gallery')),
357
+ '<iframe src="gallery/index.html" loading="lazy"></iframe>')
358
+ : `<p class="empty">No ${WEB_GALLERY_DIST}/ build. Run bun run web:gallery, then regenerate the board.</p>`
359
+
360
+ writeFileSync(join(dir, 'index.html'), panelPage('Components', body))
361
+ }
362
+
341
363
  function isImage(name: string): boolean {
342
364
  return IMAGE_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext))
343
365
  }
@@ -437,12 +459,14 @@ export function generateBoard(
437
459
  title: 'Past candidates',
438
460
  path: 'candidates/index.html',
439
461
  },
462
+ { id: 'components', title: 'Components', path: 'components/index.html' },
440
463
  ]
441
464
 
442
465
  writeTokensPanel(root, outDir)
443
466
  writeSurfacesPanel(root, outDir)
444
467
  writeWireframesPanel(root, outDir)
445
468
  writeCandidatesPanel(root, outDir)
469
+ writeComponentsPanel(root, outDir)
446
470
 
447
471
  const indexPath = join(outDir, 'index.html')
448
472
  writeFileSync(indexPath, shellHtml(panels))
@@ -523,6 +523,8 @@ function describeShippedReference(reference: ShippedReference): string {
523
523
  return 'a bare standards/ path that has nothing to expand it in an installed plugin cache'
524
524
  case 'phase-label':
525
525
  return 'a phase label that names a board no target holds'
526
+ case 'rule-path':
527
+ return 'a numbered rule path that reaches a target only through a separate canon gov sync, and only where governance was installed at all'
526
528
  }
527
529
  }
528
530
 
@@ -579,8 +581,8 @@ export const shippedReferences: Measure = async (ctx) => {
579
581
  ),
580
582
  failure:
581
583
  found.length === 1
582
- ? `One reference in the shipped corpora resolves wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark the line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`
583
- : `${found.length} references in the shipped corpora resolve wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark each line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`,
584
+ ? `One reference in the shipped corpora resolves wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, state the fact a shipped skill body's cited rule enforces instead of its path, or mark the line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`
585
+ : `${found.length} references in the shipped corpora resolve wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, state the fact a shipped skill body's cited rule enforces instead of its path, or mark each line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`,
584
586
  }
585
587
  }
586
588
 
@@ -203,6 +203,22 @@ const STANDARDS_PATH = /(?<![\w./-])standards\/[^\s`)\]]*\.md\b/g
203
203
  */
204
204
  const PHASE_LABEL = /\bv\d+\.\d+(?!\.\d)\b/g
205
205
 
206
+ /**
207
+ * A `.claude/rules/<segments>/<nnn>-<slug>.md` citation from a body under
208
+ * `claude/skills/`, the installed-path spelling `598-authoring-layout.md`
209
+ * bans a shipped skill body from citing as authority for its own behavior.
210
+ *
211
+ * Anchored on the trailing `\d{3}-[\w-]+\.md` rather than on the bare
212
+ * `.claude/rules/` prefix, which is what keeps a folder mention carrying no
213
+ * number, such as `create-rule`, `memory-review`, and `setup-gov` already
214
+ * write correctly, from matching. The segment group between `rules/` and the
215
+ * numbered file admits both a governance-namespace path
216
+ * (`canon/core/055-scratch.md`) and a project-namespace one
217
+ * (`project/<subdir>/<n>-<slug>.md`) without distinguishing them, since
218
+ * either shape is the same broken citation.
219
+ */
220
+ const RULE_PATH = /(?<![\w./-])\.claude\/rules\/[^\s`)\]]*\/\d{3}-[\w-]+\.md\b/g
221
+
206
222
  export interface ShippedReference {
207
223
  readonly file: string
208
224
  /** One-based, matching the `file:line` form a reader clicks. */
@@ -213,6 +229,7 @@ export interface ShippedReference {
213
229
  | 'docs-path'
214
230
  | 'standards-path'
215
231
  | 'phase-label'
232
+ | 'rule-path'
216
233
  /** The reference as written, so a report names the token to qualify. */
217
234
  readonly text: string
218
235
  /**
@@ -259,14 +276,19 @@ function isDocsPathReportable(
259
276
  }
260
277
 
261
278
  /**
262
- * Whether `file` sits in the one corpus `STANDARDS_PATH` gates.
279
+ * Whether `file` sits in the one corpus `STANDARDS_PATH` and `RULE_PATH`
280
+ * both gate: a shipped skill body, minus its own `REQUIREMENT.md`.
263
281
  *
264
- * `REQUIREMENT.md` is excluded for the reason `598-authoring-layout.md`
265
- * leaves it alone: a maintainer or an audit command reads that file rather
266
- * than a session loading it, so the resolver rule this pattern enforces
267
- * never applies there.
282
+ * Both patterns share this scope because both bans live in
283
+ * `598-authoring-layout.md`, stated for the same reader: a session loading
284
+ * the `SKILL.md` body a target actually receives. `REQUIREMENT.md` is
285
+ * excluded for the reason that file states there, since a maintainer or an
286
+ * audit command reads it rather than a session loading it, so neither
287
+ * resolver rule this pair enforces ever applies to it. One predicate serves
288
+ * both call sites rather than two copies drifting apart with nothing
289
+ * comparing them.
268
290
  */
269
- function isStandardsPathScope(file: string): boolean {
291
+ function isSkillBodyScope(file: string): boolean {
270
292
  return file.startsWith('claude/skills/') && !file.endsWith('/REQUIREMENT.md')
271
293
  }
272
294
 
@@ -357,7 +379,7 @@ export function referencesIn(
357
379
  })
358
380
  }
359
381
 
360
- if (isStandardsPathScope(file)) {
382
+ if (isSkillBodyScope(file)) {
361
383
  for (const match of line.matchAll(STANDARDS_PATH)) {
362
384
  if (!isStandardsPathReportable(match[0])) continue
363
385
  references.push({
@@ -377,6 +399,18 @@ export function referencesIn(
377
399
  text: match[0],
378
400
  })
379
401
  }
402
+
403
+ if (isSkillBodyScope(file)) {
404
+ for (const match of line.matchAll(RULE_PATH)) {
405
+ if (isPlaceholderPath(match[0])) continue
406
+ references.push({
407
+ file,
408
+ line: index + 1,
409
+ kind: 'rule-path',
410
+ text: match[0],
411
+ })
412
+ }
413
+ }
380
414
  }
381
415
 
382
416
  return references
@@ -0,0 +1,11 @@
1
+ /** @jsxImportSource ../html */
2
+ import type { Child, Element } from '@/teach/html/jsx-runtime'
3
+
4
+ export interface HeadingProps {
5
+ readonly level: 1 | 2
6
+ readonly children: Child
7
+ }
8
+
9
+ export function Heading({ level, children }: HeadingProps): Element {
10
+ return level === 1 ? <h1>{children}</h1> : <h2>{children}</h2>
11
+ }
@@ -0,0 +1,17 @@
1
+ /** @jsxImportSource ../html */
2
+ import type { Child, Element } from '@/teach/html/jsx-runtime'
3
+
4
+ export interface ListProps {
5
+ readonly ordered?: boolean
6
+ readonly items: readonly Child[]
7
+ }
8
+
9
+ /**
10
+ * Composes an item per entry rather than taking pre-built `<li>` children, so
11
+ * every list in a lesson escapes the same way regardless of what the caller
12
+ * hands in.
13
+ */
14
+ export function List({ ordered, items }: ListProps): Element {
15
+ const rendered = items.map((item) => <li>{item}</li>)
16
+ return ordered ? <ol>{rendered}</ol> : <ul>{rendered}</ul>
17
+ }
@@ -0,0 +1,12 @@
1
+ /** @jsxImportSource ../html */
2
+ import type { Child, Element } from '@/teach/html/jsx-runtime'
3
+
4
+ export interface ParagraphProps {
5
+ /** The lesson's dek, read back by `extractLessonMeta` in `@/teach/nav`. */
6
+ readonly lede?: boolean
7
+ readonly children: Child
8
+ }
9
+
10
+ export function Paragraph({ lede, children }: ParagraphProps): Element {
11
+ return lede ? <p class="lede">{children}</p> : <p>{children}</p>
12
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Bun resolves this module rather than `jsx-runtime` at run time (spike 1,
3
+ * `.canon/groundwork/56-teach-render-layer/08-spikes.md`), so every export a
4
+ * caller might reach through either entry point has to exist here too.
5
+ */
6
+ export {
7
+ Fragment,
8
+ jsx,
9
+ jsx as jsxDEV,
10
+ jsxs,
11
+ render,
12
+ type JSX,
13
+ } from './jsx-runtime'
@@ -0,0 +1,113 @@
1
+ import { escape, isRaw, raw, type RawMarkup } from './raw'
2
+
3
+ export type Child =
4
+ | string
5
+ | number
6
+ | boolean
7
+ | null
8
+ | undefined
9
+ | RawMarkup
10
+ | readonly Child[]
11
+
12
+ export interface Props {
13
+ readonly children?: Child
14
+ readonly [key: string]: unknown
15
+ }
16
+
17
+ /**
18
+ * What a component returns and what `jsx` produces: markup already rendered,
19
+ * carrying the same brand `raw` gives an author's own escape hatch. One brand
20
+ * for both is what lets a parent's `{children}` compose a child's output
21
+ * without escaping it a second time, since a plain string cannot say whether
22
+ * it is author text or already-rendered markup.
23
+ */
24
+ export type Element = RawMarkup
25
+
26
+ export type Component<P extends Props = Props> = (props: P) => Element
27
+
28
+ export const Fragment = Symbol('Fragment')
29
+
30
+ const ARIA_PREFIX = 'aria-'
31
+
32
+ const VOID_ELEMENTS = new Set([
33
+ 'area',
34
+ 'base',
35
+ 'br',
36
+ 'col',
37
+ 'embed',
38
+ 'hr',
39
+ 'img',
40
+ 'input',
41
+ 'link',
42
+ 'meta',
43
+ 'source',
44
+ 'track',
45
+ 'wbr',
46
+ ])
47
+
48
+ function renderChild(child: Child): string {
49
+ if (child === null || child === undefined || typeof child === 'boolean') {
50
+ return ''
51
+ }
52
+ if (typeof child === 'number') return String(child)
53
+ if (typeof child === 'string') return escape(child)
54
+ if (isRaw(child)) return child.html
55
+ return child.map(renderChild).join('')
56
+ }
57
+
58
+ /**
59
+ * ARIA takes the literal word for a boolean, where HTML boolean attributes
60
+ * take presence or absence. Spike 1's first draft rendered a bare
61
+ * `aria-disabled` for `true` and dropped `aria-disabled={false}` entirely,
62
+ * silently removing state a screen reader reads.
63
+ */
64
+ function renderAttribute(key: string, value: unknown): string {
65
+ if (key.startsWith(ARIA_PREFIX) && typeof value === 'boolean') {
66
+ return ` ${key}="${value}"`
67
+ }
68
+ if (value === undefined || value === null || value === false) return ''
69
+ if (value === true) return ` ${key}`
70
+ if (isRaw(value)) return ` ${key}="${escape(value.html)}"`
71
+ return ` ${key}="${escape(String(value))}"`
72
+ }
73
+
74
+ function renderAttributes(props: Props): string {
75
+ return Object.entries(props)
76
+ .filter(([key]) => key !== 'children')
77
+ .map(([key, value]) => renderAttribute(key, value))
78
+ .join('')
79
+ }
80
+
81
+ export function jsx(
82
+ type: string | Component | typeof Fragment,
83
+ props: Props,
84
+ ): Element {
85
+ if (type === Fragment) return raw(renderChild(props.children ?? null))
86
+ if (typeof type === 'function') return type(props)
87
+
88
+ const attrs = renderAttributes(props)
89
+ if (VOID_ELEMENTS.has(type)) return raw(`<${type}${attrs}>`)
90
+
91
+ return raw(
92
+ `<${type}${attrs}>${renderChild(props.children ?? null)}</${type}>`,
93
+ )
94
+ }
95
+
96
+ export const jsxs = jsx
97
+
98
+ /** Unwraps a rendered element to the plain string a file writes to disk. */
99
+ export function render(element: Element): string {
100
+ return element.html
101
+ }
102
+
103
+ export namespace JSX {
104
+ export type Element = RawMarkup
105
+
106
+ export interface ElementChildrenAttribute {
107
+ children: Record<string, never>
108
+ }
109
+
110
+ export interface IntrinsicElements {
111
+ [name: string]: Props
112
+ }
113
+ }
@@ -0,0 +1,28 @@
1
+ const RAW = Symbol('raw')
2
+
3
+ /**
4
+ * Markup already rendered, exempt from the escaping every other string
5
+ * receives. `raw` is the only way to produce one, so a lesson has to opt in
6
+ * explicitly rather than an author text string accidentally passing through
7
+ * unescaped.
8
+ */
9
+ export interface RawMarkup {
10
+ readonly [RAW]: true
11
+ readonly html: string
12
+ }
13
+
14
+ export function raw(html: string): RawMarkup {
15
+ return { [RAW]: true, html }
16
+ }
17
+
18
+ export function isRaw(value: unknown): value is RawMarkup {
19
+ return typeof value === 'object' && value !== null && RAW in value
20
+ }
21
+
22
+ export function escape(value: string): string {
23
+ return value
24
+ .replace(/&/g, '&amp;')
25
+ .replace(/</g, '&lt;')
26
+ .replace(/>/g, '&gt;')
27
+ .replace(/"/g, '&quot;')
28
+ }
@@ -0,0 +1,95 @@
1
+ /** @jsxImportSource ./html */
2
+ import { mkdir, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { TEACH_STYLESHEET_COMPONENTS } from '@/design/components'
5
+ import { buildDesignCss } from '@/design/css'
6
+ import { TEACH_FONT_FACES } from '@/teach/fonts'
7
+ import { Heading } from '@/teach/components/heading'
8
+ import { List } from '@/teach/components/list'
9
+ import { Paragraph } from '@/teach/components/paragraph'
10
+ import { render } from '@/teach/html/jsx-runtime'
11
+
12
+ /**
13
+ * Regenerates the committed fixture lesson under
14
+ * `examples/teach/00-fixture/`, the same shape `examples/slides/showcase.md`
15
+ * takes against its own hand-rendered snapshot. Run with
16
+ * `bun src/teach/render-fixture.tsx` after a component changes shape.
17
+ */
18
+
19
+ const FIXTURE_ROOT = join(
20
+ import.meta.dir,
21
+ '..',
22
+ '..',
23
+ 'examples',
24
+ 'teach',
25
+ '00-fixture',
26
+ )
27
+ const LESSON_TITLE = 'Compass bearings'
28
+ const LESSON_FILE = '0001-compass-bearings.html'
29
+
30
+ const STEPS = [
31
+ 'Point the direction-of-travel arrow at the landmark.',
32
+ 'Rotate the bezel until the orienting arrow lines up with the needle.',
33
+ 'Read the bearing where the direction-of-travel arrow meets the bezel.',
34
+ ]
35
+
36
+ const body = (
37
+ <>
38
+ <Heading level={1}>{LESSON_TITLE}</Heading>
39
+ <Paragraph lede>
40
+ A bearing is the compass direction from where you stand to whatever you
41
+ are aiming at, measured clockwise from north.
42
+ </Paragraph>
43
+ <Paragraph>
44
+ Hold the compass level and let the needle settle before reading anything
45
+ off it. A bearing taken while walking or tilted reads confidently and
46
+ wrong.
47
+ </Paragraph>
48
+ <Paragraph>
49
+ Three steps turn a sighted landmark into a number you can act on:
50
+ </Paragraph>
51
+ <List ordered items={STEPS} />
52
+ <Paragraph>
53
+ The same three steps run in reverse turn a bearing on a map into a
54
+ direction to walk, which is the only reason to learn them in this order.
55
+ </Paragraph>
56
+ </>
57
+ )
58
+
59
+ function page(title: string, main: string): string {
60
+ return `<!doctype html>
61
+ <html lang="en">
62
+ <head>
63
+ <meta charset="utf-8">
64
+ <meta name="viewport" content="width=device-width, initial-scale=1">
65
+ <title>${title}</title>
66
+ <link rel="stylesheet" href="../assets/course.css">
67
+ </head>
68
+ <body>
69
+ <main class="wide-body">
70
+ ${main}
71
+ </main>
72
+ </body>
73
+ </html>
74
+ `
75
+ }
76
+
77
+ async function main(): Promise<void> {
78
+ await mkdir(join(FIXTURE_ROOT, 'lessons'), { recursive: true })
79
+ await mkdir(join(FIXTURE_ROOT, 'assets'), { recursive: true })
80
+
81
+ await writeFile(
82
+ join(FIXTURE_ROOT, 'assets', 'course.css'),
83
+ buildDesignCss(undefined, {
84
+ embedFonts: TEACH_FONT_FACES,
85
+ components: TEACH_STYLESHEET_COMPONENTS,
86
+ }),
87
+ )
88
+
89
+ await writeFile(
90
+ join(FIXTURE_ROOT, 'lessons', LESSON_FILE),
91
+ page(LESSON_TITLE, render(body)),
92
+ )
93
+ }
94
+
95
+ await main()
package/tsconfig.json CHANGED
@@ -3,6 +3,7 @@
3
3
  "target": "ESNext",
4
4
  "module": "ESNext",
5
5
  "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
6
7
  "types": ["bun-types"],
7
8
  "strict": true,
8
9
  "skipLibCheck": true,