@erclx/canon 4.7.0 → 4.8.1
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 +55 -41
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-docs/REQUIREMENT.md +2 -2
- package/claude/skills/claude-docs/SKILL.md +7 -36
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +13 -8
- package/claude/skills/claude-tasks/SKILL.md +4 -5
- package/claude/skills/claude-worker/SKILL.md +2 -2
- package/docs/agents/capture.md +17 -11
- package/docs/agents/commands.md +4 -4
- package/docs/agents/demo.md +1 -1
- package/docs/agents/driver.md +2 -2
- package/docs/agents/index.md +1 -1
- package/docs/agents/overview.md +3 -3
- package/docs/agents/state-scoped-risk.md +1 -1
- package/docs/agents/tasks.md +10 -4
- package/docs/ai-workflow.md +2 -4
- package/docs/zshrc-aliases.md +19 -7
- package/package.json +1 -2
- package/scripts/core/bootstrap.sh +4 -0
- package/scripts/core/regen-hero.sh +6 -3
- package/src/capture/render.ts +166 -0
- package/src/capture/sources.ts +42 -0
- package/src/capture/stamp.ts +52 -0
- package/src/cli.ts +1 -1
- package/src/commands/capture.ts +52 -41
- package/src/commands/tasks.ts +18 -1
- package/src/demo/drive.ts +5 -8
- package/src/driver/drive.ts +7 -8
- package/src/gate/measures.ts +1 -1
- package/src/gate/stages.ts +1 -1
- package/src/inventory/walk.ts +3 -3
- package/src/tasks/archive.ts +91 -20
- package/src/tasks/validate.ts +76 -0
- package/standards/tasks.md +4 -4
package/docs/agents/tasks.md
CHANGED
|
@@ -22,9 +22,13 @@ canon tasks archive --pull-request 673 --json
|
|
|
22
22
|
| `--json` | Emit a machine-readable record on stdout |
|
|
23
23
|
| `--root <path>` | Board root, defaulting to the main worktree |
|
|
24
24
|
|
|
25
|
-
Exit codes: `0` archived, `1` refused. Every gate is a refusal rather than a warning, because `.husky/post-merge` calls this with nobody watching. The `reason` field carries which gate fired: `no-board`, `no-match`, `ambiguous`, `no-outcomes`, `open-outcomes`,
|
|
25
|
+
Exit codes: `0` archived, `1` refused. Every gate is a refusal rather than a warning, because `.husky/post-merge` calls this with nobody watching. The `reason` field carries which gate fired: `no-board`, `no-match`, `ambiguous`, `no-outcomes`, `open-outcomes`, or `bad-input`.
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
The task carries its plan with it. When the closing task is the last live one whose `Plan:` line resolves onto that file, the plan moves to `.canon/plans/archive/` under its own name and the archived task's line is rewritten as `Plan: [feature-<slug>](../../plans/archive/feature-<slug>.md)`, a folder deeper than the live task wrote it. The `plan` field on the success record carries that `from` and `to`, and is `null` when nothing moved.
|
|
28
|
+
|
|
29
|
+
A plan several tasks share stays where it is, and the task archives anyway. Moving it on the first task to close strands every sibling's pointer at a path that has gone, and `.canon/plans/` is gitignored so no history recovers the target. A `Plan:` line resolving to no file leaves the plan alone too, since a pointer somebody typed wrong is not a plan to move and holding the whole archive over it would park the board behind a repair the merge cannot make.
|
|
30
|
+
|
|
31
|
+
The merge is what settles a plan. `.husky/post-merge` reads the pull request number off the squash subject and calls this verb, so both halves close in one act with nobody naming a file. That is why the move sits inside this verb rather than in a second call the hook would make after it, which could leave the task archived and the plan live.
|
|
28
32
|
|
|
29
33
|
`bad-input` covers a malformed command line, which all three task verbs answer the same way. It is separate from `ambiguous` and `no-match` because those describe the board, and a caller that passed two selectors would otherwise be sent to repair a task citation that is fine.
|
|
30
34
|
|
|
@@ -53,7 +57,7 @@ The record carries `location`, one of `unstated`, `live`, `archived`, or `outsid
|
|
|
53
57
|
|
|
54
58
|
The target resolves against the board folder and against the project root both, so `../plans/x.md` and `.canon/plans/x.md` land on the same file and one plan two tasks spelled differently counts once. Containment is tested at both record roots rather than at the one this tree resolves at, since a line somebody wrote against a root the tree has since left is still a path into the plans folder, and reading it as outside would report a shipped plan as still live. `docs/agents/records.md` states the read order.
|
|
55
59
|
|
|
56
|
-
`canon tasks archive`
|
|
60
|
+
`canon tasks archive` decides its plan move on this same answer, so a caller wanting the count reads it here rather than scanning the board.
|
|
57
61
|
|
|
58
62
|
Branch on `reason` rather than on the exit code, which is the rule the archive section above already states and which this verb needs for a second reason. An operator's shell profile may wrap `canon` in a function that runs the binary and then another command and takes the second status, which masks every non-zero exit rather than only an absent verb. The binary exits 1 for an unknown subcommand and 1 for an ordinary refusal alike, so the record is the only signal that survives the wrapper.
|
|
59
63
|
|
|
@@ -159,7 +163,7 @@ Seven checks run. Plan and Collisions reach one half each of the `## Run now` te
|
|
|
159
163
|
| Check | What it reports |
|
|
160
164
|
| ---------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
161
165
|
| Shape | A row whose cell count disagrees with its table's header, or one stranded behind a table a blank line already closed |
|
|
162
|
-
| Plan | A `## Run now` row whose Plan column carries no link, or
|
|
166
|
+
| Plan | A `## Run now` row whose Plan column carries no link, resolves to no file, or disagrees with the task's own line |
|
|
163
167
|
| Mapping | A row or backlog line naming no task file, and a task file neither surface names |
|
|
164
168
|
| Grouping | A task carrying a row in more than one readiness group, or on both surfaces |
|
|
165
169
|
| Ordering | A `## Needs a plan` row whose stated position disagrees with where it actually sits |
|
|
@@ -168,6 +172,8 @@ Seven checks run. Plan and Collisions reach one half each of the `## Run now` te
|
|
|
168
172
|
|
|
169
173
|
Shape runs before any other check reads a row, since a row failing it carries no dependable fields for the rest to check. A blank or prose line closes the table above it, so the walk treats the next pipe line as a fresh header candidate rather than as a continuation. That candidate counts as a header only when the line behind it is a separator carrying the same cell count, and one that fails is `row-untabled`, stranded behind a table that already closed. Cell count still has to match the header on every row that clears that test, and a row whose count disagrees is `row-misshapen`, the shape a dropped pipe or a merged column produces.
|
|
170
174
|
|
|
175
|
+
The Plan check reads the row and the task file both, because the two are written by different hands and only the task's own `Plan:` line reaches the archive. A row carrying a plan whose task states none is `plan-uncited`, and a pair naming two different plans is `plan-mismatched`. Both sides resolve against the board and against the project root before they compare, so a row writing `../plans/x.md` and a task writing `.canon/plans/x.md` name one file rather than two.
|
|
176
|
+
|
|
171
177
|
Mapping spans two surfaces, because a task sits on `priority.md` when it would plausibly be planned soon and on `backlog.md` otherwise. A task file either surface names is accounted for, a file neither names is `row-missing`, and a file both name is `row-duplicated` for the reason a task in two groups is: it claims two things about itself and only one can hold. One check across both is what lets a task move between them without the move reading as a dropped file.
|
|
172
178
|
|
|
173
179
|
A backlog line is a bullet carrying a link to a sibling task, since the backlog is a flat unordered list rather than a table. A bullet holding prose is skipped rather than reported, which keeps the file's own intro out of the findings, and the task that bullet meant to name is still reported as reaching neither surface. A project carrying no `backlog.md` reads as an empty backlog rather than a refusal, which leaves the one-to-one mapping this check ran before the second surface existed.
|
package/docs/ai-workflow.md
CHANGED
|
@@ -108,12 +108,10 @@ A person points it at a private repository once and both verbs refuse until they
|
|
|
108
108
|
|
|
109
109
|
`canon records size` reports what each of these folders holds, heaviest first, along with `.canon/tmp`. Each row carries the file count, the bytes, how many files were written in the last 7 and 30 days, and the dates of the least and most recently written one. Nothing fails on a number, because a record folder has no correct size. What the verb replaces is a reading somebody had to remember to take: the memory pen went from 44 entries to 236 between two counts made by hand a fortnight apart, and nothing reported the rate in between.
|
|
110
110
|
|
|
111
|
-
A plan that ships is archived, never deleted. `canon
|
|
111
|
+
A plan that ships is archived, never deleted. `canon tasks archive` moves it to `.canon/plans/archive/` alongside the task it belonged to and retargets that task's `Plan:` line at the new location, so a completed task still leads to the reasoning behind it. An archive sits inside the record folder it archives rather than beside it, so one ignore entry and one backed-folder entry cover a record and everything it has retired. The folder is gitignored, which is why a deleted plan had no recovery path. A plan cited by more than one task stays put until the last of them closes, since moving it early would strand every other pointer.
|
|
112
112
|
|
|
113
113
|
A branch review report takes the other route and is swept rather than archived. `claude-review` writes it to `.canon/review/branch/`, the session addressing it reads it once, and the durable record of what a review found is the comment `claude-pr-review` posts on the pull request, so `claude-docs` deletes any report whose branch is gone. The body that writes a report owns how long it lives, which leaves the shipping branch's own report on disk through the run that cites it and collects it a branch later. What that loses is a local-only review on a branch that never opened a pull request, which is why the report says so where a reader meets it.
|
|
114
114
|
|
|
115
|
-
The plans sweep reads the whole board rather than the tasks the session touched. It is the one place the skill reaches past its own rule against editing a task file the session did not change, because a task that closed while an earlier run missed its archive is exactly what the sweep exists to clear. Reaching it is safe: the archive moves the plan and retargets the pointer in the same pass, so an untouched task ends up with a working link rather than a broken one.
|
|
116
|
-
|
|
117
115
|
`canon:claude-docs` decides which task closed by reading the diff rather than the conversation. It resolves a merge base against `origin/main`, unions the committed diff with the working tree and untracked files, then matches unchecked outcomes on the board against what shipped. A task that shipped without ever being discussed still gets marked. Requirements, architecture, and design stay session-sourced, because a diff cannot carry a judgment.
|
|
118
116
|
|
|
119
117
|
`.canon/tasks/` is gitignored and resolves at the main worktree root, so every session shares one board. One file per task is what keeps concurrent sessions from overwriting each other, since a gitignored board has no history to recover a lost write from. Its `index.md` is generated by a hook rather than by `bun run check`, because the whole-repo index walk skips gitignored folders.
|
|
@@ -126,7 +124,7 @@ Past that shape, it checks what a surviving row claims against what the tree hol
|
|
|
126
124
|
|
|
127
125
|
`canon:claude-tasks` owns the two operations that bracket a task's life. It creates the file, holding the filename convention and the frontmatter contract so a malformed write cannot break the index for every sibling, and it moves a shipped task to `.canon/tasks/archive/`. Creation is where the origin invariant is enforced: every task names a plan, a groundwork folder, an intake folder, or an issue, since a task with no origin is either lost context or work nobody decided to do.
|
|
128
126
|
|
|
129
|
-
Archiving a task
|
|
127
|
+
Archiving a task carries its plan with it, in the same act. The merge is what settles a plan, and the hook below reaches the archive with nobody watching, so a second call after it would be a second failure point leaving the task archived and the plan live. A task whose plan a sibling still cites archives on its own and leaves the plan live, since moving it on the first task to close strands every other pointer at a path that has gone.
|
|
130
128
|
|
|
131
129
|
Nothing chained that archive until the `post-merge` git hook landed. Every earlier step fires from `canon:claude-autoship` or `canon:git-ship`, both of which finish while the pull request is still open, so a task archived there would close for work that may be abandoned. The board is gitignored, which rules out reading it from anywhere but the machine that pulled. The hook names the board's archive candidates and stays silent otherwise, including on a project with no board.
|
|
132
130
|
|
package/docs/zshrc-aliases.md
CHANGED
|
@@ -12,6 +12,8 @@ Claude Code auto-discovers the toolkit plugin from `claude/.claude-plugin/plugin
|
|
|
12
12
|
|
|
13
13
|
`bun run bootstrap` installs this block. It owns the canonical copy in `scripts/core/bootstrap.sh`, appends it to `~/.zshrc` wrapped in the two marker comments below, and sets `TOOLKIT` to the cloned path. The markers are how a re-run recognizes its own block and skips.
|
|
14
14
|
|
|
15
|
+
Skipping means a re-run never updates a block already installed, so a shell that ran the bootstrap before the canonical block grew keeps whatever it got that day. To pick up an alias added since, delete the marked block and re-run the bootstrap, or paste the new lines into the block by hand.
|
|
16
|
+
|
|
15
17
|
A block installed by hand before the managed one has no markers. Bootstrap detects that case through the `alias clp=` line instead, warns, and leaves the block untouched rather than appending a second copy. To switch over, delete the hand-rolled block and re-run the bootstrap.
|
|
16
18
|
|
|
17
19
|
```zsh
|
|
@@ -27,26 +29,34 @@ alias cls='cl --model sonnet'
|
|
|
27
29
|
alias clp='claude --plugin-dir $TOOLKIT/claude'
|
|
28
30
|
alias clpc='clp -c'
|
|
29
31
|
alias clps='clp --model sonnet'
|
|
32
|
+
alias clpa='clp agents'
|
|
33
|
+
alias clpac='clpa --cwd .'
|
|
30
34
|
# <<< canon aliases <<<
|
|
31
35
|
```
|
|
32
36
|
|
|
33
|
-
The block sits after any `PATH` mutations and the `claude` CLI install. Zsh expands aliases recursively on the first word, so `clr`, `clc`, `clw`, and `cls` inherit their base through `cl`, and `clpc` and `
|
|
37
|
+
The block sits after any `PATH` mutations and the `claude` CLI install. Zsh expands aliases recursively on the first word, so `clr`, `clc`, `clw`, and `cls` inherit their base through `cl`, and `clpc`, `clps`, and `clpa` inherit `--plugin-dir` through `clp`. `clpac` inherits it one level further, through `clpa`. `$TOOLKIT` expands at invocation time, so updating the variable and re-sourcing reroutes all `clp` calls without touching the alias definitions.
|
|
38
|
+
|
|
39
|
+
To opt out, delete the block between the two markers. Bootstrap re-adds it on the next run, so skip that step by running `bun install` and `bun link` yourself instead.
|
|
34
40
|
|
|
35
41
|
## What each one does
|
|
36
42
|
|
|
37
|
-
`cl`
|
|
43
|
+
`cl`, `clw`, and `cls` carry no explicit plugin dir and start a fresh session. Use them inside the toolkit repository, where Claude Code auto-discovers the plugin from `claude/.claude-plugin/plugin.json`. Loading `--plugin-dir` on top of auto-discovery registers every skill twice and produces duplicate entries in the slash command list.
|
|
44
|
+
|
|
45
|
+
`clr` and `clc` resume a session rather than launching one, so auto-discovery never fires for them. A resume restores whatever plugin registration the session started with and recomputes nothing, which a bare `claude --resume` demonstrated by holding a stale registration through a full process restart until `--plugin-dir` was passed on the resume itself.
|
|
38
46
|
|
|
39
47
|
- `cl`: plain session in the current directory
|
|
40
|
-
- `clr`: opens the `/resume` picker scoped to the current directory. Trailing arguments filter by name. `clr auth` limits results to sessions containing "auth".
|
|
41
|
-
- `clc`: jumps straight into the most recent session for the current directory. No picker. Faster than `clr` when the terminal closed and you want back into the same session.
|
|
48
|
+
- `clr`: opens the `/resume` picker scoped to the current directory. Trailing arguments filter by name. `clr auth` limits results to sessions containing "auth". Resuming does not reload the plugin, so reach for `clpc` when the session needs it.
|
|
49
|
+
- `clc`: jumps straight into the most recent session for the current directory. No picker. Faster than `clr` when the terminal closed and you want back into the same session. Resuming does not reload the plugin here either.
|
|
42
50
|
- `clw`: creates a worktree under `.claude/worktrees/<name>/` on a fresh branch and starts a Claude Code session in it. Pass the worktree name as the trailing arg: `clw feat-auth`.
|
|
43
51
|
- `cls`: pins the session to Sonnet instead of the default Opus. Use for routine work where Opus cost is not justified.
|
|
44
52
|
|
|
45
|
-
`clp
|
|
53
|
+
The `clp` family bakes in `--plugin-dir`. Use it outside the toolkit repository, where auto-discovery does not fire, and for any resume that needs the plugin.
|
|
46
54
|
|
|
47
55
|
- `clp`: session with the toolkit plugin loaded explicitly
|
|
48
|
-
- `clpc`: jumps straight into the most recent session for the current directory with the plugin loaded. The `clp` mirror of `clc
|
|
56
|
+
- `clpc`: jumps straight into the most recent session for the current directory with the plugin loaded. The `clp` mirror of `clc`, and the one resume that carries the plugin.
|
|
49
57
|
- `clps`: `clp` pinned to Sonnet
|
|
58
|
+
- `clpa`: opens the agent view with the plugin loaded, covering background sessions from every directory
|
|
59
|
+
- `clpac`: the same view filtered to background sessions started under the current directory
|
|
50
60
|
|
|
51
61
|
## When to use which
|
|
52
62
|
|
|
@@ -58,7 +68,9 @@ Use `cls` or `clps` to save Opus usage on routine sessions. Switch mid-session w
|
|
|
58
68
|
|
|
59
69
|
Use `clw <name>` for features that will take more than one session. A worktree isolates the branch, the transcripts, and the `/resume` history. See [Claude Code and git worktrees](../wiki/claude/claude-worktrees.md) for fan-out rules.
|
|
60
70
|
|
|
61
|
-
Use `clc` to resume the last session without a picker. Use `clr` when you have several sessions and need to pick by name or recency.
|
|
71
|
+
Use `clc` to resume the last session without a picker. Use `clr` when you have several sessions and need to pick by name or recency. Reach for `clpc` over `clc` wherever the resumed session needs the plugin, inside the toolkit repository as well as outside it, since neither resume re-runs discovery.
|
|
72
|
+
|
|
73
|
+
Use `clpa` to see what is running across every directory, and `clpac` when only the current project matters.
|
|
62
74
|
|
|
63
75
|
## Why not a function
|
|
64
76
|
|
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.8.1",
|
|
5
5
|
"description": "Infrastructure and quality tooling for developer workflows",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
"tsconfig.json",
|
|
20
20
|
"!scripts/sandbox",
|
|
21
21
|
"!scripts/eval",
|
|
22
|
-
"!src/capture",
|
|
23
22
|
"!**/*.test.ts"
|
|
24
23
|
],
|
|
25
24
|
"publishConfig": {
|
|
@@ -36,12 +36,16 @@ alias cls='cl --model sonnet'
|
|
|
36
36
|
alias clp='claude --plugin-dir \$TOOLKIT/claude'
|
|
37
37
|
alias clpc='clp -c'
|
|
38
38
|
alias clps='clp --model sonnet'
|
|
39
|
+
alias clpa='clp agents'
|
|
40
|
+
alias clpac='clpa --cwd .'
|
|
39
41
|
$MARKER_CLOSE
|
|
40
42
|
EOF
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
install_aliases() {
|
|
44
46
|
log_step "Installing Claude Code shell aliases"
|
|
47
|
+
log_info "This step manages a marked alias block in $ZSHRC, the only file outside the repo this script writes."
|
|
48
|
+
log_info "Delete the block and its two marker comments to remove the aliases. Re-running never adds a second copy."
|
|
45
49
|
|
|
46
50
|
if [ -f "$ZSHRC" ] && grep -qF "$MARKER_OPEN" "$ZSHRC"; then
|
|
47
51
|
log_info "Aliases already present in $ZSHRC, skipping"
|
|
@@ -4,15 +4,18 @@
|
|
|
4
4
|
# Only the HTML regenerates here. The PNG beside it is a chromium render whose
|
|
5
5
|
# bytes move with the browser version, so asserting it in verify.sh would fail
|
|
6
6
|
# on a machine whose chromium differs rather than on a stale count. Rebuild the
|
|
7
|
-
# image with `canon capture assets/hero.html` after this
|
|
7
|
+
# image with `canon capture assets/hero.html --selector .window` after this
|
|
8
|
+
# script reports a change. The selector has no default, since the element a
|
|
9
|
+
# capture crops to belongs to the page rather than to the command, and `.window`
|
|
10
|
+
# is the class this repository's own two sources declare.
|
|
8
11
|
# That capture also writes assets/hero.stamp, which records the digest of the
|
|
9
12
|
# markup it rendered and is what the Hero stage compares, so all three files
|
|
10
13
|
# commit together. The frame carries no version. `package.json` is bumped on main by the release
|
|
11
14
|
# tooling, so embedding it drifts every open branch on the next release and the
|
|
12
15
|
# stage then fails for work that touched nothing.
|
|
13
16
|
#
|
|
14
|
-
# Clone-only. `
|
|
15
|
-
#
|
|
17
|
+
# Clone-only. `canon capture` ships now, but this script reads the repository's
|
|
18
|
+
# own catalogs, which a registry install does not carry.
|
|
16
19
|
set -e
|
|
17
20
|
set -o pipefail
|
|
18
21
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import { chromium } from 'playwright-core'
|
|
5
|
+
import type { Browser, Page } from 'playwright-core'
|
|
6
|
+
import type { CaptureSource } from '@/capture/sources'
|
|
7
|
+
import { primaryFontFamily, resolveCaptureSources } from '@/capture/sources'
|
|
8
|
+
import { formatStamp, hashSource, stampPath } from '@/capture/stamp'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Every browser reference the capture command makes lives in this module, and
|
|
12
|
+
* `src/commands/capture.ts` reaches it through a dynamic import so `src/cli.ts`
|
|
13
|
+
* never resolves the engine at startup.
|
|
14
|
+
*
|
|
15
|
+
* It ships, like `@/demo/drive`, `@/inventory/walk`, and `@/driver/drive`.
|
|
16
|
+
* Regenerating this repository's own committed images was the reason it stayed
|
|
17
|
+
* behind, and it was a reason about one caller rather than about the mechanism:
|
|
18
|
+
* a target renders its own generated pages and proves its own fonts resolved,
|
|
19
|
+
* which is the whole of what this module does.
|
|
20
|
+
*
|
|
21
|
+
* It imports `playwright-core` rather than `@playwright/test` for the same
|
|
22
|
+
* reason the three siblings do. The test runner is a development dependency the
|
|
23
|
+
* published tarball never carries, so the earlier import resolved here and
|
|
24
|
+
* threw `ERR_MODULE_NOT_FOUND` in every target install.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const DEVICE_SCALE_FACTOR = 2
|
|
28
|
+
const FONT_PROBE_SIZE = 72
|
|
29
|
+
const FONT_PROBE_TEXT = 'canon capture 0123456789'
|
|
30
|
+
const ABSENT_FAMILY = '__canon_absent_family__'
|
|
31
|
+
|
|
32
|
+
export interface CaptureOptions {
|
|
33
|
+
selector: string
|
|
34
|
+
outDir?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type CaptureResult =
|
|
38
|
+
| {
|
|
39
|
+
status: 'rendered'
|
|
40
|
+
htmlPath: string
|
|
41
|
+
pngPath: string
|
|
42
|
+
width: number
|
|
43
|
+
height: number
|
|
44
|
+
}
|
|
45
|
+
| { status: 'failed'; htmlPath: string; reason: string }
|
|
46
|
+
|
|
47
|
+
export async function captureSources(
|
|
48
|
+
sourcePath: string,
|
|
49
|
+
options: CaptureOptions,
|
|
50
|
+
): Promise<CaptureResult[]> {
|
|
51
|
+
const sources = resolveCaptureSources(sourcePath, options.outDir)
|
|
52
|
+
if (!sources.length) return []
|
|
53
|
+
|
|
54
|
+
const browser = await chromium.launch()
|
|
55
|
+
try {
|
|
56
|
+
return await Promise.all(
|
|
57
|
+
sources.map((source) => captureOne(browser, source, options.selector)),
|
|
58
|
+
)
|
|
59
|
+
} finally {
|
|
60
|
+
await browser.close()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolves to a failed result rather than throwing, so one source that cannot
|
|
66
|
+
* render does not drop the rest of the batch.
|
|
67
|
+
*/
|
|
68
|
+
async function captureOne(
|
|
69
|
+
browser: Browser,
|
|
70
|
+
source: CaptureSource,
|
|
71
|
+
selector: string,
|
|
72
|
+
): Promise<CaptureResult> {
|
|
73
|
+
const page = await browser.newPage({
|
|
74
|
+
deviceScaleFactor: DEVICE_SCALE_FACTOR,
|
|
75
|
+
})
|
|
76
|
+
try {
|
|
77
|
+
await page.goto(pathToFileURL(source.htmlPath).href)
|
|
78
|
+
const element = page.locator(selector).first()
|
|
79
|
+
if ((await element.count()) === 0) {
|
|
80
|
+
return failed(source, `no element matched ${selector}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
await page.evaluate(() => document.fonts.ready.then(() => undefined))
|
|
84
|
+
const family = primaryFontFamily(
|
|
85
|
+
await element.evaluate((node) => getComputedStyle(node).fontFamily),
|
|
86
|
+
)
|
|
87
|
+
if (family && !(await resolvesFont(page, family))) {
|
|
88
|
+
return failed(
|
|
89
|
+
source,
|
|
90
|
+
`${family} is not installed, so the capture would rewrap against a fallback`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
mkdirSync(dirname(source.pngPath), { recursive: true })
|
|
95
|
+
const png = await element.screenshot({ omitBackground: true })
|
|
96
|
+
writeFileSync(source.pngPath, png)
|
|
97
|
+
writeStamp(source, png)
|
|
98
|
+
return {
|
|
99
|
+
status: 'rendered',
|
|
100
|
+
htmlPath: source.htmlPath,
|
|
101
|
+
pngPath: source.pngPath,
|
|
102
|
+
width: png.readUInt32BE(16),
|
|
103
|
+
height: png.readUInt32BE(20),
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return failed(
|
|
107
|
+
source,
|
|
108
|
+
error instanceof Error ? error.message : String(error),
|
|
109
|
+
)
|
|
110
|
+
} finally {
|
|
111
|
+
await page.close()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Runs inside the render rather than in a wrapper around it, so a capture
|
|
117
|
+
* cannot succeed and leave the provenance unrecorded. A throw here reaches the
|
|
118
|
+
* caller's catch and reports the source as failed, which is correct: a PNG
|
|
119
|
+
* whose stamp never landed is the state the verify stage exists to reject.
|
|
120
|
+
*
|
|
121
|
+
* The source is stored as a bare filename. An absolute path would record the
|
|
122
|
+
* machine that ran the capture into a tracked file and differ per checkout.
|
|
123
|
+
*
|
|
124
|
+
* The image digest is taken over the buffer the screenshot returned rather than
|
|
125
|
+
* by reading the file back, so the stamp describes the bytes this run wrote.
|
|
126
|
+
*/
|
|
127
|
+
function writeStamp(source: CaptureSource, png: Uint8Array): void {
|
|
128
|
+
writeFileSync(
|
|
129
|
+
stampPath(source.pngPath),
|
|
130
|
+
formatStamp({
|
|
131
|
+
source: basename(source.htmlPath),
|
|
132
|
+
sourceSha256: hashSource(readFileSync(source.htmlPath)),
|
|
133
|
+
imageSha256: hashSource(png),
|
|
134
|
+
}),
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function failed(source: CaptureSource, reason: string): CaptureResult {
|
|
139
|
+
return { status: 'failed', htmlPath: source.htmlPath, reason }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Compares the family's text metrics against a family that cannot exist. Equal
|
|
144
|
+
* widths mean the browser fell through to its default for both, which is the
|
|
145
|
+
* only signal available: `document.fonts.check` reports every system family as
|
|
146
|
+
* present, including invented ones.
|
|
147
|
+
*/
|
|
148
|
+
async function resolvesFont(page: Page, family: string): Promise<boolean> {
|
|
149
|
+
return page.evaluate(
|
|
150
|
+
({ family, absent, size, text }) => {
|
|
151
|
+
const context = document.createElement('canvas').getContext('2d')
|
|
152
|
+
if (!context) return false
|
|
153
|
+
const widthOf = (name: string): number => {
|
|
154
|
+
context.font = `${size}px "${name}"`
|
|
155
|
+
return context.measureText(text).width
|
|
156
|
+
}
|
|
157
|
+
return widthOf(family) !== widthOf(absent)
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
family,
|
|
161
|
+
absent: ABSENT_FAMILY,
|
|
162
|
+
size: FONT_PROBE_SIZE,
|
|
163
|
+
text: FONT_PROBE_TEXT,
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, extname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface CaptureSource {
|
|
5
|
+
htmlPath: string
|
|
6
|
+
pngPath: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pairs each HTML source with the PNG it renders to. A directory expands to
|
|
11
|
+
* every `.html` directly inside it, so adding a capture is a file drop rather
|
|
12
|
+
* than a flag. Omitting `outDir` writes the PNG beside its source.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveCaptureSources(
|
|
15
|
+
sourcePath: string,
|
|
16
|
+
outDir?: string,
|
|
17
|
+
): CaptureSource[] {
|
|
18
|
+
const htmlPaths = statSync(sourcePath).isDirectory()
|
|
19
|
+
? readdirSync(sourcePath)
|
|
20
|
+
.filter((name) => extname(name) === '.html')
|
|
21
|
+
.sort()
|
|
22
|
+
.map((name) => join(sourcePath, name))
|
|
23
|
+
: [sourcePath]
|
|
24
|
+
|
|
25
|
+
return htmlPaths.map((htmlPath) => ({
|
|
26
|
+
htmlPath,
|
|
27
|
+
pngPath: join(
|
|
28
|
+
outDir ?? dirname(htmlPath),
|
|
29
|
+
`${basename(htmlPath, '.html')}.png`,
|
|
30
|
+
),
|
|
31
|
+
}))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reads the first family from a computed `font-family` value, which is the font
|
|
36
|
+
* the source asks for and the one a capture has to prove resolved. Quoting is
|
|
37
|
+
* the source's choice, so both spellings arrive here.
|
|
38
|
+
*/
|
|
39
|
+
export function primaryFontFamily(declaration: string): string {
|
|
40
|
+
const first = declaration.split(',')[0]?.trim() ?? ''
|
|
41
|
+
return first.replace(/^['"]|['"]$/g, '')
|
|
42
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { basename, dirname, extname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const HASH_ALGORITHM = 'sha256'
|
|
5
|
+
const STAMP_EXTENSION = '.stamp'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Provenance for one rendered PNG: the markup it came from and a digest over
|
|
9
|
+
* each side of the pair. `assertStampField` in `src/gate/measures.ts` reads both
|
|
10
|
+
* digests back off the first whitespace-separated token, so the field names, the
|
|
11
|
+
* colon, and the space after it are a contract between the writer and the gate
|
|
12
|
+
* rather than formatting.
|
|
13
|
+
*
|
|
14
|
+
* Both sides are recorded because either can move alone. The markup digest
|
|
15
|
+
* catches an edit committed with no capture, and the image digest catches a PNG
|
|
16
|
+
* replaced under markup that never changed.
|
|
17
|
+
*/
|
|
18
|
+
export interface CaptureStamp {
|
|
19
|
+
readonly source: string
|
|
20
|
+
readonly sourceSha256: string
|
|
21
|
+
readonly imageSha256: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A capture writes its stamp beside its PNG rather than beside its HTML,
|
|
26
|
+
* because `--out` moves the image away from the source and the stamp answers a
|
|
27
|
+
* question about the image.
|
|
28
|
+
*/
|
|
29
|
+
export function stampPath(pngPath: string): string {
|
|
30
|
+
return join(
|
|
31
|
+
dirname(pngPath),
|
|
32
|
+
`${basename(pngPath, extname(pngPath))}${STAMP_EXTENSION}`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Digests the whole file rather than the counts inside it. A template edit
|
|
38
|
+
* changes what the image shows without moving any count, and the whole point of
|
|
39
|
+
* the stamp is that it needs no knowledge of what the markup renders.
|
|
40
|
+
*/
|
|
41
|
+
export function hashSource(bytes: Uint8Array): string {
|
|
42
|
+
return createHash(HASH_ALGORITHM).update(bytes).digest('hex')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatStamp(stamp: CaptureStamp): string {
|
|
46
|
+
return [
|
|
47
|
+
`source: ${stamp.source}`,
|
|
48
|
+
`source-sha256: ${stamp.sourceSha256}`,
|
|
49
|
+
`image-sha256: ${stamp.imageSha256}`,
|
|
50
|
+
'',
|
|
51
|
+
].join('\n')
|
|
52
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -120,7 +120,7 @@ function showHelp(): void {
|
|
|
120
120
|
`${GREY}│${NC} canon design render`,
|
|
121
121
|
`${GREY}│${NC} canon slides render`,
|
|
122
122
|
`${GREY}│${NC} canon slides list --json`,
|
|
123
|
-
`${GREY}│${NC} canon capture assets/install.html`,
|
|
123
|
+
`${GREY}│${NC} canon capture assets/install.html --selector .window`,
|
|
124
124
|
`${GREY}│${NC} canon serve .canon/teach`,
|
|
125
125
|
`${GREY}│${NC} canon inventory focus --json`,
|
|
126
126
|
`${GREY}│${NC} canon drive http://localhost:4173 run.json --json`,
|
package/src/commands/capture.ts
CHANGED
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { relative, resolve } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
INSTALL_BROWSER,
|
|
6
|
+
isBrowserMissing,
|
|
7
|
+
isEngineMissing,
|
|
8
|
+
} from '@/browser/engine'
|
|
9
|
+
import {
|
|
10
|
+
frameError,
|
|
11
|
+
intro,
|
|
12
|
+
logError,
|
|
13
|
+
logInfo,
|
|
14
|
+
logWarn,
|
|
15
|
+
outro,
|
|
16
|
+
pipeOutput,
|
|
17
|
+
} from '@/ui'
|
|
5
18
|
|
|
6
19
|
const DEFAULT_SOURCE = 'assets'
|
|
7
|
-
const DEFAULT_SELECTOR = '.window'
|
|
8
20
|
|
|
9
21
|
/**
|
|
10
|
-
* Both aliases are type queries rather than imports, so
|
|
11
|
-
*
|
|
22
|
+
* Both aliases are type queries rather than imports, so naming the render
|
|
23
|
+
* module's types adds no static import of it. The only reference that survives
|
|
24
|
+
* to runtime is the `import()` inside the action.
|
|
12
25
|
*/
|
|
13
26
|
type Renderer = typeof import('@/capture/render')
|
|
14
27
|
type CaptureResult = Awaited<ReturnType<Renderer['captureSources']>>[number]
|
|
15
28
|
|
|
16
29
|
/**
|
|
17
|
-
* Holds wiring only. Every browser reference sits behind
|
|
18
|
-
* because `src/cli.ts` imports this module at startup and the
|
|
19
|
-
*
|
|
30
|
+
* Holds wiring only. Every browser reference sits behind a dynamic import,
|
|
31
|
+
* because `src/cli.ts` imports this module at startup and resolving the engine
|
|
32
|
+
* there would put a browser launch in front of every other command. That is the
|
|
33
|
+
* same reason `src/commands/demo.ts`, `src/commands/inventory.ts`, and
|
|
34
|
+
* `src/commands/driver.ts` state for themselves.
|
|
20
35
|
*/
|
|
21
36
|
export function register(program: Command): void {
|
|
22
37
|
program
|
|
@@ -24,20 +39,27 @@ export function register(program: Command): void {
|
|
|
24
39
|
.description('Render HTML capture sources to PNG')
|
|
25
40
|
.argument('[source]', 'HTML file or a directory of them', DEFAULT_SOURCE)
|
|
26
41
|
.option('-o, --out <dir>', 'Output directory, defaults beside the source')
|
|
27
|
-
.option('-s, --selector <selector>', 'Element to capture'
|
|
42
|
+
.option('-s, --selector <selector>', 'Element to capture')
|
|
28
43
|
.action(
|
|
29
44
|
async (
|
|
30
45
|
source: string,
|
|
31
|
-
opts: { out?: string; selector
|
|
46
|
+
opts: { out?: string; selector?: string },
|
|
32
47
|
): Promise<void> => {
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Refused rather than defaulted, and refused ahead of every other
|
|
50
|
+
* check, so the message names the invocation rather than whatever the
|
|
51
|
+
* working directory happens to hold. The element a capture crops to is
|
|
52
|
+
* a property of the page's own markup, and the class this command used
|
|
53
|
+
* to assume is declared by two committed sources in one repository.
|
|
54
|
+
*/
|
|
55
|
+
if (!opts.selector) {
|
|
35
56
|
frameError(
|
|
36
|
-
'
|
|
57
|
+
'--selector names the element to capture and has no default. See canon capture --help.',
|
|
37
58
|
)
|
|
38
59
|
process.exitCode = 1
|
|
39
60
|
return
|
|
40
61
|
}
|
|
62
|
+
const selector = opts.selector
|
|
41
63
|
|
|
42
64
|
const sourcePath = resolve(process.cwd(), source)
|
|
43
65
|
if (!existsSync(sourcePath)) {
|
|
@@ -49,8 +71,9 @@ export function register(program: Command): void {
|
|
|
49
71
|
intro('Capture')
|
|
50
72
|
let results: CaptureResult[]
|
|
51
73
|
try {
|
|
74
|
+
const renderer = await import('@/capture/render')
|
|
52
75
|
results = await renderer.captureSources(sourcePath, {
|
|
53
|
-
selector
|
|
76
|
+
selector,
|
|
54
77
|
outDir: opts.out ? resolve(process.cwd(), opts.out) : undefined,
|
|
55
78
|
})
|
|
56
79
|
} catch (error) {
|
|
@@ -85,16 +108,28 @@ export function register(program: Command): void {
|
|
|
85
108
|
}
|
|
86
109
|
|
|
87
110
|
/**
|
|
88
|
-
* Closes an open frame around a failure
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
111
|
+
* Closes an open frame around a failure that stopped the whole run rather than
|
|
112
|
+
* one source. Two of them are setup states rather than defects and each names
|
|
113
|
+
* the step that clears it: an engine package that never resolved, and a browser
|
|
114
|
+
* binary that was never downloaded. Every other failure passes through intact,
|
|
115
|
+
* since the engine's own message is readable and summarizing it loses what it
|
|
116
|
+
* said.
|
|
92
117
|
*/
|
|
93
118
|
function reportInFrame(error: unknown): void {
|
|
119
|
+
if (isEngineMissing(error)) {
|
|
120
|
+
logError('the browser engine is not installed in this project')
|
|
121
|
+
logWarn(`Install it with: ${INSTALL_BROWSER}`)
|
|
122
|
+
outro()
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
94
126
|
const message = error instanceof Error ? error.message : String(error)
|
|
95
127
|
const [first, ...rest] = message.split('\n')
|
|
96
128
|
logError(first ?? 'capture failed')
|
|
97
129
|
if (rest.length) pipeOutput(rest.join('\n'))
|
|
130
|
+
if (isBrowserMissing(error)) {
|
|
131
|
+
logWarn(`Install the browser binary with: ${INSTALL_BROWSER}`)
|
|
132
|
+
}
|
|
98
133
|
outro()
|
|
99
134
|
}
|
|
100
135
|
|
|
@@ -107,27 +142,3 @@ function displayPath(path: string): string {
|
|
|
107
142
|
const fromCwd = relative(process.cwd(), path)
|
|
108
143
|
return fromCwd.startsWith('..') ? path : fromCwd
|
|
109
144
|
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Reports absence only when the module or its engine cannot be resolved, which
|
|
113
|
-
* is the published-package case. Any other import failure is a defect inside
|
|
114
|
-
* the render module and propagates, rather than being reported as a feature
|
|
115
|
-
* the package left out.
|
|
116
|
-
*/
|
|
117
|
-
async function loadRenderer(): Promise<Renderer | undefined> {
|
|
118
|
-
try {
|
|
119
|
-
return await import('@/capture/render')
|
|
120
|
-
} catch (error) {
|
|
121
|
-
if (isModuleNotFound(error)) return undefined
|
|
122
|
-
throw error
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function isModuleNotFound(error: unknown): boolean {
|
|
127
|
-
return (
|
|
128
|
-
typeof error === 'object' &&
|
|
129
|
-
error !== null &&
|
|
130
|
-
'code' in error &&
|
|
131
|
-
error.code === 'ERR_MODULE_NOT_FOUND'
|
|
132
|
-
)
|
|
133
|
-
}
|