@erclx/canon 4.92.0 → 4.93.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.
- package/README.md +5 -5
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
- package/docs/agents/capture.md +12 -5
- package/docs/agents/commands.md +3 -3
- package/docs/agents/rule-citations.md +1 -1
- package/docs/agents/teach.md +1 -1
- package/governance/rules/lib/305-e2e-reliability.md +3 -2
- package/governance/rules/lib/306-test-scope.md +1 -2
- package/governance/stacks/astro.toml +1 -1
- package/package.json +3 -2
- package/scripts/core/regen-hero.sh +4 -3
- package/src/capture/render.ts +15 -1
- package/src/cli.ts +1 -1
- package/src/commands/capture.ts +25 -1
- package/src/commands/serve.ts +11 -1
- package/src/gate/measures.ts +5 -4
- package/src/gate/stages.ts +1 -1
- package/src/serve/static.ts +90 -6
- package/src/teach/render.ts +2 -2
- package/tooling/astro/configs/playwright.config.ts +2 -0
- package/tooling/nextjs/configs/playwright.config.ts +2 -0
- package/tooling/vite-react/configs/playwright.config.ts +2 -0
- package/src/teach/render-fixture.tsx +0 -95
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
canon is a CLI and Claude Code plugin that stops your AI conventions drifting apart across repositories. It keeps one authoritative copy and installs it into each project on demand.
|
|
8
8
|
|
|
9
|
-

|
|
9
|
+

|
|
10
10
|
|
|
11
11
|
The counts above are read from the catalogs when the image is built, so they're what the repo actually ships today. See it live at [canon.erclx.dev](https://canon.erclx.dev).
|
|
12
12
|
|
|
@@ -39,7 +39,7 @@ claude plugin marketplace add https://github.com/erclx/canon
|
|
|
39
39
|
claude plugin install canon@canon
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-

|
|
42
|
+

|
|
43
43
|
|
|
44
44
|
The skills land as `/canon:<name>`. If your session was already open, run `/reload-plugins` to pick them up.
|
|
45
45
|
|
|
@@ -67,19 +67,19 @@ Each domain has a canonical source in this repo and a thin install or sync CLI o
|
|
|
67
67
|
|
|
68
68
|
Those domains split on one line: some are copied into your project and become yours to edit, and some are never copied at all.
|
|
69
69
|
|
|
70
|
-

|
|
70
|
+

|
|
71
71
|
|
|
72
72
|
A tooling stack lands as real files under version control, because a config is something your build reads and your project owns. A standard stays here and is opened by name, so there is no copy in your repo to drift from this one.
|
|
73
73
|
|
|
74
74
|
Governance is the third shape, and it is worth seeing rather than reading about, because the glob beside each rule is what decides whether it reaches a session at all.
|
|
75
75
|
|
|
76
|
-

|
|
76
|
+

|
|
77
77
|
|
|
78
78
|
Stacks compose, so a react project inherits node and node inherits base. A rule with a glob loads only when a matching path is edited, and a rule with none loads every session. Both counts and every row above are read from the catalogs when the image is built.
|
|
79
79
|
|
|
80
80
|
The toolkit tracks its own work the same way, in a task board no catalog can read back.
|
|
81
81
|
|
|
82
|
-

|
|
82
|
+

