@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.
Files changed (201) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +11 -13
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +5 -8
  5. package/claude/skills/context-fold/REQUIREMENT.md +0 -1
  6. package/claude/skills/design-identity/REQUIREMENT.md +2 -2
  7. package/claude/skills/design-identity/SKILL.md +6 -6
  8. package/claude/skills/design-record/REQUIREMENT.md +2 -2
  9. package/claude/skills/design-record/SKILL.md +4 -4
  10. package/claude/skills/design-record/references/folder.md +1 -1
  11. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  12. package/claude/skills/design-taste/references/kinds.md +0 -1
  13. package/claude/skills/draw-figure/REQUIREMENT.md +3 -4
  14. package/claude/skills/draw-figure/SKILL.md +5 -6
  15. package/claude/skills/draw-figure/references/figures.md +0 -1
  16. package/claude/skills/draw-figure/references/hand-drawn.md +1 -1
  17. package/claude/skills/pick-by-eye/REQUIREMENT.md +4 -5
  18. package/claude/skills/pick-by-eye/SKILL.md +34 -41
  19. package/claude/skills/pick-by-eye/references/board.md +42 -0
  20. package/claude/skills/pick-by-eye/references/live-arms.md +3 -3
  21. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  22. package/claude/skills/teach-craft/REQUIREMENT.md +30 -0
  23. package/claude/skills/teach-craft/SKILL.md +70 -0
  24. package/claude/skills/teach-craft/references/adopted.md +113 -0
  25. package/claude/skills/teach-craft/references/lesson-craft.md +60 -0
  26. package/claude/skills/{teach-workspace → teach-craft}/references/pedagogy.md +41 -6
  27. package/docs/agents/audits.md +0 -1
  28. package/docs/agents/install-and-sync.md +0 -8
  29. package/docs/target/migrations.md +16 -2
  30. package/docs/target/projects.md +1 -1
  31. package/docs/workflow/parallel-features.md +1 -1
  32. package/docs/workflow/skill-map-on-request.md +10 -22
  33. package/docs/workflow/skill-map.md +11 -14
  34. package/docs/workflow/visual-design-workflow.md +1 -4
  35. package/governance/detect.toml +1 -6
  36. package/governance/renames.toml +0 -1
  37. package/package.json +2 -8
  38. package/scripts/core/regen-web-previews.ts +2 -67
  39. package/src/audits/catalog.ts +0 -1
  40. package/src/browser/engine.ts +3 -6
  41. package/src/capture/render.ts +1 -1
  42. package/src/claude/cases/authoring.ts +0 -12
  43. package/src/claude/cases/misc.ts +0 -22
  44. package/src/claude/cases/workflow.ts +2 -38
  45. package/src/claude/skills-audit.ts +1 -1
  46. package/src/cli/help.ts +0 -10
  47. package/src/cli.ts +0 -8
  48. package/src/commands/design.ts +16 -315
  49. package/src/commands/records.ts +0 -1
  50. package/src/commands/serve.ts +2 -2
  51. package/src/commands/sync.ts +4 -5
  52. package/src/design/components.ts +8 -1514
  53. package/src/design/css.ts +5 -6
  54. package/src/design/fonts.ts +3 -5
  55. package/src/design/regen.ts +5 -32
  56. package/src/design/render.ts +1 -1
  57. package/src/design/tokens.ts +3 -2
  58. package/src/driver/drive.ts +3 -4
  59. package/src/driver/steps.ts +2 -5
  60. package/src/gate/stages.ts +4 -10
  61. package/src/gov/audit.ts +1 -6
  62. package/src/inventory/walk.ts +1 -1
  63. package/src/markdown/scope.ts +6 -35
  64. package/src/records/validate.ts +4 -176
  65. package/src/sandbox/equivalence.ts +0 -5
  66. package/src/shipped/references.ts +1 -2
  67. package/src/sync/check.ts +4 -29
  68. package/src/sync/stamp.ts +6 -6
  69. package/src/sync/target.ts +1 -4
  70. package/tooling/claude/reference.md +1 -1
  71. package/tooling/claude/seeds/canon/DESIGN.md +1 -1
  72. package/claude/skills/canvas-board/REQUIREMENT.md +0 -35
  73. package/claude/skills/canvas-board/SKILL.md +0 -143
  74. package/claude/skills/canvas-board/references/canvas.md +0 -170
  75. package/claude/skills/draft-and-pick/REQUIREMENT.md +0 -26
  76. package/claude/skills/draft-and-pick/SKILL.md +0 -11
  77. package/claude/skills/draft-doc/REQUIREMENT.md +0 -26
  78. package/claude/skills/draft-doc/SKILL.md +0 -11
  79. package/claude/skills/draft-figure/REQUIREMENT.md +0 -26
  80. package/claude/skills/draft-figure/SKILL.md +0 -11
  81. package/claude/skills/draft-identity/REQUIREMENT.md +0 -26
  82. package/claude/skills/draft-identity/SKILL.md +0 -11
  83. package/claude/skills/draft-ready/REQUIREMENT.md +0 -26
  84. package/claude/skills/draft-ready/SKILL.md +0 -11
  85. package/claude/skills/draft-screencast/REQUIREMENT.md +0 -26
  86. package/claude/skills/draft-screencast/SKILL.md +0 -11
  87. package/claude/skills/draft-slides/REQUIREMENT.md +0 -26
  88. package/claude/skills/draft-slides/SKILL.md +0 -11
  89. package/claude/skills/make-deck/REQUIREMENT.md +0 -36
  90. package/claude/skills/make-deck/SKILL.md +0 -80
  91. package/claude/skills/make-deck/references/deck.md +0 -183
  92. package/claude/skills/read-frames/REQUIREMENT.md +0 -30
  93. package/claude/skills/read-frames/SKILL.md +0 -55
  94. package/claude/skills/record-screencast/REQUIREMENT.md +0 -35
  95. package/claude/skills/record-screencast/SKILL.md +0 -99
  96. package/claude/skills/script-screencast/REQUIREMENT.md +0 -34
  97. package/claude/skills/script-screencast/SKILL.md +0 -70
  98. package/claude/skills/script-screencast/references/beats-template.md +0 -119
  99. package/claude/skills/teach-workspace/REQUIREMENT.md +0 -34
  100. package/claude/skills/teach-workspace/SKILL.md +0 -196
  101. package/claude/skills/teach-workspace/references/glossary.md +0 -90
  102. package/claude/skills/teach-workspace/references/lesson-build.md +0 -106
  103. package/claude/skills/teach-workspace/references/lesson-craft.md +0 -135
  104. package/claude/skills/teach-workspace/references/promotion-fold.md +0 -35
  105. package/claude/skills/teach-workspace/references/promotion.md +0 -116
  106. package/claude/skills/teach-workspace/references/revisit.md +0 -28
  107. package/claude/skills/teach-workspace/references/teach.md +0 -211
  108. package/claude/skills/video-craft/REQUIREMENT.md +0 -35
  109. package/claude/skills/video-craft/SKILL.md +0 -96
  110. package/claude/skills/video-craft/references/adopted.md +0 -110
  111. package/governance/rules/ui/canvas.md +0 -13
  112. package/src/canvas/address.ts +0 -227
  113. package/src/canvas/arrange.ts +0 -66
  114. package/src/canvas/capture.ts +0 -391
  115. package/src/canvas/client/app.tsx +0 -188
  116. package/src/canvas/client/inspector/align-grid.tsx +0 -162
  117. package/src/canvas/client/inspector/color-field.tsx +0 -398
  118. package/src/canvas/client/inspector/color-inputs.tsx +0 -139
  119. package/src/canvas/client/inspector/color-picker.tsx +0 -668
  120. package/src/canvas/client/inspector/color-space.ts +0 -240
  121. package/src/canvas/client/inspector/field.tsx +0 -263
  122. package/src/canvas/client/inspector/fields.css +0 -763
  123. package/src/canvas/client/inspector/section.tsx +0 -27
  124. package/src/canvas/client/inspector/size-field.tsx +0 -69
  125. package/src/canvas/client/inspector/token-select.tsx +0 -109
  126. package/src/canvas/client/inspector/values.ts +0 -297
  127. package/src/canvas/client/inspector.tsx +0 -692
  128. package/src/canvas/client/keys.ts +0 -71
  129. package/src/canvas/client/layers.tsx +0 -118
  130. package/src/canvas/client/main.tsx +0 -71
  131. package/src/canvas/client/pages-panel.tsx +0 -212
  132. package/src/canvas/client/panel-handle.tsx +0 -90
  133. package/src/canvas/client/selection.tsx +0 -199
  134. package/src/canvas/client/shell.css +0 -762
  135. package/src/canvas/client/state.ts +0 -792
  136. package/src/canvas/client/surface.tsx +0 -803
  137. package/src/canvas/client/theme-panel.tsx +0 -55
  138. package/src/canvas/client/tools.tsx +0 -153
  139. package/src/canvas/content.ts +0 -781
  140. package/src/canvas/edit.ts +0 -575
  141. package/src/canvas/editing.ts +0 -203
  142. package/src/canvas/fit.ts +0 -238
  143. package/src/canvas/global.d.ts +0 -15
  144. package/src/canvas/history.ts +0 -116
  145. package/src/canvas/index.html +0 -20
  146. package/src/canvas/server.ts +0 -766
  147. package/src/canvas/tokens.ts +0 -221
  148. package/src/commands/canvas.ts +0 -1097
  149. package/src/commands/demo.ts +0 -542
  150. package/src/commands/slides.ts +0 -184
  151. package/src/commands/teach.ts +0 -1401
  152. package/src/demo/beats.ts +0 -135
  153. package/src/demo/compile.ts +0 -350
  154. package/src/demo/container.ts +0 -219
  155. package/src/demo/cursors.ts +0 -55
  156. package/src/demo/drive.ts +0 -442
  157. package/src/demo/pointer.ts +0 -178
  158. package/src/demo/theme.ts +0 -112
  159. package/src/demo/timeline.ts +0 -64
  160. package/src/design/adapter.ts +0 -59
  161. package/src/design/base.css +0 -203
  162. package/src/design/brand-favicon.ts +0 -18
  163. package/src/design/neutral.ts +0 -127
  164. package/src/design/project-fonts.ts +0 -392
  165. package/src/slides/convert/chart.ts +0 -165
  166. package/src/slides/convert/deck.ts +0 -244
  167. package/src/slides/convert/export.ts +0 -567
  168. package/src/slides/convert/master.ts +0 -198
  169. package/src/slides/convert/shapes.ts +0 -804
  170. package/src/slides/convert/svg.ts +0 -38
  171. package/src/slides/convert/walk.ts +0 -671
  172. package/src/slides/decks.ts +0 -110
  173. package/src/slides/open.ts +0 -18
  174. package/src/slides/package/fonts.ts +0 -301
  175. package/src/slides/package/motion.ts +0 -316
  176. package/src/slides/package/test-face.ts +0 -113
  177. package/src/teach/browser/compile.ts +0 -44
  178. package/src/teach/browser/dismiss.ts +0 -76
  179. package/src/teach/browser/focus-line.ts +0 -19
  180. package/src/teach/browser/glossary-filter.ts +0 -44
  181. package/src/teach/browser/head.ts +0 -30
  182. package/src/teach/browser/quiz.ts +0 -19
  183. package/src/teach/browser/reload.ts +0 -9
  184. package/src/teach/browser/sidebar.ts +0 -233
  185. package/src/teach/browser/theme.ts +0 -18
  186. package/src/teach/components/code.tsx +0 -24
  187. package/src/teach/components/heading.tsx +0 -11
  188. package/src/teach/components/list.tsx +0 -17
  189. package/src/teach/components/paragraph.tsx +0 -29
  190. package/src/teach/components/refs.tsx +0 -28
  191. package/src/teach/cross-references.ts +0 -78
  192. package/src/teach/fonts.ts +0 -16
  193. package/src/teach/highlight.ts +0 -71
  194. package/src/teach/html/jsx-dev-runtime.ts +0 -13
  195. package/src/teach/html/jsx-runtime.ts +0 -113
  196. package/src/teach/html/raw.ts +0 -28
  197. package/src/teach/lesson.ts +0 -178
  198. package/src/teach/nav.ts +0 -1337
  199. package/src/teach/render.ts +0 -240
  200. package/src/teach/workspace.ts +0 -1164
  201. package/src/teach/write-if-changed.ts +0 -17
