@erclx/canon 5.52.0 → 5.54.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/CHANGELOG.md +17 -0
- package/README.md +11 -13
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +5 -8
- package/claude/skills/context-fold/REQUIREMENT.md +0 -1
- package/claude/skills/design-identity/REQUIREMENT.md +2 -2
- package/claude/skills/design-identity/SKILL.md +6 -6
- package/claude/skills/design-record/REQUIREMENT.md +2 -2
- package/claude/skills/design-record/SKILL.md +4 -4
- package/claude/skills/design-record/references/folder.md +1 -1
- package/claude/skills/design-taste/REQUIREMENT.md +1 -1
- package/claude/skills/design-taste/references/kinds.md +0 -1
- package/claude/skills/draw-figure/REQUIREMENT.md +3 -4
- package/claude/skills/draw-figure/SKILL.md +5 -6
- package/claude/skills/draw-figure/references/figures.md +0 -1
- package/claude/skills/draw-figure/references/hand-drawn.md +1 -1
- package/claude/skills/pick-by-eye/REQUIREMENT.md +4 -5
- package/claude/skills/pick-by-eye/SKILL.md +34 -41
- package/claude/skills/pick-by-eye/references/board.md +42 -0
- package/claude/skills/pick-by-eye/references/live-arms.md +3 -3
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/teach-craft/REQUIREMENT.md +30 -0
- package/claude/skills/teach-craft/SKILL.md +70 -0
- package/claude/skills/teach-craft/references/adopted.md +113 -0
- package/claude/skills/teach-craft/references/lesson-craft.md +60 -0
- package/claude/skills/{teach-workspace → teach-craft}/references/pedagogy.md +41 -6
- package/docs/agents/audits.md +0 -1
- package/docs/agents/install-and-sync.md +0 -8
- package/docs/target/migrations.md +16 -2
- package/docs/target/projects.md +1 -1
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map-on-request.md +10 -22
- package/docs/workflow/skill-map.md +11 -14
- package/docs/workflow/visual-design-workflow.md +1 -4
- package/governance/detect.toml +1 -6
- package/governance/renames.toml +0 -1
- package/package.json +2 -8
- package/scripts/core/regen-web-previews.ts +2 -67
- package/src/audits/catalog.ts +0 -1
- package/src/browser/engine.ts +3 -6
- package/src/capture/render.ts +1 -1
- package/src/claude/cases/authoring.ts +0 -12
- package/src/claude/cases/misc.ts +0 -22
- package/src/claude/cases/workflow.ts +2 -38
- package/src/claude/skills-audit.ts +1 -1
- package/src/cli/help.ts +0 -10
- package/src/cli.ts +0 -8
- package/src/commands/design.ts +16 -315
- package/src/commands/records.ts +0 -1
- package/src/commands/serve.ts +2 -2
- package/src/commands/sync.ts +4 -5
- package/src/design/components.ts +8 -1514
- package/src/design/css.ts +5 -6
- package/src/design/fonts.ts +3 -5
- package/src/design/regen.ts +5 -32
- package/src/design/render.ts +1 -1
- package/src/design/tokens.ts +3 -2
- package/src/driver/drive.ts +3 -4
- package/src/driver/steps.ts +2 -5
- package/src/gate/stages.ts +4 -10
- package/src/gov/audit.ts +1 -6
- package/src/inventory/walk.ts +1 -1
- package/src/markdown/scope.ts +6 -35
- package/src/records/validate.ts +4 -176
- package/src/sandbox/equivalence.ts +0 -5
- package/src/shipped/references.ts +1 -2
- package/src/sync/check.ts +4 -29
- package/src/sync/stamp.ts +6 -6
- package/src/sync/target.ts +1 -4
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/canon/DESIGN.md +1 -1
- package/claude/skills/canvas-board/REQUIREMENT.md +0 -35
- package/claude/skills/canvas-board/SKILL.md +0 -143
- package/claude/skills/canvas-board/references/canvas.md +0 -170
- package/claude/skills/draft-and-pick/REQUIREMENT.md +0 -26
- package/claude/skills/draft-and-pick/SKILL.md +0 -11
- package/claude/skills/draft-doc/REQUIREMENT.md +0 -26
- package/claude/skills/draft-doc/SKILL.md +0 -11
- package/claude/skills/draft-figure/REQUIREMENT.md +0 -26
- package/claude/skills/draft-figure/SKILL.md +0 -11
- package/claude/skills/draft-identity/REQUIREMENT.md +0 -26
- package/claude/skills/draft-identity/SKILL.md +0 -11
- package/claude/skills/draft-ready/REQUIREMENT.md +0 -26
- package/claude/skills/draft-ready/SKILL.md +0 -11
- package/claude/skills/draft-screencast/REQUIREMENT.md +0 -26
- package/claude/skills/draft-screencast/SKILL.md +0 -11
- package/claude/skills/draft-slides/REQUIREMENT.md +0 -26
- package/claude/skills/draft-slides/SKILL.md +0 -11
- package/claude/skills/make-deck/REQUIREMENT.md +0 -36
- package/claude/skills/make-deck/SKILL.md +0 -80
- package/claude/skills/make-deck/references/deck.md +0 -183
- package/claude/skills/read-frames/REQUIREMENT.md +0 -30
- package/claude/skills/read-frames/SKILL.md +0 -55
- package/claude/skills/record-screencast/REQUIREMENT.md +0 -35
- package/claude/skills/record-screencast/SKILL.md +0 -99
- package/claude/skills/script-screencast/REQUIREMENT.md +0 -34
- package/claude/skills/script-screencast/SKILL.md +0 -70
- package/claude/skills/script-screencast/references/beats-template.md +0 -119
- package/claude/skills/teach-workspace/REQUIREMENT.md +0 -34
- package/claude/skills/teach-workspace/SKILL.md +0 -196
- package/claude/skills/teach-workspace/references/glossary.md +0 -90
- package/claude/skills/teach-workspace/references/lesson-build.md +0 -106
- package/claude/skills/teach-workspace/references/lesson-craft.md +0 -135
- package/claude/skills/teach-workspace/references/promotion-fold.md +0 -35
- package/claude/skills/teach-workspace/references/promotion.md +0 -116
- package/claude/skills/teach-workspace/references/revisit.md +0 -28
- package/claude/skills/teach-workspace/references/teach.md +0 -211
- package/claude/skills/video-craft/REQUIREMENT.md +0 -35
- package/claude/skills/video-craft/SKILL.md +0 -96
- package/claude/skills/video-craft/references/adopted.md +0 -110
- package/governance/rules/ui/canvas.md +0 -13
- package/src/canvas/address.ts +0 -227
- package/src/canvas/arrange.ts +0 -66
- package/src/canvas/capture.ts +0 -391
- package/src/canvas/client/app.tsx +0 -188
- package/src/canvas/client/inspector/align-grid.tsx +0 -162
- package/src/canvas/client/inspector/color-field.tsx +0 -398
- package/src/canvas/client/inspector/color-inputs.tsx +0 -139
- package/src/canvas/client/inspector/color-picker.tsx +0 -668
- package/src/canvas/client/inspector/color-space.ts +0 -240
- package/src/canvas/client/inspector/field.tsx +0 -263
- package/src/canvas/client/inspector/fields.css +0 -763
- package/src/canvas/client/inspector/section.tsx +0 -27
- package/src/canvas/client/inspector/size-field.tsx +0 -69
- package/src/canvas/client/inspector/token-select.tsx +0 -109
- package/src/canvas/client/inspector/values.ts +0 -297
- package/src/canvas/client/inspector.tsx +0 -692
- package/src/canvas/client/keys.ts +0 -71
- package/src/canvas/client/layers.tsx +0 -118
- package/src/canvas/client/main.tsx +0 -71
- package/src/canvas/client/pages-panel.tsx +0 -212
- package/src/canvas/client/panel-handle.tsx +0 -90
- package/src/canvas/client/selection.tsx +0 -199
- package/src/canvas/client/shell.css +0 -762
- package/src/canvas/client/state.ts +0 -792
- package/src/canvas/client/surface.tsx +0 -803
- package/src/canvas/client/theme-panel.tsx +0 -55
- package/src/canvas/client/tools.tsx +0 -153
- package/src/canvas/content.ts +0 -781
- package/src/canvas/edit.ts +0 -575
- package/src/canvas/editing.ts +0 -203
- package/src/canvas/fit.ts +0 -238
- package/src/canvas/global.d.ts +0 -15
- package/src/canvas/history.ts +0 -116
- package/src/canvas/index.html +0 -20
- package/src/canvas/server.ts +0 -766
- package/src/canvas/tokens.ts +0 -221
- package/src/commands/canvas.ts +0 -1097
- package/src/commands/demo.ts +0 -542
- package/src/commands/slides.ts +0 -184
- package/src/commands/teach.ts +0 -1401
- package/src/demo/beats.ts +0 -135
- package/src/demo/compile.ts +0 -350
- package/src/demo/container.ts +0 -219
- package/src/demo/cursors.ts +0 -55
- package/src/demo/drive.ts +0 -442
- package/src/demo/pointer.ts +0 -178
- package/src/demo/theme.ts +0 -112
- package/src/demo/timeline.ts +0 -64
- package/src/design/adapter.ts +0 -59
- package/src/design/base.css +0 -203
- package/src/design/brand-favicon.ts +0 -18
- package/src/design/neutral.ts +0 -127
- package/src/design/project-fonts.ts +0 -392
- package/src/slides/convert/chart.ts +0 -165
- package/src/slides/convert/deck.ts +0 -244
- package/src/slides/convert/export.ts +0 -567
- package/src/slides/convert/master.ts +0 -198
- package/src/slides/convert/shapes.ts +0 -804
- package/src/slides/convert/svg.ts +0 -38
- package/src/slides/convert/walk.ts +0 -671
- package/src/slides/decks.ts +0 -110
- package/src/slides/open.ts +0 -18
- package/src/slides/package/fonts.ts +0 -301
- package/src/slides/package/motion.ts +0 -316
- package/src/slides/package/test-face.ts +0 -113
- package/src/teach/browser/compile.ts +0 -44
- package/src/teach/browser/dismiss.ts +0 -76
- package/src/teach/browser/focus-line.ts +0 -19
- package/src/teach/browser/glossary-filter.ts +0 -44
- package/src/teach/browser/head.ts +0 -30
- package/src/teach/browser/quiz.ts +0 -19
- package/src/teach/browser/reload.ts +0 -9
- package/src/teach/browser/sidebar.ts +0 -233
- package/src/teach/browser/theme.ts +0 -18
- package/src/teach/components/code.tsx +0 -24
- package/src/teach/components/heading.tsx +0 -11
- package/src/teach/components/list.tsx +0 -17
- package/src/teach/components/paragraph.tsx +0 -29
- package/src/teach/components/refs.tsx +0 -28
- package/src/teach/cross-references.ts +0 -78
- package/src/teach/fonts.ts +0 -16
- package/src/teach/highlight.ts +0 -71
- package/src/teach/html/jsx-dev-runtime.ts +0 -13
- package/src/teach/html/jsx-runtime.ts +0 -113
- package/src/teach/html/raw.ts +0 -28
- package/src/teach/lesson.ts +0 -178
- package/src/teach/nav.ts +0 -1337
- package/src/teach/render.ts +0 -240
- package/src/teach/workspace.ts +0 -1164
- package/src/teach/write-if-changed.ts +0 -17
package/src/canvas/address.ts
DELETED
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The element address: a frame plus the element's index in document order.
|
|
3
|
-
* The shell counts it over the frame's parsed DOM and the server counts it over
|
|
4
|
-
* the file through `HTMLRewriter`, so the two have to count the same elements.
|
|
5
|
-
* This module imports nothing, which keeps it bundling into the shell, and the
|
|
6
|
-
* server half reaches `HTMLRewriter` as a Bun global only when called.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/** Marks the token stylesheet the server injects, which the file never holds. */
|
|
10
|
-
export const TOKENS_ATTRIBUTE = 'data-canvas-tokens'
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Marks a document wrapper the server writes because the file leaves it to the
|
|
14
|
-
* browser to imply, so the shell counts only what the file states.
|
|
15
|
-
*/
|
|
16
|
-
export const IMPLIED_ATTRIBUTE = 'data-canvas-implied'
|
|
17
|
-
|
|
18
|
-
/** On that same element, the hash of the file the frame was served from. */
|
|
19
|
-
export const HASH_ATTRIBUTE = 'data-canvas-hash'
|
|
20
|
-
|
|
21
|
-
const EXCERPT_LENGTH = 80
|
|
22
|
-
|
|
23
|
-
/** Elements whose content is raw text, which a browser never parses as markup. */
|
|
24
|
-
const RAW_TEXT = new Set(['script', 'style', 'template', 'textarea', 'title'])
|
|
25
|
-
|
|
26
|
-
export interface ElementAddress {
|
|
27
|
-
readonly index: number
|
|
28
|
-
readonly tag: string
|
|
29
|
-
/**
|
|
30
|
-
* Every element the shell counted. A browser implies elements the file never
|
|
31
|
-
* states, such as a `tbody`, and each one shifts the count, so a total that
|
|
32
|
-
* differs from the server's is what exposes it. The server marks an implied
|
|
33
|
-
* document wrapper itself, so only the marked ones leave the count.
|
|
34
|
-
*/
|
|
35
|
-
readonly count: number
|
|
36
|
-
/**
|
|
37
|
-
* The served file's hash, so the server can refuse a pick made against a
|
|
38
|
-
* version of the frame the file has since moved past.
|
|
39
|
-
*/
|
|
40
|
-
readonly hash?: string
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface SourceElement {
|
|
44
|
-
readonly tag: string
|
|
45
|
-
readonly classes: readonly string[]
|
|
46
|
-
readonly text: string
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export type AddressCheck =
|
|
50
|
-
| { readonly ok: true; readonly element: SourceElement }
|
|
51
|
-
| {
|
|
52
|
-
readonly ok: false
|
|
53
|
-
readonly reason: 'address-mismatch'
|
|
54
|
-
readonly detail: string
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function excerpt(text: string): string {
|
|
58
|
-
return text.replace(/\s+/g, ' ').trim().slice(0, EXCERPT_LENGTH)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function documentElements(doc: Document): Element[] {
|
|
62
|
-
return [...doc.querySelectorAll('*')].filter(
|
|
63
|
-
(element) =>
|
|
64
|
-
!element.hasAttribute(TOKENS_ATTRIBUTE) &&
|
|
65
|
-
!element.hasAttribute(IMPLIED_ATTRIBUTE),
|
|
66
|
-
)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function addressOf(
|
|
70
|
-
doc: Document,
|
|
71
|
-
element: Element,
|
|
72
|
-
): ElementAddress | undefined {
|
|
73
|
-
const all = documentElements(doc)
|
|
74
|
-
const index = all.indexOf(element)
|
|
75
|
-
if (index === -1) return undefined
|
|
76
|
-
const address = {
|
|
77
|
-
index,
|
|
78
|
-
tag: element.tagName.toLowerCase(),
|
|
79
|
-
count: all.length,
|
|
80
|
-
}
|
|
81
|
-
const hash = doc
|
|
82
|
-
.querySelector(`[${TOKENS_ATTRIBUTE}]`)
|
|
83
|
-
?.getAttribute(HASH_ATTRIBUTE)
|
|
84
|
-
return hash ? { ...address, hash } : address
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** The element an address names, or nothing once the tag there differs. */
|
|
88
|
-
export function elementAt(
|
|
89
|
-
doc: Document,
|
|
90
|
-
address: Pick<ElementAddress, 'index' | 'tag'>,
|
|
91
|
-
): Element | undefined {
|
|
92
|
-
const element = documentElements(doc)[address.index]
|
|
93
|
-
return element?.tagName.toLowerCase() === address.tag ? element : undefined
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
interface Collected {
|
|
97
|
-
readonly tag: string
|
|
98
|
-
readonly classes: readonly string[]
|
|
99
|
-
text: string
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Called on each element the address counts, with its index. What it returns
|
|
104
|
-
* runs at the element's end tag, since a second `onEndTag` on one element
|
|
105
|
-
* replaces the first rather than adding to it.
|
|
106
|
-
*/
|
|
107
|
-
export type ElementVisitor = (
|
|
108
|
-
element: HTMLRewriterTypes.Element,
|
|
109
|
-
index: number,
|
|
110
|
-
) => (() => void) | undefined
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Registers the one counting rule both the reader and the edit writer follow,
|
|
114
|
-
* so an index means the same element to each.
|
|
115
|
-
*/
|
|
116
|
-
export function countElements(
|
|
117
|
-
rewriter: HTMLRewriter,
|
|
118
|
-
visit: ElementVisitor,
|
|
119
|
-
): HTMLRewriter {
|
|
120
|
-
let count = 0
|
|
121
|
-
/* A template's content is a fragment `querySelectorAll` never reaches. */
|
|
122
|
-
let templateDepth = 0
|
|
123
|
-
|
|
124
|
-
return rewriter.on('*', {
|
|
125
|
-
element(element) {
|
|
126
|
-
const tag = element.tagName.toLowerCase()
|
|
127
|
-
if (templateDepth > 0) {
|
|
128
|
-
if (tag === 'template') {
|
|
129
|
-
templateDepth += 1
|
|
130
|
-
element.onEndTag(() => {
|
|
131
|
-
templateDepth -= 1
|
|
132
|
-
})
|
|
133
|
-
}
|
|
134
|
-
return
|
|
135
|
-
}
|
|
136
|
-
const end = visit(element, count)
|
|
137
|
-
count += 1
|
|
138
|
-
if (!element.canHaveContent || element.selfClosing) return
|
|
139
|
-
const isTemplate = tag === 'template'
|
|
140
|
-
if (isTemplate) templateDepth += 1
|
|
141
|
-
element.onEndTag(() => {
|
|
142
|
-
if (isTemplate) templateDepth -= 1
|
|
143
|
-
end?.()
|
|
144
|
-
})
|
|
145
|
-
},
|
|
146
|
-
})
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Whether an element's content is raw text rather than parsed markup. */
|
|
150
|
-
export function isRawText(tag: string): boolean {
|
|
151
|
-
return RAW_TEXT.has(tag)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Every element the file states, in document order. Text is gathered into
|
|
156
|
-
* each open element, which an element closed only by implication keeps open to
|
|
157
|
-
* the end, so its excerpt can run past where a browser would end it.
|
|
158
|
-
*/
|
|
159
|
-
export function sourceElements(html: string): SourceElement[] {
|
|
160
|
-
const elements: Collected[] = []
|
|
161
|
-
const open: number[] = []
|
|
162
|
-
let rawDepth = 0
|
|
163
|
-
|
|
164
|
-
const appendText = (text: string) => {
|
|
165
|
-
for (const index of open) {
|
|
166
|
-
const element = elements[index]
|
|
167
|
-
if (element && element.text.length < EXCERPT_LENGTH * 4) {
|
|
168
|
-
element.text += text
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
countElements(new HTMLRewriter(), (element, index) => {
|
|
174
|
-
const tag = element.tagName.toLowerCase()
|
|
175
|
-
/* A tag boundary separates words the way a rendered block would. */
|
|
176
|
-
appendText(' ')
|
|
177
|
-
elements.push({
|
|
178
|
-
tag,
|
|
179
|
-
classes: (element.getAttribute('class') ?? '')
|
|
180
|
-
.split(/\s+/)
|
|
181
|
-
.filter(Boolean),
|
|
182
|
-
text: '',
|
|
183
|
-
})
|
|
184
|
-
if (!element.canHaveContent || element.selfClosing) return undefined
|
|
185
|
-
const isRaw = isRawText(tag)
|
|
186
|
-
open.push(index)
|
|
187
|
-
if (isRaw) rawDepth += 1
|
|
188
|
-
return () => {
|
|
189
|
-
const at = open.lastIndexOf(index)
|
|
190
|
-
if (at !== -1) open.splice(at, 1)
|
|
191
|
-
if (isRaw) rawDepth -= 1
|
|
192
|
-
appendText(' ')
|
|
193
|
-
}
|
|
194
|
-
})
|
|
195
|
-
.onDocument({
|
|
196
|
-
text(chunk) {
|
|
197
|
-
if (rawDepth === 0) appendText(chunk.text)
|
|
198
|
-
},
|
|
199
|
-
})
|
|
200
|
-
.transform(html)
|
|
201
|
-
|
|
202
|
-
return elements.map(({ tag, classes, text }) => ({
|
|
203
|
-
tag,
|
|
204
|
-
classes,
|
|
205
|
-
text: excerpt(text),
|
|
206
|
-
}))
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Finds the element the shell addressed in the file, and refuses when the two
|
|
211
|
-
* counts disagree rather than naming whatever sits at that index on disk.
|
|
212
|
-
*/
|
|
213
|
-
export function resolveAddress(
|
|
214
|
-
html: string,
|
|
215
|
-
address: ElementAddress,
|
|
216
|
-
): AddressCheck {
|
|
217
|
-
const elements = sourceElements(html)
|
|
218
|
-
const element = elements[address.index]
|
|
219
|
-
if (elements.length !== address.count || element?.tag !== address.tag) {
|
|
220
|
-
return {
|
|
221
|
-
ok: false,
|
|
222
|
-
reason: 'address-mismatch',
|
|
223
|
-
detail: `the browser counted ${address.count} elements and the file states ${elements.length}, so element ${address.index} cannot be matched`,
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
return { ok: true, element }
|
|
227
|
-
}
|
package/src/canvas/arrange.ts
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { type Box, FRAME_GAP, type Frame } from '@/canvas/content'
|
|
2
|
-
|
|
3
|
-
export interface Placement {
|
|
4
|
-
readonly name: string
|
|
5
|
-
readonly from: Box
|
|
6
|
-
readonly to: Box
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function boxOf(frame: Frame): Box {
|
|
10
|
-
return {
|
|
11
|
-
x: frame.x,
|
|
12
|
-
y: frame.y,
|
|
13
|
-
width: frame.width,
|
|
14
|
-
height: frame.height,
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Boxes whose vertical spans overlap share a row, so a row the operator dragged
|
|
20
|
-
* stays a row. Reading by overlap rather than by an equal `y` lets a frame a
|
|
21
|
-
* few pixels off its neighbor still join it.
|
|
22
|
-
*/
|
|
23
|
-
function rowsOf(frames: readonly Frame[]): Frame[][] {
|
|
24
|
-
const sorted = [...frames].sort((a, b) => a.y - b.y || a.x - b.x)
|
|
25
|
-
const rows: Frame[][] = []
|
|
26
|
-
let bottom = Number.NEGATIVE_INFINITY
|
|
27
|
-
for (const frame of sorted) {
|
|
28
|
-
const row = rows.at(-1)
|
|
29
|
-
if (row && frame.y < bottom) {
|
|
30
|
-
row.push(frame)
|
|
31
|
-
bottom = Math.max(bottom, frame.y + frame.height)
|
|
32
|
-
} else {
|
|
33
|
-
rows.push([frame])
|
|
34
|
-
bottom = frame.y + frame.height
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return rows.map((row) =>
|
|
38
|
-
row.sort((a, b) => a.x - b.x || a.name.localeCompare(b.name)),
|
|
39
|
-
)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Re-lays a page's frames: each row left to right on `FRAME_GAP` from the
|
|
44
|
-
* page's leftmost edge, rows stacked on their tallest box plus the gap from
|
|
45
|
-
* the page's top edge. Returns only the frames whose position changes.
|
|
46
|
-
*/
|
|
47
|
-
export function planArrange(frames: readonly Frame[]): Placement[] {
|
|
48
|
-
if (frames.length === 0) return []
|
|
49
|
-
const left = Math.min(...frames.map((frame) => frame.x))
|
|
50
|
-
let top = Math.min(...frames.map((frame) => frame.y))
|
|
51
|
-
|
|
52
|
-
const moves: Placement[] = []
|
|
53
|
-
for (const row of rowsOf(frames)) {
|
|
54
|
-
let x = left
|
|
55
|
-
for (const frame of row) {
|
|
56
|
-
const from = boxOf(frame)
|
|
57
|
-
const to: Box = { ...from, x, y: top }
|
|
58
|
-
if (from.x !== to.x || from.y !== to.y) {
|
|
59
|
-
moves.push({ name: frame.name, from, to })
|
|
60
|
-
}
|
|
61
|
-
x += frame.width + FRAME_GAP
|
|
62
|
-
}
|
|
63
|
-
top += Math.max(...row.map((frame) => frame.height)) + FRAME_GAP
|
|
64
|
-
}
|
|
65
|
-
return moves
|
|
66
|
-
}
|
package/src/canvas/capture.ts
DELETED
|
@@ -1,391 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { dirname, join } from 'node:path'
|
|
3
|
-
import type { CaptureResult } from '@/capture/render'
|
|
4
|
-
import {
|
|
5
|
-
type ContentRefused,
|
|
6
|
-
canvasDir,
|
|
7
|
-
type Frame,
|
|
8
|
-
readPage,
|
|
9
|
-
} from '@/canvas/content'
|
|
10
|
-
import {
|
|
11
|
-
type CanvasOptions,
|
|
12
|
-
type CanvasOutcome,
|
|
13
|
-
FRAMES_PREFIX,
|
|
14
|
-
startCanvas,
|
|
15
|
-
} from '@/canvas/server'
|
|
16
|
-
import { recordDir } from '@/roots/record'
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* The element a frame is captured by. The document rather than its body, so a
|
|
20
|
-
* frame that paints its background on the root is not cropped to the body's
|
|
21
|
-
* box.
|
|
22
|
-
*/
|
|
23
|
-
export const CAPTURE_SELECTOR = 'html'
|
|
24
|
-
|
|
25
|
-
export interface CaptureTarget {
|
|
26
|
-
readonly page: string
|
|
27
|
-
readonly frame: string
|
|
28
|
-
/** The served address, which is what carries the injected tokens. */
|
|
29
|
-
readonly url: string
|
|
30
|
-
/** The viewport width, so a media query resolves at the frame's own width. */
|
|
31
|
-
readonly width: number
|
|
32
|
-
readonly pngPath: string
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export type TargetsOutcome =
|
|
36
|
-
| { readonly ok: true; readonly targets: readonly CaptureTarget[] }
|
|
37
|
-
| ContentRefused
|
|
38
|
-
|
|
39
|
-
function refuse(
|
|
40
|
-
reason: ContentRefused['reason'],
|
|
41
|
-
detail: string,
|
|
42
|
-
): ContentRefused {
|
|
43
|
-
return { ok: false, reason, detail }
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function frameUrl(base: string, page: string, frame: Frame): string {
|
|
47
|
-
return `${base}${FRAMES_PREFIX.slice(1)}${encodeURIComponent(page)}/${encodeURIComponent(frame.file)}`
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Turns `<page>` or `<page>/<frame>` into what to capture. Every target is a
|
|
52
|
-
* served address, never the file on disk, so a capture can only show what the
|
|
53
|
-
* canvas shows. The width comes from the layout and not from the window, so a
|
|
54
|
-
* frame narrower than its own breakpoint renders its narrow layout.
|
|
55
|
-
*
|
|
56
|
-
* `out` is the PNG for a frame and a folder for a page, the way a URL source
|
|
57
|
-
* reads `--out` in `canon capture`.
|
|
58
|
-
*/
|
|
59
|
-
export function resolveCaptureTargets(
|
|
60
|
-
root: string,
|
|
61
|
-
spec: string,
|
|
62
|
-
base: string,
|
|
63
|
-
out?: string,
|
|
64
|
-
): TargetsOutcome {
|
|
65
|
-
const [pageName, frameName, ...extra] = spec.split('/')
|
|
66
|
-
if (!pageName || extra.length > 0 || frameName === '') {
|
|
67
|
-
return refuse('invalid-name', `${spec} is not <page> or <page>/<frame>`)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const page = readPage(root, pageName)
|
|
71
|
-
if (!page) return refuse('no-page', `page ${pageName} does not exist`)
|
|
72
|
-
|
|
73
|
-
const frames =
|
|
74
|
-
frameName === undefined
|
|
75
|
-
? page.frames
|
|
76
|
-
: page.frames.filter((frame) => frame.name === frameName)
|
|
77
|
-
if (frames.length === 0) {
|
|
78
|
-
return frameName === undefined
|
|
79
|
-
? refuse('no-frame', `page ${pageName} has no frames`)
|
|
80
|
-
: refuse('no-frame', `frame ${frameName} does not exist on ${pageName}`)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const scratch = recordDir(root, 'tmp', 'canvas-capture', pageName)
|
|
84
|
-
return {
|
|
85
|
-
ok: true,
|
|
86
|
-
targets: frames.map((frame) => ({
|
|
87
|
-
page: pageName,
|
|
88
|
-
frame: frame.name,
|
|
89
|
-
url: frameUrl(base, pageName, frame),
|
|
90
|
-
width: frame.width,
|
|
91
|
-
pngPath:
|
|
92
|
-
frameName !== undefined && out !== undefined
|
|
93
|
-
? out
|
|
94
|
-
: join(out ?? scratch, `${frame.name}.png`),
|
|
95
|
-
})),
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export type CaptureOutcome =
|
|
100
|
-
| {
|
|
101
|
-
readonly ok: true
|
|
102
|
-
readonly captures: readonly {
|
|
103
|
-
readonly target: CaptureTarget
|
|
104
|
-
readonly result: CaptureResult
|
|
105
|
-
}[]
|
|
106
|
-
}
|
|
107
|
-
| ContentRefused
|
|
108
|
-
| {
|
|
109
|
-
readonly ok: false
|
|
110
|
-
readonly reason: 'no-server' | 'capture-failed'
|
|
111
|
-
readonly detail: string
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
type CaptureEngine = (typeof import('@/capture/render'))['captureSources']
|
|
115
|
-
|
|
116
|
-
/** What a test stands in for, which the command never passes. */
|
|
117
|
-
export interface CaptureDeps {
|
|
118
|
-
readonly start?: (root: string, options: CanvasOptions) => CanvasOutcome
|
|
119
|
-
readonly capture?: CaptureEngine
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Serves the canvas for the length of the call and captures through it, so the
|
|
124
|
-
* run needs no `canon canvas serve` and still never reads the raw file. The
|
|
125
|
-
* port is whatever is free, which keeps it clear of a canvas already running.
|
|
126
|
-
* An error the engine throws, such as a browser that will not launch, comes
|
|
127
|
-
* back as `capture-failed` rather than escaping, so a caller reading the record
|
|
128
|
-
* can tell it from a root that is wrong.
|
|
129
|
-
*/
|
|
130
|
-
export async function captureCanvas(
|
|
131
|
-
root: string,
|
|
132
|
-
spec: string,
|
|
133
|
-
out?: string,
|
|
134
|
-
deps: CaptureDeps = {},
|
|
135
|
-
): Promise<CaptureOutcome> {
|
|
136
|
-
/*
|
|
137
|
-
* Refused before the server starts, since starting it creates the canvas
|
|
138
|
-
* folder and a refused capture should leave the project as it found it.
|
|
139
|
-
*/
|
|
140
|
-
const early = resolveCaptureTargets(root, spec, '')
|
|
141
|
-
if (!early.ok) return early
|
|
142
|
-
|
|
143
|
-
return serveFor(root, deps, async (base, capture) => {
|
|
144
|
-
const resolved = resolveCaptureTargets(root, spec, base, out)
|
|
145
|
-
if (!resolved.ok) return resolved
|
|
146
|
-
|
|
147
|
-
const captures: { target: CaptureTarget; result: CaptureResult }[] = []
|
|
148
|
-
for (const target of resolved.targets) {
|
|
149
|
-
const [result] = await capture(target.url, {
|
|
150
|
-
selector: CAPTURE_SELECTOR,
|
|
151
|
-
width: target.width,
|
|
152
|
-
outDir: target.pngPath,
|
|
153
|
-
})
|
|
154
|
-
if (result) captures.push({ target, result })
|
|
155
|
-
}
|
|
156
|
-
return { ok: true, captures }
|
|
157
|
-
})
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export type ServeRefused = {
|
|
161
|
-
readonly ok: false
|
|
162
|
-
readonly reason: 'no-server' | 'capture-failed'
|
|
163
|
-
readonly detail: string
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async function serveFor<T>(
|
|
167
|
-
root: string,
|
|
168
|
-
deps: CaptureDeps,
|
|
169
|
-
run: (base: string, capture: CaptureEngine) => Promise<T>,
|
|
170
|
-
): Promise<T | ServeRefused> {
|
|
171
|
-
return serveCanvas(root, deps.start, async (base) => {
|
|
172
|
-
const capture =
|
|
173
|
-
deps.capture ?? (await import('@/capture/render')).captureSources
|
|
174
|
-
return run(base, capture)
|
|
175
|
-
})
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Runs a call against a canvas served on a free port for its length, so a verb
|
|
180
|
-
* that renders frames reads the same address the board shows.
|
|
181
|
-
*/
|
|
182
|
-
export async function serveCanvas<T>(
|
|
183
|
-
root: string,
|
|
184
|
-
startOverride: CaptureDeps['start'],
|
|
185
|
-
run: (base: string) => Promise<T>,
|
|
186
|
-
): Promise<T | ServeRefused> {
|
|
187
|
-
const start = startOverride ?? startCanvas
|
|
188
|
-
const server = start(root, { port: 0, shell: new Response('') })
|
|
189
|
-
if (!server.ok) {
|
|
190
|
-
return {
|
|
191
|
-
ok: false,
|
|
192
|
-
reason: 'no-server',
|
|
193
|
-
detail: `${server.detail}, so run canon canvas serve and try again`,
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
try {
|
|
198
|
-
return await run(server.url)
|
|
199
|
-
} catch (error) {
|
|
200
|
-
return {
|
|
201
|
-
ok: false,
|
|
202
|
-
reason: 'capture-failed',
|
|
203
|
-
detail: error instanceof Error ? error.message : String(error),
|
|
204
|
-
}
|
|
205
|
-
} finally {
|
|
206
|
-
await server.stop()
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** The element a composite is captured by, holding every frame and no chrome. */
|
|
211
|
-
export const COMPOSITE_SELECTOR = '#canvas-composite'
|
|
212
|
-
|
|
213
|
-
export interface Placement {
|
|
214
|
-
readonly frame: string
|
|
215
|
-
readonly url: string
|
|
216
|
-
readonly left: number
|
|
217
|
-
readonly top: number
|
|
218
|
-
readonly width: number
|
|
219
|
-
readonly height: number
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
export interface Composite {
|
|
223
|
-
readonly placements: readonly Placement[]
|
|
224
|
-
readonly width: number
|
|
225
|
-
readonly height: number
|
|
226
|
-
readonly html: string
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* One document showing every frame of a page at its layout position. Offsets
|
|
231
|
-
* run from the top left of the union of the boxes, so a negative coordinate
|
|
232
|
-
* never clips and a lone frame fills the container exactly. Each iframe takes
|
|
233
|
-
* its box, so a document taller than its frame clips as it does on the board,
|
|
234
|
-
* and frames paint in the order given, which is the board's.
|
|
235
|
-
*/
|
|
236
|
-
export function buildComposite(
|
|
237
|
-
page: string,
|
|
238
|
-
frames: readonly Frame[],
|
|
239
|
-
base: string,
|
|
240
|
-
): Composite {
|
|
241
|
-
const left = Math.min(...frames.map((frame) => frame.x))
|
|
242
|
-
const top = Math.min(...frames.map((frame) => frame.y))
|
|
243
|
-
const right = Math.max(...frames.map((frame) => frame.x + frame.width))
|
|
244
|
-
const bottom = Math.max(...frames.map((frame) => frame.y + frame.height))
|
|
245
|
-
const width = right - left
|
|
246
|
-
const height = bottom - top
|
|
247
|
-
|
|
248
|
-
const placements = frames.map((frame) => ({
|
|
249
|
-
frame: frame.name,
|
|
250
|
-
url: frameUrl(base, page, frame),
|
|
251
|
-
left: frame.x - left,
|
|
252
|
-
top: frame.y - top,
|
|
253
|
-
width: frame.width,
|
|
254
|
-
height: frame.height,
|
|
255
|
-
}))
|
|
256
|
-
|
|
257
|
-
const iframes = placements.map(
|
|
258
|
-
(p) =>
|
|
259
|
-
`<iframe title="${escapeAttribute(p.frame)}" src="${escapeAttribute(p.url)}" style="left:${p.left}px;top:${p.top}px;width:${p.width}px;height:${p.height}px"></iframe>`,
|
|
260
|
-
)
|
|
261
|
-
const html = [
|
|
262
|
-
'<!doctype html>',
|
|
263
|
-
'<html><head><meta charset="utf-8">',
|
|
264
|
-
`<style>html,body{margin:0;background:transparent}#canvas-composite{position:relative}#canvas-composite iframe{position:absolute;display:block;border:0;background:transparent}</style>`,
|
|
265
|
-
'</head><body>',
|
|
266
|
-
`<div id="canvas-composite" style="width:${width}px;height:${height}px">`,
|
|
267
|
-
...iframes,
|
|
268
|
-
'</div></body></html>',
|
|
269
|
-
'',
|
|
270
|
-
].join('\n')
|
|
271
|
-
|
|
272
|
-
return { placements, width, height, html }
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function escapeAttribute(value: string): string {
|
|
276
|
-
return value.replaceAll('&', '&').replaceAll('"', '"')
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Where the generated document is served from, inside the canvas folder so it
|
|
281
|
-
* shares the frames' origin. Chromium skips painting a cross-origin iframe
|
|
282
|
-
* outside the viewport, and the engine's viewport is 720 pixels tall, so a
|
|
283
|
-
* document on another origin captured every frame below that line blank. A dot
|
|
284
|
-
* folder is no page name, so the board never lists it.
|
|
285
|
-
*/
|
|
286
|
-
const COMPOSITE_FOLDER = '.composite'
|
|
287
|
-
|
|
288
|
-
export interface CompositeTarget {
|
|
289
|
-
readonly page: string
|
|
290
|
-
readonly composite: Composite
|
|
291
|
-
readonly htmlPath: string
|
|
292
|
-
/** The served address of the document at `htmlPath`. */
|
|
293
|
-
readonly url: string
|
|
294
|
-
readonly pngPath: string
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
export type CompositeTargetOutcome =
|
|
298
|
-
| { readonly ok: true; readonly target: CompositeTarget }
|
|
299
|
-
| ContentRefused
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Turns `<page>` into one composite. `out` is the PNG, and without it the PNG
|
|
303
|
-
* lands at `<page>.png` beside the per-frame folder of the same name, so the
|
|
304
|
-
* two captures of one page never collide.
|
|
305
|
-
*/
|
|
306
|
-
export function resolveCompositeTarget(
|
|
307
|
-
root: string,
|
|
308
|
-
spec: string,
|
|
309
|
-
base: string,
|
|
310
|
-
out?: string,
|
|
311
|
-
): CompositeTargetOutcome {
|
|
312
|
-
if (spec.includes('/')) {
|
|
313
|
-
return refuse(
|
|
314
|
-
'invalid-name',
|
|
315
|
-
`--composite takes a page, not ${spec}, so capture <page> or drop the flag`,
|
|
316
|
-
)
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const page = readPage(root, spec)
|
|
320
|
-
if (!page) return refuse('no-page', `page ${spec} does not exist`)
|
|
321
|
-
if (page.frames.length === 0) {
|
|
322
|
-
return refuse('no-frame', `page ${spec} has no frames`)
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
const file = `${spec}.html`
|
|
326
|
-
return {
|
|
327
|
-
ok: true,
|
|
328
|
-
target: {
|
|
329
|
-
page: spec,
|
|
330
|
-
composite: buildComposite(spec, page.frames, base),
|
|
331
|
-
htmlPath: join(canvasDir(root), COMPOSITE_FOLDER, file),
|
|
332
|
-
url: `${base}${FRAMES_PREFIX.slice(1)}${COMPOSITE_FOLDER}/${encodeURIComponent(file)}`,
|
|
333
|
-
pngPath:
|
|
334
|
-
out ?? join(recordDir(root, 'tmp', 'canvas-capture'), `${spec}.png`),
|
|
335
|
-
},
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
export type CompositeOutcome =
|
|
340
|
-
| {
|
|
341
|
-
readonly ok: true
|
|
342
|
-
readonly composite: {
|
|
343
|
-
readonly target: CompositeTarget
|
|
344
|
-
readonly result: CaptureResult
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
| ContentRefused
|
|
348
|
-
| ServeRefused
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Captures a page as one PNG through the served canvas, the way `captureCanvas`
|
|
352
|
-
* captures each frame. The viewport is the container's width, so no frame's
|
|
353
|
-
* media query resolves against a window narrower than the frame itself. The
|
|
354
|
-
* page load the engine waits on waits on every iframe.
|
|
355
|
-
*/
|
|
356
|
-
export async function captureComposite(
|
|
357
|
-
root: string,
|
|
358
|
-
spec: string,
|
|
359
|
-
out?: string,
|
|
360
|
-
deps: CaptureDeps = {},
|
|
361
|
-
): Promise<CompositeOutcome> {
|
|
362
|
-
const early = resolveCompositeTarget(root, spec, '')
|
|
363
|
-
if (!early.ok) return early
|
|
364
|
-
|
|
365
|
-
return serveFor(root, deps, async (base, capture) => {
|
|
366
|
-
const resolved = resolveCompositeTarget(root, spec, base, out)
|
|
367
|
-
if (!resolved.ok) return resolved
|
|
368
|
-
|
|
369
|
-
const { target } = resolved
|
|
370
|
-
mkdirSync(dirname(target.htmlPath), { recursive: true })
|
|
371
|
-
writeFileSync(target.htmlPath, target.composite.html)
|
|
372
|
-
let result: CaptureResult | undefined
|
|
373
|
-
try {
|
|
374
|
-
;[result] = await capture(target.url, {
|
|
375
|
-
selector: COMPOSITE_SELECTOR,
|
|
376
|
-
width: target.composite.width,
|
|
377
|
-
outDir: target.pngPath,
|
|
378
|
-
})
|
|
379
|
-
} finally {
|
|
380
|
-
rmSync(target.htmlPath, { force: true })
|
|
381
|
-
}
|
|
382
|
-
if (!result) {
|
|
383
|
-
return {
|
|
384
|
-
ok: false,
|
|
385
|
-
reason: 'capture-failed',
|
|
386
|
-
detail: `the engine rendered nothing for ${spec}`,
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
return { ok: true, composite: { target, result } }
|
|
390
|
-
})
|
|
391
|
-
}
|