|
|
83
83
|
|
|
84
84
|
`.canon/tasks/` is gitignored session scratch, so unlike the two frames above, this one is a hand-taken snapshot rather than something the build reads live, and it goes stale the moment the board moves.
|
|
85
85
|
|
|
@@ -73,7 +73,7 @@ The stepper hides and shows and sets nothing else, so how a selected option look
|
|
|
73
73
|
|
|
74
74
|
## The block list
|
|
75
75
|
|
|
76
|
-
`canon teach render` takes a JSON array of blocks and renders it through the same components
|
|
76
|
+
`canon teach render` takes a JSON array of blocks and renders it through the same components a lesson body composes. Four types, and every structural body composes from them:
|
|
77
77
|
|
|
78
78
|
- `{"type":"heading","level":1|2,"text":"<text>"}`: an `<h1>` or `<h2>`.
|
|
79
79
|
- `{"type":"paragraph","text":"<text>","lede":true}`: a `<p>`, marked `lede` for the dek that opens the lesson.
|
package/docs/agents/capture.md
CHANGED
|
@@ -5,11 +5,11 @@ description: Rendering HTML sources to PNG, what the command asserts about fonts
|
|
|
5
5
|
|
|
6
6
|
# Capture
|
|
7
7
|
|
|
8
|
-
`canon capture [source] --selector <sel>` renders HTML sources to PNG, which is how a generated documentation image is rebuilt from the markup it was generated out of. The source defaults to `assets/`, where a directory expands to every `.html` directly inside it, so adding a capture means dropping a file beside the first one and running the same command. That default is a generic starting point rather than a convention every target shares, and it stays because a missing folder still refuses loud, naming the argument, rather than failing silently. This repository no longer takes it: its own sources moved to `assets/captures/` and its images
|
|
8
|
+
`canon capture [source] --selector <sel>` renders HTML sources to PNG, which is how a generated documentation image is rebuilt from the markup it was generated out of. The source defaults to `assets/`, where a directory expands to every `.html` directly inside it, so adding a capture means dropping a file beside the first one and running the same command. That default is a generic starting point rather than a convention every target shares, and it stays because a missing folder still refuses loud, naming the argument, rather than failing silently. This repository no longer takes it: its own sources moved to `assets/captures/` and its images moved to `assets/evidence/`, so a run here names the source explicitly and sends the output where that run wants it, `--out assets/evidence` to rebuild the committed images and somewhere disposable to preview them.
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
canon capture --selector .window
|
|
12
|
-
canon capture assets/captures/install.html --selector .window --out assets
|
|
12
|
+
canon capture assets/captures/install.html --selector .window --out assets/evidence
|
|
13
13
|
canon capture assets/captures --selector .window --out .canon/review/captures
|
|
14
14
|
canon capture https://example.com --selector .window --out preview.png
|
|
15
15
|
```
|
|
@@ -18,13 +18,13 @@ canon capture https://example.com --selector .window --out preview.png
|
|
|
18
18
|
|
|
19
19
|
## What this repository captures
|
|
20
20
|
|
|
21
|
-
A capture set here spans two folders. `assets/captures/` holds five sources and the template each is written from, and `assets/` holds the five images a document points at with the stamp answering for each. One run over the source folder rebuilds every image, and it takes `--out assets` to put each PNG where its document points rather than beside the markup it rendered. None of the five is edited by hand. `scripts/core/regen-hero.sh` writes each `.html` from the template beside it, filling one shared value map into all of them, and `bun run check` regenerates them and fails on the difference.
|
|
21
|
+
A capture set here spans two folders. `assets/captures/` holds five sources and the template each is written from, and `assets/evidence/` holds the five images a document points at with the stamp answering for each, under the segment `canon pr evidence` compares. One run over the source folder rebuilds every image, and it takes `--out assets/evidence` to put each PNG where its document points rather than beside the markup it rendered. None of the five is edited by hand. `scripts/core/regen-hero.sh` writes each `.html` from the template beside it, filling one shared value map into all of them, and `bun run check` regenerates them and fails on the difference.
|
|
22
22
|
|
|
23
23
|
Three of the five take catalog data, so a stack gaining a rule moves the frame on the next run. `install.html` and `task-board.html` are the two exceptions: the first holds terminal text from a real run and the second holds a hand-frozen snapshot of a gitignored board, each in its template rather than derived from a live catalog at build time.
|
|
24
24
|
|
|
25
|
-
`assets/captures/` is read flat and never descends, by the regeneration script, by this command, and by the drift stage alike. A source nested a further folder down is skipped by all three with nothing reported, so a new frame is a template dropped directly in rather than a folder of its own. The split is what retired the name prefix the flat layout used to need: a frame is named for itself now, since `assets/` no longer mixes markup in with the images.
|
|
25
|
+
`assets/captures/` is read flat and never descends, by the regeneration script, by this command, and by the drift stage alike. A source nested a further folder down is skipped by all three with nothing reported, so a new frame is a template dropped directly in rather than a folder of its own. The split is what retired the name prefix the flat layout used to need: a frame is named for itself now, since `assets/captures/` no longer mixes markup in with the images.
|
|
26
26
|
|
|
27
|
-
Only the HTML is asserted for drift. The PNG is a chromium render whose bytes move with the browser version, so rebuild it with `canon capture assets/captures --selector .window --out assets` when the check reports the HTML changed.
|
|
27
|
+
Only the HTML is asserted for drift. The PNG is a chromium render whose bytes move with the browser version, so rebuild it with `canon capture assets/captures --selector .window --out assets/evidence` when the check reports the HTML changed.
|
|
28
28
|
|
|
29
29
|
Every render writes a stamp beside its PNG, `hero.png` next to `hero.stamp`, holding the source filename, a `source-sha256` over the markup bytes it read, and an `image-sha256` over the image bytes it wrote. Both digests are what `bun run check` compares, so a markup edit committed without a capture and a PNG swapped under unchanged markup each fail. The stamp is tracked and commits alongside the pair. A capture that cannot write it reports that source as failed and exits 1, so an image whose stamp never landed is reported rather than passed over.
|
|
30
30
|
|
|
@@ -34,6 +34,7 @@ Neither digest is ever written by hand. A digest is what the gate compares, so a
|
|
|
34
34
|
| ------------------ | ------------------------------------------------------------------------------------------------------ |
|
|
35
35
|
| `--out <dir>` | Write every PNG here instead of beside its source. For a URL source, names the destination PNG itself. |
|
|
36
36
|
| `--selector <sel>` | Element to capture, required and never defaulted |
|
|
37
|
+
| `--width <px>` | Render at this viewport width so a media query resolves there. One whole number of 1 or more. |
|
|
37
38
|
|
|
38
39
|
## What the command asserts
|
|
39
40
|
|
|
@@ -50,3 +51,9 @@ The command ships to targets, alongside `demo`, `inventory`, and `drive`. It was
|
|
|
50
51
|
Shipping it also fixed what the exclusion was hiding. The render module imported the `@playwright/test` development dependency, which no published tarball carries. Every browser reference still sits behind a dynamic import, so a browser loads for this command rather than in front of every other one.
|
|
51
52
|
|
|
52
53
|
`demo.md` and `driver.md` cover two of the other three browser commands. What separates this one is that a capture renders a single state, from a file on disk or a named `http(s)://` URL, where the rest drive a running application.
|
|
54
|
+
|
|
55
|
+
## Capturing at a width
|
|
56
|
+
|
|
57
|
+
`--width` sets the browser viewport and leaves the page alone. A media query answers to the viewport, so a wrapper narrowed to 390px renders a thin column of the desktop layout and proves nothing about the mobile one. The height stays at Playwright's default of 720, which keeps `vh` units where they are without the flag. A run that passes no `--width` passes no viewport option at all, so every committed image renders as before and no stamp digest moves.
|
|
58
|
+
|
|
59
|
+
The flag takes one integer and no list. A sweep across breakpoints is one run per width, and the caller picks `--out` for each, since two widths written into one directory overwrite each other under the source's name. A value that is not a whole number of 1 or more refuses ahead of the browser import, naming the flag.
|
package/docs/agents/commands.md
CHANGED
|
@@ -103,8 +103,8 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
103
103
|
| `canon gate run` | Run every stage that guards a branch here, scoping shell, types, and tests to the changed set (`--all`, `--no-write`, `--nested`, `--json`) |
|
|
104
104
|
| `canon inventory [subject]` | Walk every route a project declares and group its elements by the property each computes, as a listing rather than a gate (`--json`) |
|
|
105
105
|
| `canon drive <url> <run>` | Walk a page through named interactions and measure each state it reaches, reporting findings rather than gating (`--json`) |
|
|
106
|
-
| `canon capture [source]` | Render an HTML source or an `http(s)://` URL to PNG and prove the font each one declares resolved (`--selector` required, `--out`)
|
|
107
|
-
| `canon serve [dir]` | Serve a directory on the loopback interface and print the link that opens it, running until interrupted (`--port`, `--entry`, `--json`)
|
|
106
|
+
| `canon capture [source]` | Render an HTML source or an `http(s)://` URL to PNG and prove the font each one declares resolved (`--selector` required, `--out`, `--width`) |
|
|
107
|
+
| `canon serve [dir]` | Serve a directory on the loopback interface and print the link that opens it, running until interrupted (`--port`, `--entry`, `--index`, `--json`) |
|
|
108
108
|
| `canon upgrade` | Reinstall the CLI globally with the package manager the install path names (`--json`) |
|
|
109
109
|
|
|
110
110
|
`canon serve` drives no browser, which is what separates it from the four that do. All four ship now that `capture` does, so the line between them is the engine rather than the package. A generated page loses its script to an editor preview and to a `file://` open, so the link is the delivery rather than a convenience, and every generated surface here reaches a reader through one. A teach lesson's stylesheet is embedded rather than linked, so only its script still needs a server. It binds `127.0.0.1` and never a wildcard, because what it is pointed at is routinely a gitignored record tree. It sends `cache-control: no-store`, since a preview exists to be edited and reloaded and a cached stylesheet reads as a fix that did not work.
|
|
@@ -113,7 +113,7 @@ A port already in use is the ordinary case rather than a refusal, so it walks fo
|
|
|
113
113
|
|
|
114
114
|
A request naming a directory is redirected to its trailing-slash form rather than answered in place. A browser resolves a relative asset against the last slash of the URL it is on, so answering `/lesson` directly leaves the page asking for `/course.css` instead of `/lesson/course.css`, and it renders unstyled through the server that exists to prevent exactly that.
|
|
115
115
|
|
|
116
|
-
Containment is tested after symlinks are followed rather than on the path as written, and the test sits immediately before the read rather than beside the request that produced it. Resolving a request lexically clears a link pointing outside the served root, and this repository is a live instance of that shape, since `claude/standards` and `claude/snippets` are links out of `claude/`. Position is what makes the property hold: a directory request appends its index after the request path has been checked, so a check placed earlier leaves that index untested. An `--entry` that escapes the root refuses with `no-entry` before a port is taken, because `url` is the field a caller hands to a reader.
|
|
116
|
+
Containment is tested after symlinks are followed rather than on the path as written, and the test sits immediately before the read rather than beside the request that produced it. Resolving a request lexically clears a link pointing outside the served root, and this repository is a live instance of that shape, since `claude/standards` and `claude/snippets` are links out of `claude/`. Position is what makes the property hold: a directory request appends its index after the request path has been checked, so a check placed earlier leaves that index untested. `--index` answers a directory holding no `index.html` with a listing read per request, where the default stays a 404. Every entry the listing names takes the same containment test a request for it would, so a link out of the root is absent rather than greyed, and dotfiles are hidden. With no `--entry` and no root `index.html`, the printed link is `/` and `entryExists` reads true. An `--entry` that escapes the root refuses with `no-entry` before a port is taken, because `url` is the field a caller hands to a reader.
|
|
117
117
|
|
|
118
118
|
`canon demo` is the second browser command and the one that ships, since its purpose is running in a target rather than regenerating what this repository commits. It needs a browser binary the package does not carry, installed once with `bunx playwright install chromium`.
|
|
119
119
|
|
|
@@ -73,7 +73,7 @@ A rule under `governance/rules/` installs into a target, and its `paths:` entrie
|
|
|
73
73
|
|
|
74
74
|
`internal/rules/` ships nowhere. The tree it governs is the tree present, which makes the question answerable, and all 14 of its globs across 7 rules match at the commit this shipped on.
|
|
75
75
|
|
|
76
|
-
What that leaves unreached is a glob that matches real files and still reaches none of the work it was scoped at. `governance/rules/lib/305-e2e-reliability.md`
|
|
76
|
+
What that leaves unreached is a glob that matches real files and still reaches none of the work it was scoped at. `governance/rules/lib/305-e2e-reliability.md` scoped itself at `e2e/*.ts` and `e2e/**/*.ts` and now reads `**/e2e/**/*.ts`. No probe in this repository is written under `e2e/`, so the rule asking a session to watch a new guard fail never fired for the session writing guards. Resolution is mechanical and reach is a judgment about where the work happens, so only the first is here.
|
|
77
77
|
|
|
78
78
|
## The exemption marker
|
|
79
79
|
|
package/docs/agents/teach.md
CHANGED
|
@@ -141,7 +141,7 @@ The order is drawn here rather than instructed, and that is the point of the ver
|
|
|
141
141
|
|
|
142
142
|
## Render
|
|
143
143
|
|
|
144
|
-
`canon teach render` renders a lesson body's structural blocks to HTML, through the
|
|
144
|
+
`canon teach render` renders a lesson body's structural blocks to HTML, through the three lesson components. It takes no topic and no `--root`, since the verb is a stateless transform reading nothing off a workspace on disk.
|
|
145
145
|
|
|
146
146
|
```bash
|
|
147
147
|
echo '[{"type":"heading","level":1,"text":"Compass bearings"}]' | canon teach render --json
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Enforce settled waits and falsifiable guards in end-to-end tests
|
|
3
3
|
paths:
|
|
4
|
-
- 'e2e
|
|
5
|
-
- 'e2e/**/*.ts'
|
|
4
|
+
- '**/e2e/**/*.ts'
|
|
6
5
|
---
|
|
7
6
|
|
|
8
7
|
# End-to-end reliability standards
|
|
@@ -14,6 +13,8 @@ paths:
|
|
|
14
13
|
- Bound every settle with an explicit timeout.
|
|
15
14
|
- Do not raise a timeout to clear a failure that reproduces under load. Replace the wait with a settle.
|
|
16
15
|
- Do not read a value once after a pause. Poll it.
|
|
16
|
+
- Settle a smooth scroll on the `scrollend` event. Fall back to a stillness window only when the target was already in view.
|
|
17
|
+
- Run with motion reduced unless the test asserts motion, and opt back in per test.
|
|
17
18
|
|
|
18
19
|
## Falsifiable guards
|
|
19
20
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
extends = "node"
|
|
2
|
-
rules = ["210-astro", "350-security-web", "400-ui", "410-a11y", "420-forms", "430-ux-completeness", "440-surface-capture", "450-link-behavior", "460-design-taste", "470-motion"]
|
|
2
|
+
rules = ["210-astro", "300-testing-ts", "305-e2e-reliability", "306-test-scope", "350-security-web", "400-ui", "410-a11y", "420-forms", "430-ux-completeness", "440-surface-capture", "450-link-behavior", "460-design-taste", "470-motion"]
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erclx/canon",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.93.0",
|
|
5
5
|
"description": "Infrastructure and quality tooling for developer workflows",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"tsconfig.json",
|
|
20
20
|
"!scripts/sandbox",
|
|
21
21
|
"!scripts/eval",
|
|
22
|
-
"!**/*.test.ts"
|
|
22
|
+
"!**/*.test.ts",
|
|
23
|
+
"!**/*.test.tsx"
|
|
23
24
|
],
|
|
24
25
|
"publishConfig": {
|
|
25
26
|
"access": "public"
|
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
# Only the HTML regenerates here. The PNG beside it is a chromium render whose
|
|
19
19
|
# bytes move with the browser version, so asserting it in verify.sh would fail
|
|
20
20
|
# on a machine whose chromium differs rather than on a stale count. Rebuild the
|
|
21
|
-
# images with `canon capture assets/captures --selector .window --out assets`
|
|
21
|
+
# images with `canon capture assets/captures --selector .window --out assets/evidence`
|
|
22
22
|
# after this script reports a change. The markup and the image sit in two
|
|
23
|
-
# folders, so that run reads the sources here and sends every PNG and stamp
|
|
24
|
-
#
|
|
23
|
+
# folders, so that run reads the sources here and sends every PNG and stamp to
|
|
24
|
+
# assets/evidence/, where the documents point and where `canon pr evidence`
|
|
25
|
+
# compares a changed image. The selector has no default, since
|
|
25
26
|
# the element a capture crops to belongs to the page rather than to the command,
|
|
26
27
|
# and `.window` is the class this repository's own sources declare.
|
|
27
28
|
# That capture also writes a .stamp beside each PNG, which records the digest of
|
package/src/capture/render.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { formatStamp, hashSource, stampPath } from '@/capture/stamp'
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
const DEVICE_SCALE_FACTOR = 2
|
|
28
|
+
const DEFAULT_VIEWPORT_HEIGHT = 720
|
|
28
29
|
const FONT_PROBE_SIZE = 72
|
|
29
30
|
const FONT_PROBE_TEXT = 'canon capture 0123456789'
|
|
30
31
|
const ABSENT_FAMILY = '__canon_absent_family__'
|
|
@@ -32,6 +33,7 @@ const ABSENT_FAMILY = '__canon_absent_family__'
|
|
|
32
33
|
export interface CaptureOptions {
|
|
33
34
|
selector: string
|
|
34
35
|
outDir?: string
|
|
36
|
+
width?: number
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export type CaptureResult =
|
|
@@ -54,7 +56,9 @@ export async function captureSources(
|
|
|
54
56
|
const browser = await chromium.launch()
|
|
55
57
|
try {
|
|
56
58
|
return await Promise.all(
|
|
57
|
-
sources.map((source) =>
|
|
59
|
+
sources.map((source) =>
|
|
60
|
+
captureOne(browser, source, options.selector, options.width),
|
|
61
|
+
),
|
|
58
62
|
)
|
|
59
63
|
} finally {
|
|
60
64
|
await browser.close()
|
|
@@ -69,9 +73,19 @@ async function captureOne(
|
|
|
69
73
|
browser: Browser,
|
|
70
74
|
source: CaptureSource,
|
|
71
75
|
selector: string,
|
|
76
|
+
width: number | undefined,
|
|
72
77
|
): Promise<CaptureResult> {
|
|
78
|
+
/**
|
|
79
|
+
* A viewport rather than a wrapper, since a media query answers to the
|
|
80
|
+
* viewport. Absent leaves the option out so the browser default stands and
|
|
81
|
+
* every committed capture renders as before. The height is Playwright's own
|
|
82
|
+
* default, which keeps `vh` units where they were.
|
|
83
|
+
*/
|
|
73
84
|
const page = await browser.newPage({
|
|
74
85
|
deviceScaleFactor: DEVICE_SCALE_FACTOR,
|
|
86
|
+
...(width === undefined
|
|
87
|
+
? {}
|
|
88
|
+
: { viewport: { width, height: DEFAULT_VIEWPORT_HEIGHT } }),
|
|
75
89
|
})
|
|
76
90
|
try {
|
|
77
91
|
await page.goto(
|
package/src/cli.ts
CHANGED
|
@@ -121,7 +121,7 @@ function showHelp(): void {
|
|
|
121
121
|
`${GREY}│${NC} canon design board`,
|
|
122
122
|
`${GREY}│${NC} canon slides render`,
|
|
123
123
|
`${GREY}│${NC} canon slides list --json`,
|
|
124
|
-
`${GREY}│${NC} canon capture assets/captures/install.html --selector .window --out assets`,
|
|
124
|
+
`${GREY}│${NC} canon capture assets/captures/install.html --selector .window --out assets/evidence`,
|
|
125
125
|
`${GREY}│${NC} canon serve .canon/teach`,
|
|
126
126
|
`${GREY}│${NC} canon inventory focus --json`,
|
|
127
127
|
`${GREY}│${NC} canon drive http://localhost:4173 run.json --json`,
|
package/src/commands/capture.ts
CHANGED
|
@@ -54,10 +54,14 @@ export function register(program: Command): void {
|
|
|
54
54
|
'Output directory for a file source, or the destination PNG for a URL source; defaults beside the source',
|
|
55
55
|
)
|
|
56
56
|
.option('-s, --selector <selector>', 'Element to capture')
|
|
57
|
+
.option(
|
|
58
|
+
'--width <px>',
|
|
59
|
+
'Viewport width in pixels, so a media query resolves at that width',
|
|
60
|
+
)
|
|
57
61
|
.action(
|
|
58
62
|
async (
|
|
59
63
|
source: string,
|
|
60
|
-
opts: { out?: string; selector?: string },
|
|
64
|
+
opts: { out?: string; selector?: string; width?: string },
|
|
61
65
|
): Promise<void> => {
|
|
62
66
|
/**
|
|
63
67
|
* Refused rather than defaulted, and refused ahead of every other
|
|
@@ -75,6 +79,15 @@ export function register(program: Command): void {
|
|
|
75
79
|
}
|
|
76
80
|
const selector = opts.selector
|
|
77
81
|
|
|
82
|
+
const width = parseWidth(opts.width)
|
|
83
|
+
if (Number.isNaN(width)) {
|
|
84
|
+
frameError(
|
|
85
|
+
`--width takes a whole number of pixels of 1 or more, got ${opts.width}.`,
|
|
86
|
+
)
|
|
87
|
+
process.exitCode = 1
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
78
91
|
const isUrl = isUrlSource(source)
|
|
79
92
|
const sourcePath = isUrl ? source : resolve(process.cwd(), source)
|
|
80
93
|
if (!isUrl && !existsSync(sourcePath)) {
|
|
@@ -89,6 +102,7 @@ export function register(program: Command): void {
|
|
|
89
102
|
const renderer = await import('@/capture/render')
|
|
90
103
|
results = await renderer.captureSources(sourcePath, {
|
|
91
104
|
selector,
|
|
105
|
+
width,
|
|
92
106
|
outDir: opts.out ? resolve(process.cwd(), opts.out) : undefined,
|
|
93
107
|
})
|
|
94
108
|
} catch (error) {
|
|
@@ -122,6 +136,16 @@ export function register(program: Command): void {
|
|
|
122
136
|
)
|
|
123
137
|
}
|
|
124
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Undefined for an absent flag and `NaN` for a value that is not a positive
|
|
141
|
+
* integer, so the caller can tell no width from a bad one.
|
|
142
|
+
*/
|
|
143
|
+
function parseWidth(raw: string | undefined): number | undefined {
|
|
144
|
+
if (raw === undefined) return undefined
|
|
145
|
+
const value = Number(raw)
|
|
146
|
+
return Number.isInteger(value) && value >= 1 ? value : Number.NaN
|
|
147
|
+
}
|
|
148
|
+
|
|
125
149
|
/**
|
|
126
150
|
* Closes an open frame around a failure that stopped the whole run rather than
|
|
127
151
|
* one source. Two of them are setup states rather than defects and each names
|
package/src/commands/serve.ts
CHANGED
|
@@ -7,6 +7,7 @@ const DEFAULT_DIR = '.'
|
|
|
7
7
|
|
|
8
8
|
interface ServeCommandOptions {
|
|
9
9
|
readonly entry?: string
|
|
10
|
+
readonly index?: boolean
|
|
10
11
|
readonly json?: boolean
|
|
11
12
|
readonly port?: string
|
|
12
13
|
}
|
|
@@ -19,6 +20,10 @@ export function register(program: Command): void {
|
|
|
19
20
|
.helpOption('-h, --help', 'Show this help message')
|
|
20
21
|
.option('--port <number>', `Port to try first, default ${DEFAULT_PORT}`)
|
|
21
22
|
.option('--entry <path>', 'Page the printed link opens, default index.html')
|
|
23
|
+
.option(
|
|
24
|
+
'--index',
|
|
25
|
+
'List a directory that has no index.html instead of answering 404',
|
|
26
|
+
)
|
|
22
27
|
.option('--json', 'Emit a machine-readable record on stdout')
|
|
23
28
|
.addHelpText(
|
|
24
29
|
'after',
|
|
@@ -39,6 +44,7 @@ export function register(program: Command): void {
|
|
|
39
44
|
' canon serve .canon/teach',
|
|
40
45
|
' canon serve .canon/teach --entry 03-fde-system-design/index.html',
|
|
41
46
|
' canon serve dist --port 4000 --json',
|
|
47
|
+
' canon serve .canon/groundwork --index',
|
|
42
48
|
'',
|
|
43
49
|
].join('\n'),
|
|
44
50
|
)
|
|
@@ -61,7 +67,11 @@ async function runServe(
|
|
|
61
67
|
)
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
const outcome = startServer(dir, {
|
|
70
|
+
const outcome = startServer(dir, {
|
|
71
|
+
port,
|
|
72
|
+
entry: opts.entry,
|
|
73
|
+
index: opts.index,
|
|
74
|
+
})
|
|
65
75
|
const code = report(outcome, emitJson)
|
|
66
76
|
if (!outcome.ok) return code
|
|
67
77
|
|
package/src/gate/measures.ts
CHANGED
|
@@ -142,7 +142,7 @@ export function auditsBaselineRel(root: string): string {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
export const CAPTURE_STAMP_FAILURE =
|
|
145
|
-
'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets/captures --selector .window --out assets and commit each frame with its image and its stamp.'
|
|
145
|
+
'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets/captures --selector .window --out assets/evidence and commit each frame with its image and its stamp.'
|
|
146
146
|
|
|
147
147
|
function parseJson(payload: string): unknown {
|
|
148
148
|
try {
|
|
@@ -1118,11 +1118,12 @@ async function collectPluginManifests(ctx: MeasureContext): Promise<string[]> {
|
|
|
1118
1118
|
/**
|
|
1119
1119
|
* A capture set spans two folders. The markup and the template beside it are
|
|
1120
1120
|
* the authored half and sit under `assets/captures/`, while the image a
|
|
1121
|
-
* document points at and the stamp answering for it
|
|
1122
|
-
* where `--out assets` sends them
|
|
1121
|
+
* document points at and the stamp answering for it sit under
|
|
1122
|
+
* `assets/evidence/`, which is where `--out assets/evidence` sends them and where
|
|
1123
|
+
* `canon pr evidence` looks for a changed image.
|
|
1123
1124
|
*/
|
|
1124
1125
|
const CAPTURE_MARKUP_DIR = 'assets/captures'
|
|
1125
|
-
const CAPTURE_OUTPUT_DIR = 'assets'
|
|
1126
|
+
const CAPTURE_OUTPUT_DIR = 'assets/evidence'
|
|
1126
1127
|
|
|
1127
1128
|
/**
|
|
1128
1129
|
* Every capture under `assets/captures/`, named by the base its three files
|
package/src/gate/stages.ts
CHANGED
|
@@ -201,7 +201,7 @@ export const STAGES: readonly Stage[] = [
|
|
|
201
201
|
kind: 'drift',
|
|
202
202
|
pathspec: 'assets/captures/*.html',
|
|
203
203
|
failure:
|
|
204
|
-
'A generated frame drifted from the catalogs or the design source. Run bun run check, then canon capture assets/captures --selector .window --out assets, and commit each assets/captures/*.html with its .png and .stamp.',
|
|
204
|
+
'A generated frame drifted from the catalogs or the design source. Run bun run check, then canon capture assets/captures --selector .window --out assets/evidence, and commit each assets/captures/*.html with its .png and .stamp.',
|
|
205
205
|
},
|
|
206
206
|
{ kind: 'measure', measure: captureStamps },
|
|
207
207
|
],
|
package/src/serve/static.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, realpathSync, statSync } from 'node:fs'
|
|
1
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'
|
|
2
2
|
import { join, resolve, sep } from 'node:path'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -81,6 +81,66 @@ export type ServeOutcome = ServeStarted | ServeRefused
|
|
|
81
81
|
export interface ServeOptions {
|
|
82
82
|
readonly port?: number
|
|
83
83
|
readonly entry?: string
|
|
84
|
+
/** Answers a directory request with no index page by listing it. Off by default. */
|
|
85
|
+
readonly index?: boolean
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function escapeHtml(text: string): string {
|
|
89
|
+
return text
|
|
90
|
+
.replaceAll('&', '&')
|
|
91
|
+
.replaceAll('<', '<')
|
|
92
|
+
.replaceAll('>', '>')
|
|
93
|
+
.replaceAll('"', '"')
|
|
94
|
+
.replaceAll("'", ''')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface ListingEntry {
|
|
98
|
+
readonly name: string
|
|
99
|
+
readonly isDirectory: boolean
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reads the entries a listing may name. Each one takes the same containment
|
|
104
|
+
* test a request for it would, since a listed name is itself a fact about a
|
|
105
|
+
* place outside the root. Dotfiles are hidden because a listing is for
|
|
106
|
+
* navigating pages, and a hidden file is still reachable by its URL.
|
|
107
|
+
*/
|
|
108
|
+
function listEntries(root: string, directory: string): ListingEntry[] {
|
|
109
|
+
return readdirSync(directory)
|
|
110
|
+
.filter((name) => !name.startsWith('.'))
|
|
111
|
+
.filter((name) => !escapesThroughLink(root, join(directory, name)))
|
|
112
|
+
.map((name) => ({
|
|
113
|
+
name,
|
|
114
|
+
isDirectory:
|
|
115
|
+
statSync(join(directory, name), {
|
|
116
|
+
throwIfNoEntry: false,
|
|
117
|
+
})?.isDirectory() ?? false,
|
|
118
|
+
}))
|
|
119
|
+
.sort(
|
|
120
|
+
(a, b) =>
|
|
121
|
+
Number(b.isDirectory) - Number(a.isDirectory) ||
|
|
122
|
+
a.name.localeCompare(b.name),
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function renderListing(
|
|
127
|
+
pathname: string,
|
|
128
|
+
entries: readonly ListingEntry[],
|
|
129
|
+
hasParent: boolean,
|
|
130
|
+
): string {
|
|
131
|
+
const rows = entries.map(({ name, isDirectory }) => {
|
|
132
|
+
const label = isDirectory ? `${name}/` : name
|
|
133
|
+
return `<li><a href="${escapeHtml(encodeURIComponent(name))}${isDirectory ? '/' : ''}">${escapeHtml(label)}</a></li>`
|
|
134
|
+
})
|
|
135
|
+
if (hasParent) rows.unshift('<li><a href="../">../</a></li>')
|
|
136
|
+
return `<!doctype html>
|
|
137
|
+
<meta charset="utf-8">
|
|
138
|
+
<title>Index of ${escapeHtml(pathname)}</title>
|
|
139
|
+
<h1>Index of ${escapeHtml(pathname)}</h1>
|
|
140
|
+
<ul>
|
|
141
|
+
${rows.join('\n')}
|
|
142
|
+
</ul>
|
|
143
|
+
`
|
|
84
144
|
}
|
|
85
145
|
|
|
86
146
|
function refuse(reason: ServeRefusal, detail: string): ServeRefused {
|
|
@@ -165,13 +225,14 @@ export function shouldWalkPast(error: unknown): boolean {
|
|
|
165
225
|
function listen(
|
|
166
226
|
root: string,
|
|
167
227
|
first: number,
|
|
228
|
+
index: boolean,
|
|
168
229
|
): { server: ReturnType<typeof Bun.serve>; port: number } | undefined {
|
|
169
230
|
for (let port = first; port < first + PORT_ATTEMPTS; port++) {
|
|
170
231
|
try {
|
|
171
232
|
const server = Bun.serve({
|
|
172
233
|
hostname: SERVE_HOST,
|
|
173
234
|
port,
|
|
174
|
-
fetch: (request) => respond(root, request),
|
|
235
|
+
fetch: (request) => respond(root, request, index),
|
|
175
236
|
})
|
|
176
237
|
/**
|
|
177
238
|
* The bound port rather than the requested one. Port 0 asks the OS to
|
|
@@ -196,6 +257,7 @@ function listen(
|
|
|
196
257
|
export async function respond(
|
|
197
258
|
root: string,
|
|
198
259
|
request: Request,
|
|
260
|
+
index = false,
|
|
199
261
|
): Promise<Response> {
|
|
200
262
|
const { pathname, search } = new URL(request.url)
|
|
201
263
|
const forbidden = () =>
|
|
@@ -229,7 +291,19 @@ export async function respond(
|
|
|
229
291
|
headers: { location: `${pathname}/${search}` },
|
|
230
292
|
})
|
|
231
293
|
}
|
|
232
|
-
|
|
294
|
+
const indexPage = join(path, DEFAULT_ENTRY)
|
|
295
|
+
if (index && !existsSync(indexPage)) {
|
|
296
|
+
return new Response(
|
|
297
|
+
renderListing(pathname, listEntries(root, path), path !== root),
|
|
298
|
+
{
|
|
299
|
+
headers: {
|
|
300
|
+
'content-type': 'text/html; charset=utf-8',
|
|
301
|
+
'cache-control': 'no-store',
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
path = indexPage
|
|
233
307
|
}
|
|
234
308
|
|
|
235
309
|
/**
|
|
@@ -291,7 +365,7 @@ export function startServer(
|
|
|
291
365
|
*/
|
|
292
366
|
let bound: ReturnType<typeof listen>
|
|
293
367
|
try {
|
|
294
|
-
bound = listen(root, first)
|
|
368
|
+
bound = listen(root, first, options.index ?? false)
|
|
295
369
|
} catch (error) {
|
|
296
370
|
const code = (error as NodeJS.ErrnoException).code ?? 'unknown'
|
|
297
371
|
return refuse(
|
|
@@ -307,14 +381,24 @@ export function startServer(
|
|
|
307
381
|
)
|
|
308
382
|
}
|
|
309
383
|
|
|
384
|
+
/**
|
|
385
|
+
* A listing answers `/` where no index page exists, so under the flag the
|
|
386
|
+
* default entry is the root itself. An explicit `--entry` keeps its own link
|
|
387
|
+
* and its own warning.
|
|
388
|
+
*/
|
|
389
|
+
const listsRoot =
|
|
390
|
+
(options.index ?? false) &&
|
|
391
|
+
options.entry === undefined &&
|
|
392
|
+
!existsSync(entryPath)
|
|
393
|
+
|
|
310
394
|
return {
|
|
311
395
|
ok: true,
|
|
312
396
|
root,
|
|
313
397
|
host: SERVE_HOST,
|
|
314
398
|
port: bound.port,
|
|
315
399
|
entry,
|
|
316
|
-
url: `http://${SERVE_HOST}:${bound.port}/${entry}`,
|
|
317
|
-
entryExists: existsSync(entryPath),
|
|
400
|
+
url: `http://${SERVE_HOST}:${bound.port}/${listsRoot ? '' : entry}`,
|
|
401
|
+
entryExists: listsRoot || existsSync(entryPath),
|
|
318
402
|
stop: async () => {
|
|
319
403
|
await bound.server.stop(true)
|
|
320
404
|
},
|
package/src/teach/render.ts
CHANGED
|
@@ -109,8 +109,8 @@ function renderBlock(
|
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
111
|
* Renders a lesson body's structural blocks through the same components
|
|
112
|
-
* `
|
|
113
|
-
*
|
|
112
|
+
* `lesson.test.tsx` composes as JSX, so a real lesson and the component test
|
|
113
|
+
* share one rendering mechanism. Every block past the first refusal
|
|
114
114
|
* goes unread, matching how a malformed JSON parse refuses the whole call.
|
|
115
115
|
*/
|
|
116
116
|
export function renderLessonBody(blocks: readonly unknown[]): RenderOutcome {
|
|
@@ -13,6 +13,8 @@ export default defineConfig({
|
|
|
13
13
|
use: {
|
|
14
14
|
trace: 'on-first-retry',
|
|
15
15
|
baseURL,
|
|
16
|
+
// Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
|
|
17
|
+
reducedMotion: 'reduce',
|
|
16
18
|
},
|
|
17
19
|
projects: [
|
|
18
20
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
|
@@ -11,6 +11,8 @@ export default defineConfig({
|
|
|
11
11
|
use: {
|
|
12
12
|
trace: 'on-first-retry',
|
|
13
13
|
baseURL,
|
|
14
|
+
// Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
|
|
15
|
+
reducedMotion: 'reduce',
|
|
14
16
|
},
|
|
15
17
|
projects: [
|
|
16
18
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
|
@@ -13,6 +13,8 @@ export default defineConfig({
|
|
|
13
13
|
use: {
|
|
14
14
|
trace: 'on-first-retry',
|
|
15
15
|
baseURL,
|
|
16
|
+
// Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
|
|
17
|
+
reducedMotion: 'reduce',
|
|
16
18
|
},
|
|
17
19
|
projects: [
|
|
18
20
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
|
@@ -1,95 +0,0 @@
|
|
|
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()
|