@@ -1,781 +0,0 @@
1
- import { createHash } from 'node:crypto'
2
- import {
3
- closeSync,
4
- existsSync,
5
- mkdirSync,
6
- openSync,
7
- readdirSync,
8
- readFileSync,
9
- renameSync,
10
- rmSync,
11
- statSync,
12
- unlinkSync,
13
- writeFileSync,
14
- } from 'node:fs'
15
- import { basename, join } from 'node:path'
16
- import {
17
- type ElementAddress,
18
- resolveAddress,
19
- type SourceElement,
20
- } from '@/canvas/address'
21
- import { recordDir } from '@/roots/record'
22
-
23
- /**
24
- * The content format. A page is a folder under the record root's `canvas/`, a
25
- * frame is one `.html` file in it, and `layout.json` beside them holds each
26
- * frame's box. The HTML stays a plain file so an agent edits a frame the way it
27
- * edits any other file, and the box lives apart from it so moving a frame never
28
- * rewrites its markup.
29
- */
30
- export const CANVAS_FOLDER = 'canvas'
31
-
32
- export const LAYOUT_FILE = 'layout.json'
33
-
34
- /** The frame or element the operator pointed at, beside the pages it names. */
35
- export const SELECTION_FILE = 'selection.json'
36
-
37
- /** Suffix of the file a write goes through before it replaces its target. */
38
- export const TEMP_SUFFIX = '.tmp'
39
-
40
- /** Suffix of the file a writer holds while it reads, merges, and replaces. */
41
- export const LOCK_SUFFIX = '.lock'
42
-
43
- /** A lock older than this was left by a writer that died holding it. */
44
- const LOCK_STALE_MS = 10_000
45
-
46
- /** How long a writer waits on another's lock before it refuses. */
47
- const LOCK_WAIT_MS = 2_000
48
-
49
- const LOCK_POLL_MS = 5
50
-
51
- const FRAME_EXTENSION = '.html'
52
-
53
- /** The size a frame takes when nothing states one. */
54
- export const DEFAULT_FRAME = { width: 1440, height: 900 } as const
55
-
56
- /** Horizontal space between frames placed in a row. */
57
- export const FRAME_GAP = 80
58
-
59
- /**
60
- * One path segment with no dot leading it, so a name can neither climb out of
61
- * the canvas folder nor hide as a dotfile the listing skips.
62
- */
63
- const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
64
-
65
- export interface Box {
66
- readonly x: number
67
- readonly y: number
68
- readonly width: number
69
- readonly height: number
70
- }
71
-
72
- export interface Frame extends Box {
73
- readonly name: string
74
- /** Relative to the page folder. */
75
- readonly file: string
76
- /** Whether the layout stated this box, or the reader placed it in a row. */
77
- readonly placed: boolean
78
- }
79
-
80
- export interface Page {
81
- readonly name: string
82
- readonly frames: readonly Frame[]
83
- /** Set when `layout.json` exists and does not parse. Frames still list. */
84
- readonly layoutIssue?: 'malformed'
85
- }
86
-
87
- export type ContentRefusal =
88
- | 'invalid-name'
89
- | 'invalid-size'
90
- | 'exists'
91
- | 'no-page'
92
- | 'no-frame'
93
- | 'invalid-position'
94
- | 'malformed-layout'
95
- | 'busy'
96
- | 'invalid-address'
97
- | 'address-mismatch'
98
- | 'stale-address'
99
-
100
- export interface ContentRefused {
101
- readonly ok: false
102
- readonly reason: ContentRefusal
103
- readonly detail: string
104
- }
105
-
106
- export type PageOutcome =
107
- | { readonly ok: true; readonly page: string }
108
- | ContentRefused
109
-
110
- export type FrameOutcome =
111
- | {
112
- readonly ok: true
113
- readonly page: string
114
- readonly frame: string
115
- readonly file: string
116
- readonly box: Box
117
- }
118
- | ContentRefused
119
-
120
- export function canvasDir(root: string): string {
121
- return recordDir(root, CANVAS_FOLDER)
122
- }
123
-
124
- function pagePath(root: string, page: string): string {
125
- return join(canvasDir(root), page)
126
- }
127
-
128
- function refuse(reason: ContentRefusal, detail: string): ContentRefused {
129
- return { ok: false, reason, detail }
130
- }
131
-
132
- export function isValidName(name: string): boolean {
133
- return NAME_PATTERN.test(name) && !name.includes('..')
134
- }
135
-
136
- function isDirectory(path: string): boolean {
137
- return statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false
138
- }
139
-
140
- function isBox(value: unknown): value is Box {
141
- if (typeof value !== 'object' || value === null) return false
142
- const box = value as Record<string, unknown>
143
- return ['x', 'y', 'width', 'height'].every(
144
- (key) => typeof box[key] === 'number' && Number.isFinite(box[key]),
145
- )
146
- }
147
-
148
- interface LayoutRead {
149
- readonly boxes: ReadonlyMap<string, Box>
150
- readonly malformed: boolean
151
- }
152
-
153
- /**
154
- * Reads what a layout states and nothing else. An entry with a box that is not
155
- * four finite numbers is skipped rather than refused, since one bad entry
156
- * should cost that frame its position and not the whole page its frames.
157
- */
158
- function readLayout(dir: string): LayoutRead {
159
- const path = join(dir, LAYOUT_FILE)
160
- if (!existsSync(path)) return { boxes: new Map(), malformed: false }
161
-
162
- let parsed: unknown
163
- try {
164
- parsed = JSON.parse(readFileSync(path, 'utf8'))
165
- } catch {
166
- return { boxes: new Map(), malformed: true }
167
- }
168
-
169
- const frames =
170
- typeof parsed === 'object' && parsed !== null
171
- ? (parsed as { frames?: unknown }).frames
172
- : undefined
173
- if (typeof frames !== 'object' || frames === null) {
174
- return { boxes: new Map(), malformed: true }
175
- }
176
-
177
- const boxes = new Map<string, Box>()
178
- for (const [name, box] of Object.entries(frames)) {
179
- if (isBox(box)) {
180
- boxes.set(name, {
181
- x: box.x,
182
- y: box.y,
183
- width: box.width,
184
- height: box.height,
185
- })
186
- }
187
- }
188
- return { boxes, malformed: false }
189
- }
190
-
191
- function sleep(ms: number): void {
192
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
193
- }
194
-
195
- /**
196
- * Holds an exclusive lock file for the length of a read, merge, and write. The
197
- * server and a CLI verb are two processes, so a synchronous step inside either
198
- * does not keep them from reading the same layout and each replacing it with
199
- * its own edit, which loses the other's. A refused call leaves the file as it
200
- * found it.
201
- */
202
- function withLayoutLock(dir: string, run: () => FrameOutcome): FrameOutcome {
203
- return withFileLock(join(dir, LAYOUT_FILE), run)
204
- }
205
-
206
- /**
207
- * The same lock over any one file under the canvas folder, which a frame
208
- * writer takes so its hash check and its write cannot straddle another's.
209
- */
210
- export function withFileLock<T>(
211
- target: string,
212
- run: () => T,
213
- ): T | ContentRefused {
214
- const lock = `${target}${LOCK_SUFFIX}`
215
- const deadline = Date.now() + LOCK_WAIT_MS
216
- for (;;) {
217
- try {
218
- closeSync(openSync(lock, 'wx'))
219
- break
220
- } catch (error) {
221
- if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
222
- const modified = statSync(lock, { throwIfNoEntry: false })?.mtimeMs
223
- if (modified !== undefined && Date.now() - modified > LOCK_STALE_MS) {
224
- rmSync(lock, { force: true })
225
- continue
226
- }
227
- if (Date.now() >= deadline) {
228
- return refuse('busy', `${basename(target)} is being written, try again`)
229
- }
230
- sleep(LOCK_POLL_MS)
231
- }
232
- }
233
- try {
234
- return run()
235
- } finally {
236
- rmSync(lock, { force: true })
237
- }
238
- }
239
-
240
- /**
241
- * Replaces the file in one rename, so the shell reading while a CLI verb writes
242
- * never sees half a layout and reports it as malformed. The caller holds
243
- * `withLayoutLock`, which is what keeps two writers from losing each other.
244
- */
245
- function writeLayout(dir: string, boxes: ReadonlyMap<string, Box>): void {
246
- const frames = Object.fromEntries(boxes)
247
- const target = join(dir, LAYOUT_FILE)
248
- const temp = `${target}.${process.pid}${TEMP_SUFFIX}`
249
- writeFileSync(temp, `${JSON.stringify({ frames }, null, 2)}\n`)
250
- renameSync(temp, target)
251
- }
252
-
253
- function frameNames(dir: string): string[] {
254
- return readdirSync(dir)
255
- .filter((entry) => entry.endsWith(FRAME_EXTENSION))
256
- .map((entry) => entry.slice(0, -FRAME_EXTENSION.length))
257
- .filter(isValidName)
258
- .sort((a, b) => a.localeCompare(b))
259
- }
260
-
261
- /** The x a frame starts at when it joins the row after every box given. */
262
- function nextX(boxes: Iterable<Box>): number {
263
- let right: number | undefined
264
- for (const box of boxes) {
265
- const edge = box.x + box.width
266
- right = right === undefined ? edge : Math.max(right, edge)
267
- }
268
- return right === undefined ? 0 : right + FRAME_GAP
269
- }
270
-
271
- export function readPage(root: string, page: string): Page | undefined {
272
- if (!isValidName(page)) return undefined
273
- const dir = pagePath(root, page)
274
- if (!isDirectory(dir)) return undefined
275
-
276
- const { boxes, malformed } = readLayout(dir)
277
- const names = frameNames(dir)
278
-
279
- const placed = names.filter((name) => boxes.has(name))
280
- const frames: Frame[] = []
281
- for (const name of placed) {
282
- const box = boxes.get(name)
283
- if (box) {
284
- frames.push({
285
- name,
286
- file: `${name}${FRAME_EXTENSION}`,
287
- ...box,
288
- placed: true,
289
- })
290
- }
291
- }
292
-
293
- for (const name of names.filter((candidate) => !boxes.has(candidate))) {
294
- frames.push({
295
- name,
296
- file: `${name}${FRAME_EXTENSION}`,
297
- x: nextX(frames),
298
- y: 0,
299
- ...DEFAULT_FRAME,
300
- placed: false,
301
- })
302
- }
303
-
304
- frames.sort((a, b) => a.name.localeCompare(b.name))
305
- return malformed
306
- ? { name: page, frames, layoutIssue: 'malformed' }
307
- : { name: page, frames }
308
- }
309
-
310
- export function listPages(root: string): Page[] {
311
- const dir = canvasDir(root)
312
- if (!isDirectory(dir)) return []
313
-
314
- return readdirSync(dir)
315
- .filter(isValidName)
316
- .filter((name) => isDirectory(join(dir, name)))
317
- .sort((a, b) => a.localeCompare(b))
318
- .flatMap((name) => readPage(root, name) ?? [])
319
- }
320
-
321
- export function addPage(root: string, page: string): PageOutcome {
322
- if (!isValidName(page)) {
323
- return refuse('invalid-name', `${page} is not a valid page name`)
324
- }
325
- const dir = pagePath(root, page)
326
- if (existsSync(dir)) return refuse('exists', `page ${page} already exists`)
327
-
328
- mkdirSync(dir, { recursive: true })
329
- return { ok: true, page }
330
- }
331
-
332
- export function renamePage(
333
- root: string,
334
- from: string,
335
- to: string,
336
- ): PageOutcome {
337
- if (!isValidName(from) || !isValidName(to)) {
338
- return refuse(
339
- 'invalid-name',
340
- `${isValidName(from) ? to : from} is not a valid page name`,
341
- )
342
- }
343
- const source = pagePath(root, from)
344
- if (!isDirectory(source))
345
- return refuse('no-page', `page ${from} does not exist`)
346
- const target = pagePath(root, to)
347
- if (existsSync(target)) return refuse('exists', `page ${to} already exists`)
348
-
349
- renameSync(source, target)
350
- return { ok: true, page: to }
351
- }
352
-
353
- function frameScaffold(name: string): string {
354
- return `<!doctype html>
355
- <html lang="en">
356
- <head>
357
- <meta charset="utf-8" />
358
- <title>${name}</title>
359
- </head>
360
- <body>
361
- <main>
362
- <h1>${name}</h1>
363
- </main>
364
- </body>
365
- </html>
366
- `
367
- }
368
-
369
- export interface FrameSize {
370
- readonly width: number
371
- readonly height: number
372
- }
373
-
374
- export function addFrame(
375
- root: string,
376
- page: string,
377
- frame: string,
378
- size: FrameSize,
379
- ): FrameOutcome {
380
- if (!isValidName(page)) {
381
- return refuse('invalid-name', `${page} is not a valid page name`)
382
- }
383
- if (!isValidName(frame)) {
384
- return refuse('invalid-name', `${frame} is not a valid frame name`)
385
- }
386
- const isPositive = (value: number) => Number.isFinite(value) && value > 0
387
- if (!isPositive(size.width) || !isPositive(size.height)) {
388
- return refuse('invalid-size', 'width and height must be positive numbers')
389
- }
390
-
391
- const dir = pagePath(root, page)
392
- if (!isDirectory(dir)) return refuse('no-page', `page ${page} does not exist`)
393
- const file = `${frame}${FRAME_EXTENSION}`
394
- if (existsSync(join(dir, file))) {
395
- return refuse('exists', `frame ${frame} already exists on ${page}`)
396
- }
397
-
398
- return withLayoutLock(dir, () => {
399
- /*
400
- * Placed against the boxes a reader would draw, unplaced frames included,
401
- * so a new frame never lands under one the layout has yet to name.
402
- */
403
- const current = readPage(root, page)?.frames ?? []
404
- const { boxes, malformed } = readLayout(dir)
405
- /* Writing over a layout that does not parse would erase every box in it. */
406
- if (malformed) {
407
- return refuse('malformed-layout', `${page}/${LAYOUT_FILE} does not parse`)
408
- }
409
- const box: Box = { x: nextX(current), y: 0, ...size }
410
-
411
- writeFileSync(join(dir, file), frameScaffold(frame))
412
- writeLayout(dir, new Map([...boxes, [frame, box]]))
413
- return { ok: true, page, frame, file, box }
414
- })
415
- }
416
-
417
- export interface Position {
418
- readonly x: number
419
- readonly y: number
420
- }
421
-
422
- /**
423
- * Hears the box a write replaced, undefined when the layout held none, and the
424
- * box it wrote, apart from the returned record, which the CLI prints.
425
- */
426
- export type BoxObserver = (replaced: Box | undefined, written: Box) => void
427
-
428
- /**
429
- * Moves one frame and keeps its size. A frame the layout never named takes the
430
- * default size it was already drawn at, so the move does not resize it.
431
- */
432
- export function moveFrame(
433
- root: string,
434
- page: string,
435
- frame: string,
436
- to: Position,
437
- observe?: BoxObserver,
438
- ): FrameOutcome {
439
- if (!isValidName(page)) {
440
- return refuse('invalid-name', `${page} is not a valid page name`)
441
- }
442
- if (!isValidName(frame)) {
443
- return refuse('invalid-name', `${frame} is not a valid frame name`)
444
- }
445
- if (!Number.isFinite(to.x) || !Number.isFinite(to.y)) {
446
- return refuse('invalid-position', 'x and y must be numbers')
447
- }
448
-
449
- const dir = pagePath(root, page)
450
- if (!isDirectory(dir)) return refuse('no-page', `page ${page} does not exist`)
451
- const file = `${frame}${FRAME_EXTENSION}`
452
- if (!existsSync(join(dir, file))) {
453
- return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
454
- }
455
-
456
- return withLayoutLock(dir, () => {
457
- const { boxes, malformed } = readLayout(dir)
458
- if (malformed) {
459
- return refuse('malformed-layout', `${page}/${LAYOUT_FILE} does not parse`)
460
- }
461
- const size = boxes.get(frame) ?? DEFAULT_FRAME
462
- const box: Box = {
463
- x: to.x,
464
- y: to.y,
465
- width: size.width,
466
- height: size.height,
467
- }
468
-
469
- writeLayout(dir, new Map([...boxes, [frame, box]]))
470
- observe?.(boxes.get(frame), box)
471
- return { ok: true, page, frame, file, box }
472
- })
473
- }
474
-
475
- /**
476
- * Writes one frame's whole box. A handle on the top or left edge moves the
477
- * frame as it resizes it, so position and size land in one locked write rather
478
- * than a move and a resize another writer could land between.
479
- */
480
- export function resizeFrame(
481
- root: string,
482
- page: string,
483
- frame: string,
484
- box: Box,
485
- observe?: BoxObserver,
486
- ): FrameOutcome {
487
- if (!isValidName(page)) {
488
- return refuse('invalid-name', `${page} is not a valid page name`)
489
- }
490
- if (!isValidName(frame)) {
491
- return refuse('invalid-name', `${frame} is not a valid frame name`)
492
- }
493
- if (!Number.isFinite(box.x) || !Number.isFinite(box.y)) {
494
- return refuse('invalid-position', 'x and y must be numbers')
495
- }
496
- const isPositive = (value: number) => Number.isFinite(value) && value > 0
497
- if (!isPositive(box.width) || !isPositive(box.height)) {
498
- return refuse('invalid-size', 'width and height must be positive numbers')
499
- }
500
-
501
- const dir = pagePath(root, page)
502
- if (!isDirectory(dir)) return refuse('no-page', `page ${page} does not exist`)
503
- const file = `${frame}${FRAME_EXTENSION}`
504
- if (!existsSync(join(dir, file))) {
505
- return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
506
- }
507
-
508
- return withLayoutLock(dir, () => {
509
- const { boxes, malformed } = readLayout(dir)
510
- if (malformed) {
511
- return refuse('malformed-layout', `${page}/${LAYOUT_FILE} does not parse`)
512
- }
513
- const written: Box = {
514
- x: box.x,
515
- y: box.y,
516
- width: box.width,
517
- height: box.height,
518
- }
519
-
520
- writeLayout(dir, new Map([...boxes, [frame, written]]))
521
- observe?.(boxes.get(frame), written)
522
- return { ok: true, page, frame, file, box: written }
523
- })
524
- }
525
-
526
- function isSameBox(a: Box | undefined, b: Box | undefined): boolean {
527
- if (a === undefined || b === undefined) return a === b
528
- return (
529
- a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
530
- )
531
- }
532
-
533
- export type BoxRestore =
534
- | { readonly ok: true }
535
- | ContentRefused
536
- | {
537
- readonly ok: false
538
- readonly reason: 'changed'
539
- readonly detail: string
540
- }
541
-
542
- /**
543
- * Writes a recorded box back under the layout lock, only while the layout
544
- * still holds the box the record expects, where undefined expects none.
545
- * Restoring to no box drops the frame's entry, so the reader places it in the
546
- * default row again.
547
- */
548
- export function restoreBox(
549
- root: string,
550
- page: string,
551
- frame: string,
552
- expected: Box | undefined,
553
- to: Box | undefined,
554
- ): BoxRestore {
555
- const dir = pagePath(root, page)
556
- if (!isDirectory(dir)) return refuse('no-page', `page ${page} does not exist`)
557
- if (!existsSync(join(dir, `${frame}${FRAME_EXTENSION}`))) {
558
- return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
559
- }
560
-
561
- return withFileLock(join(dir, LAYOUT_FILE), (): BoxRestore => {
562
- const { boxes, malformed } = readLayout(dir)
563
- if (malformed) {
564
- return refuse('malformed-layout', `${page}/${LAYOUT_FILE} does not parse`)
565
- }
566
- if (!isSameBox(boxes.get(frame), expected)) {
567
- return {
568
- ok: false,
569
- reason: 'changed',
570
- detail: `${page}/${frame} moved since this change, so nothing was written`,
571
- }
572
- }
573
- const next = new Map(boxes)
574
- if (to === undefined) next.delete(frame)
575
- else next.set(frame, to)
576
- writeLayout(dir, next)
577
- return { ok: true }
578
- })
579
- }
580
-
581
- export type PruneOutcome =
582
- | { readonly ok: true; readonly dropped: readonly string[] }
583
- | ContentRefused
584
-
585
- /**
586
- * Lists the layout entries whose frame file is gone, and removes them under
587
- * the layout lock unless `write` is false. `readPage` never reports them, so
588
- * this is the one place an entry outliving its frame is seen.
589
- */
590
- export function pruneLayout(
591
- root: string,
592
- page: string,
593
- write: boolean,
594
- ): PruneOutcome {
595
- if (!isValidName(page)) {
596
- return refuse('invalid-name', `${page} is not a valid page name`)
597
- }
598
- const dir = pagePath(root, page)
599
- if (!isDirectory(dir)) return refuse('no-page', `page ${page} does not exist`)
600
-
601
- const prune = (): PruneOutcome => {
602
- const { boxes, malformed } = readLayout(dir)
603
- if (malformed) {
604
- return refuse('malformed-layout', `${page}/${LAYOUT_FILE} does not parse`)
605
- }
606
- const present = new Set(frameNames(dir))
607
- const dropped = [...boxes.keys()].filter((name) => !present.has(name))
608
- if (write && dropped.length > 0) {
609
- writeLayout(
610
- dir,
611
- new Map([...boxes].filter(([name]) => present.has(name))),
612
- )
613
- }
614
- return { ok: true, dropped }
615
- }
616
-
617
- return write ? withFileLock(join(dir, LAYOUT_FILE), prune) : prune()
618
- }
619
-
620
- export interface Selection {
621
- readonly page: string
622
- readonly frame: string
623
- readonly element?: ElementAddress
624
- }
625
-
626
- export interface SelectedElement extends SourceElement {
627
- readonly index: number
628
- /**
629
- * Whether the frame file changed since the element was picked. The address
630
- * is an index, so an edit can shift it onto another element, and the fields
631
- * here are what was picked rather than what sits at the index now.
632
- */
633
- readonly stale: boolean
634
- }
635
-
636
- export interface SelectedFrame {
637
- readonly page: string
638
- readonly frame: string
639
- readonly file: string
640
- readonly box: Box
641
- readonly element?: SelectedElement
642
- }
643
-
644
- export type SelectionOutcome = { readonly ok: true } | ContentRefused
645
-
646
- interface StoredElement extends SourceElement {
647
- readonly index: number
648
- readonly hash: string
649
- }
650
-
651
- function selectionPath(root: string): string {
652
- return join(canvasDir(root), SELECTION_FILE)
653
- }
654
-
655
- export function contentHash(html: string | Uint8Array): string {
656
- return createHash('sha256').update(html).digest('hex')
657
- }
658
-
659
- function isAddress(value: ElementAddress): boolean {
660
- return (
661
- Number.isInteger(value.index) &&
662
- Number.isInteger(value.count) &&
663
- value.index >= 0 &&
664
- value.index < value.count &&
665
- typeof value.tag === 'string' &&
666
- value.tag !== ''
667
- )
668
- }
669
-
670
- /**
671
- * Records which frame, or which element in it, "this one" means, or clears it.
672
- * Only a frame on disk can be recorded, and only an element the file holds at
673
- * the address given, so the file never names something a reader would have to
674
- * guess about.
675
- */
676
- export function writeSelection(
677
- root: string,
678
- selection: Selection | undefined,
679
- ): SelectionOutcome {
680
- if (selection === undefined) {
681
- if (existsSync(selectionPath(root))) unlinkSync(selectionPath(root))
682
- return { ok: true }
683
- }
684
-
685
- const { page, frame, element } = selection
686
- if (!isValidName(page) || !isValidName(frame)) {
687
- return refuse('invalid-name', `${page}/${frame} is not a valid frame`)
688
- }
689
- const onPage = readPage(root, page)
690
- if (!onPage) return refuse('no-page', `page ${page} does not exist`)
691
- const found = onPage.frames.find((candidate) => candidate.name === frame)
692
- if (!found) {
693
- return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
694
- }
695
-
696
- let stored: StoredElement | undefined
697
- if (element !== undefined) {
698
- if (!isAddress(element)) {
699
- return refuse(
700
- 'invalid-address',
701
- 'an element address needs a whole index below its count and a tag',
702
- )
703
- }
704
- const bytes = readFileSync(join(pagePath(root, page), found.file))
705
- const hash = contentHash(bytes)
706
- const html = bytes.toString('utf8')
707
- if (element.hash !== undefined && element.hash !== hash) {
708
- return refuse(
709
- 'stale-address',
710
- `${page}/${frame} changed after the pick was made, so pick it again`,
711
- )
712
- }
713
- const check = resolveAddress(html, element)
714
- if (!check.ok) return refuse(check.reason, check.detail)
715
- stored = { index: element.index, ...check.element, hash }
716
- }
717
-
718
- const target = selectionPath(root)
719
- const temp = `${target}.${process.pid}${TEMP_SUFFIX}`
720
- const record = stored ? { page, frame, element: stored } : { page, frame }
721
- writeFileSync(temp, `${JSON.stringify(record)}\n`)
722
- renameSync(temp, target)
723
- return { ok: true }
724
- }
725
-
726
- function readStoredElement(value: unknown): StoredElement | undefined {
727
- if (typeof value !== 'object' || value === null) return undefined
728
- const { index, tag, classes, text, hash } = value as Record<string, unknown>
729
- if (
730
- typeof index !== 'number' ||
731
- typeof tag !== 'string' ||
732
- !Array.isArray(classes) ||
733
- !classes.every((name) => typeof name === 'string') ||
734
- typeof text !== 'string' ||
735
- typeof hash !== 'string'
736
- ) {
737
- return undefined
738
- }
739
- return { index, tag, classes, text, hash }
740
- }
741
-
742
- /**
743
- * The selected frame with its box, or undefined when nothing is selected, the
744
- * file does not parse, or the frame it names has since been removed, so a
745
- * reader never gets a stale name back. An element is reported as picked, with
746
- * `stale` set once the frame file no longer matches what was picked from.
747
- */
748
- export function readSelection(root: string): SelectedFrame | undefined {
749
- const path = selectionPath(root)
750
- if (!existsSync(path)) return undefined
751
-
752
- let parsed: unknown
753
- try {
754
- parsed = JSON.parse(readFileSync(path, 'utf8'))
755
- } catch {
756
- return undefined
757
- }
758
- if (typeof parsed !== 'object' || parsed === null) return undefined
759
- const { page, frame, element } = parsed as Record<string, unknown>
760
- if (typeof page !== 'string' || typeof frame !== 'string') return undefined
761
-
762
- const found = readPage(root, page)?.frames.find(
763
- (candidate) => candidate.name === frame,
764
- )
765
- if (!found) return undefined
766
- const { x, y, width, height } = found
767
- const selected = {
768
- page,
769
- frame,
770
- file: found.file,
771
- box: { x, y, width, height },
772
- }
773
-
774
- const stored = readStoredElement(element)
775
- if (!stored) return selected
776
- const { hash, ...picked } = stored
777
- const current = contentHash(
778
- readFileSync(join(pagePath(root, page), found.file)),
779
- )
780
- return { ...selected, element: { ...picked, stale: current !== hash } }
781
- }