@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,575 +0,0 @@
1
- import { readFileSync, renameSync, writeFileSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import {
4
- countElements,
5
- type ElementAddress,
6
- isRawText,
7
- resolveAddress,
8
- sourceElements,
9
- } from '@/canvas/address'
10
- import {
11
- type ContentRefusal,
12
- canvasDir,
13
- contentHash,
14
- readPage,
15
- readSelection,
16
- TEMP_SUFFIX,
17
- withFileLock,
18
- writeSelection,
19
- } from '@/canvas/content'
20
- import type { ElementState } from '@/canvas/history'
21
-
22
- /**
23
- * The basic set an operator edits by hand. Each is a CSS property written into
24
- * the element's inline style, except `text`, which replaces its text.
25
- */
26
- export const STYLE_PROPERTIES = [
27
- 'color',
28
- 'background-color',
29
- 'font-size',
30
- 'font-weight',
31
- 'font-family',
32
- 'line-height',
33
- 'letter-spacing',
34
- 'text-align',
35
- 'width',
36
- 'height',
37
- 'padding',
38
- 'gap',
39
- 'display',
40
- 'flex-direction',
41
- 'justify-content',
42
- 'align-items',
43
- 'flex-wrap',
44
- 'opacity',
45
- 'border-radius',
46
- 'border-top-left-radius',
47
- 'border-top-right-radius',
48
- 'border-bottom-right-radius',
49
- 'border-bottom-left-radius',
50
- ] as const
51
-
52
- export type StyleProperty = (typeof STYLE_PROPERTIES)[number]
53
-
54
- /**
55
- * The longhands a shorthand write drops, so a later longhand in the attribute
56
- * does not keep overriding the value just set. A longhand write leaves its
57
- * shorthand alone, since the longhand comes later and already wins.
58
- */
59
- const SHORTHAND_LONGHANDS: Readonly<
60
- Partial<Record<StyleProperty, readonly string[]>>
61
- > = {
62
- 'border-radius': [
63
- 'border-top-left-radius',
64
- 'border-top-right-radius',
65
- 'border-bottom-right-radius',
66
- 'border-bottom-left-radius',
67
- ],
68
- padding: ['padding-top', 'padding-right', 'padding-bottom', 'padding-left'],
69
- }
70
-
71
- export type EditProperty = StyleProperty | 'text'
72
-
73
- export interface EditChange {
74
- readonly property: string
75
- /** Empty drops a style property, and sets an element's text to nothing. */
76
- readonly value: string
77
- }
78
-
79
- export type EditRefusal = ContentRefusal | 'invalid-edit' | 'not-text-only'
80
-
81
- export interface EditRefused {
82
- readonly ok: false
83
- readonly reason: EditRefusal
84
- readonly detail: string
85
- }
86
-
87
- export type AppliedEdit = { readonly ok: true; readonly html: string }
88
-
89
- export type FrameEdit =
90
- | {
91
- readonly ok: true
92
- readonly page: string
93
- readonly frame: string
94
- readonly file: string
95
- /** The file's hash after the edit, which the next edit is made from. */
96
- readonly hash: string
97
- }
98
- | EditRefused
99
-
100
- const MAX_VALUE_LENGTH = 200
101
-
102
- /** Anything that would end the declaration and start another. */
103
- const DECLARATION_BREAK = /[;{}<>\n\r]/
104
-
105
- const BOM = ''
106
-
107
- /* The rewriter drops a leading byte order mark, so a read skips it first. */
108
- function withoutBom(html: string): string {
109
- return html.startsWith(BOM) ? html.slice(BOM.length) : html
110
- }
111
-
112
- function refuse(reason: EditRefusal, detail: string): EditRefused {
113
- return { ok: false, reason, detail }
114
- }
115
-
116
- function isStyleProperty(property: string): property is StyleProperty {
117
- return (STYLE_PROPERTIES as readonly string[]).includes(property)
118
- }
119
-
120
- export function isEditProperty(property: string): property is EditProperty {
121
- return property === 'text' || isStyleProperty(property)
122
- }
123
-
124
- function checkChange(change: EditChange): EditRefused | undefined {
125
- if (!isEditProperty(change.property)) {
126
- return refuse(
127
- 'invalid-edit',
128
- `${change.property} is not editable here. Edit one of text, ${STYLE_PROPERTIES.join(', ')}`,
129
- )
130
- }
131
- if (change.value.length > MAX_VALUE_LENGTH) {
132
- return refuse(
133
- 'invalid-edit',
134
- `the value runs past ${MAX_VALUE_LENGTH} characters`,
135
- )
136
- }
137
- if (change.property !== 'text' && DECLARATION_BREAK.test(change.value)) {
138
- return refuse(
139
- 'invalid-edit',
140
- `${change.property} takes one value, with no ; { } < > or line break`,
141
- )
142
- }
143
- return undefined
144
- }
145
-
146
- const CHARACTER_REFERENCE = /&(?:#\d+|#x[0-9a-f]+|[a-z][a-z0-9]*);/iy
147
-
148
- /**
149
- * Splits a style attribute at each semicolon outside parentheses and quotes,
150
- * keeping every segment's own spacing so an untouched one is written back as
151
- * it was. The rewriter hands the attribute over undecoded and writes a quote
152
- * back as `&quot;`, so a character reference's own semicolon never splits.
153
- */
154
- function declarations(style: string): string[] {
155
- const segments: string[] = []
156
- let depth = 0
157
- let quote: string | undefined
158
- let start = 0
159
- for (let at = 0; at < style.length; at += 1) {
160
- const char = style[at]
161
- CHARACTER_REFERENCE.lastIndex = at
162
- if (char === '&' && CHARACTER_REFERENCE.test(style)) {
163
- at = CHARACTER_REFERENCE.lastIndex - 1
164
- } else if (quote) {
165
- if (char === quote) quote = undefined
166
- } else if (char === '"' || char === "'") quote = char
167
- else if (char === '(') depth += 1
168
- else if (char === ')') depth = Math.max(0, depth - 1)
169
- else if (char === ';' && depth === 0) {
170
- segments.push(style.slice(start, at))
171
- start = at + 1
172
- }
173
- }
174
- segments.push(style.slice(start))
175
- return segments
176
- }
177
-
178
- function propertyOf(segment: string): string {
179
- return segment.split(':', 1)[0]?.trim().toLowerCase() ?? ''
180
- }
181
-
182
- /**
183
- * Sets one property in a style attribute, or drops it on an empty value. The
184
- * first declaration of it is replaced in place and any later one dropped, so
185
- * nothing further in the attribute overrides the edit. A shorthand also drops
186
- * its longhands. Undefined means no style is left.
187
- */
188
- export function setDeclaration(
189
- style: string,
190
- property: StyleProperty,
191
- value: string,
192
- ): string | undefined {
193
- const longhands = SHORTHAND_LONGHANDS[property] ?? []
194
- const segments = declarations(style).filter(
195
- (segment) => !longhands.includes(propertyOf(segment)),
196
- )
197
- const matches = segments.filter((segment) => propertyOf(segment) === property)
198
- const declaration = `${property}: ${value.trim()}`
199
-
200
- let next: string[]
201
- if (value.trim() === '') {
202
- next = segments.filter((segment) => propertyOf(segment) !== property)
203
- } else if (matches.length > 0) {
204
- let isReplaced = false
205
- next = segments.flatMap((segment) => {
206
- if (propertyOf(segment) !== property) return [segment]
207
- if (isReplaced) return []
208
- isReplaced = true
209
- const lead = segment.match(/^\s*/)?.[0] ?? ''
210
- return [`${lead}${declaration}`]
211
- })
212
- } else {
213
- const kept = segments
214
- .join(';')
215
- .replace(/^[\s;]+/, '')
216
- .replace(/[\s;]*$/, '')
217
- return kept === '' ? declaration : `${kept}; ${declaration}`
218
- }
219
-
220
- if (next.every((segment) => segment.trim() === '')) return undefined
221
- return next.join(';').replace(/^[\s;]+/, '')
222
- }
223
-
224
- /**
225
- * Whether the element at an index holds text alone, so replacing its text
226
- * flattens no nested markup. An element closed only by implication stays open
227
- * to the end and reads as holding everything after it, which refuses.
228
- */
229
- function holdsTextAlone(html: string, index: number): boolean {
230
- let isOpen = false
231
- let hasChild = false
232
- countElements(new HTMLRewriter(), (_element, at) => {
233
- if (isOpen) hasChild = true
234
- if (at !== index) return undefined
235
- isOpen = true
236
- return () => {
237
- isOpen = false
238
- }
239
- }).transform(html)
240
- return !hasChild
241
- }
242
-
243
- /**
244
- * Applies one change to the element the address names and leaves every other
245
- * byte of the file as it was. The address is checked against the file first,
246
- * the same way a pick is.
247
- */
248
- export function applyEdit(
249
- source: string,
250
- address: ElementAddress,
251
- change: EditChange,
252
- ): AppliedEdit | EditRefused {
253
- const invalid = checkChange(change)
254
- if (invalid) return invalid
255
-
256
- /* The rewriter drops a leading byte order mark, so it is put back after. */
257
- const bom = source.startsWith(BOM) ? BOM : ''
258
- const html = source.slice(bom.length)
259
-
260
- const check = resolveAddress(html, address)
261
- if (!check.ok) return refuse(check.reason, check.detail)
262
-
263
- const { property, value } = change
264
- if (property === 'text') {
265
- if (isRawText(address.tag) || !holdsTextAlone(html, address.index)) {
266
- return refuse(
267
- 'not-text-only',
268
- `<${address.tag}> holds other elements or raw content, so its text is not edited as one string`,
269
- )
270
- }
271
- }
272
-
273
- let voidRefused = false
274
- const rewritten = countElements(new HTMLRewriter(), (element, at) => {
275
- if (at !== address.index) return undefined
276
- if (property === 'text') {
277
- if (!element.canHaveContent || element.selfClosing) {
278
- voidRefused = true
279
- return undefined
280
- }
281
- element.setInnerContent(value, { html: false })
282
- return undefined
283
- }
284
- if (!isStyleProperty(property)) return undefined
285
- const style = setDeclaration(
286
- element.getAttribute('style') ?? '',
287
- property,
288
- value,
289
- )
290
- if (style === undefined) element.removeAttribute('style')
291
- else element.setAttribute('style', style)
292
- return undefined
293
- }).transform(html)
294
-
295
- if (voidRefused) {
296
- return refuse('not-text-only', `<${address.tag}> cannot hold text`)
297
- }
298
- return { ok: true, html: `${bom}${rewritten}` }
299
- }
300
-
301
- interface ElementRead {
302
- readonly tag: string
303
- readonly style: string | undefined
304
- /** The raw text inside it, entities still encoded, when it holds text alone. */
305
- readonly inner: string | undefined
306
- }
307
-
308
- /**
309
- * What the element at an index holds, read as the file states it. Each text
310
- * chunk the rewriter hands over is the source text, so the inner content keeps
311
- * every entity as written.
312
- */
313
- function readElement(html: string, index: number): ElementRead | undefined {
314
- let found: { tag: string; style: string | undefined } | undefined
315
- let isOpen = false
316
- let hasChild = false
317
- let inner = ''
318
- countElements(new HTMLRewriter(), (element, at) => {
319
- if (isOpen) hasChild = true
320
- if (at !== index) return undefined
321
- found = {
322
- tag: element.tagName.toLowerCase(),
323
- style: element.getAttribute('style') ?? undefined,
324
- }
325
- if (!element.canHaveContent || element.selfClosing) return undefined
326
- isOpen = true
327
- return () => {
328
- isOpen = false
329
- }
330
- })
331
- .onDocument({
332
- text(chunk) {
333
- if (isOpen) inner += chunk.text
334
- },
335
- })
336
- .transform(html)
337
- if (!found) return undefined
338
- return { ...found, inner: hasChild ? undefined : inner }
339
- }
340
-
341
- function stateOf(read: ElementRead, withInner: boolean): ElementState {
342
- return withInner
343
- ? { style: read.style, inner: read.inner }
344
- : { style: read.style }
345
- }
346
-
347
- function sameState(read: ElementRead, state: ElementState): boolean {
348
- if (read.style !== state.style) return false
349
- return state.inner === undefined || read.inner === state.inner
350
- }
351
-
352
- /**
353
- * Restamps a selection that was fresh before a write, since neither an edit
354
- * nor a restore moves an element.
355
- */
356
- function restampSelection(
357
- root: string,
358
- page: string,
359
- frame: string,
360
- before: ReturnType<typeof readSelection>,
361
- html: string,
362
- ): void {
363
- if (
364
- before?.page !== page ||
365
- before.frame !== frame ||
366
- !before.element ||
367
- before.element.stale
368
- ) {
369
- return
370
- }
371
- writeSelection(root, {
372
- page,
373
- frame,
374
- element: {
375
- index: before.element.index,
376
- tag: before.element.tag,
377
- count: sourceElements(html).length,
378
- },
379
- })
380
- }
381
-
382
- function writeFrame(path: string, html: string): void {
383
- const temp = `${path}.${process.pid}${TEMP_SUFFIX}`
384
- writeFileSync(temp, html)
385
- renameSync(temp, path)
386
- }
387
-
388
- /** The element's state on each side of an edit, which undo restores. */
389
- export type EditObserver = (states: {
390
- readonly before: ElementState
391
- readonly after: ElementState
392
- }) => void
393
-
394
- /**
395
- * Edits one element of a frame file under the file's lock. An address carrying
396
- * a hash the file no longer has is refused rather than applied at an index the
397
- * change may have shifted. A selection that was fresh before the edit is
398
- * restamped after it, since the edit moves no element. An observer hears the
399
- * element's state before and after a write that landed, apart from the
400
- * returned record, which the CLI prints.
401
- */
402
- export function editFrame(
403
- root: string,
404
- page: string,
405
- frame: string,
406
- address: ElementAddress,
407
- change: EditChange,
408
- observe?: EditObserver,
409
- ): FrameEdit {
410
- const invalid = checkChange(change)
411
- if (invalid) return invalid
412
-
413
- const onPage = readPage(root, page)
414
- if (!onPage) return refuse('no-page', `page ${page} does not exist`)
415
- const found = onPage.frames.find((candidate) => candidate.name === frame)
416
- if (!found) {
417
- return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
418
- }
419
-
420
- const path = join(canvasDir(root), page, found.file)
421
- return withFileLock(path, (): FrameEdit => {
422
- const bytes = readFileSync(path)
423
- if (address.hash !== undefined && address.hash !== contentHash(bytes)) {
424
- return refuse(
425
- 'stale-address',
426
- `${page}/${frame} changed after this edit was made, so nothing was written`,
427
- )
428
- }
429
- const selected = readSelection(root)
430
- const source = bytes.toString('utf8')
431
-
432
- const applied = applyEdit(source, address, change)
433
- if (!applied.ok) return applied
434
-
435
- writeFrame(path, applied.html)
436
- restampSelection(root, page, frame, selected, applied.html)
437
-
438
- if (observe) {
439
- const withInner = change.property === 'text'
440
- const before = readElement(withoutBom(source), address.index)
441
- const after = readElement(withoutBom(applied.html), address.index)
442
- if (before && after) {
443
- observe({
444
- before: stateOf(before, withInner),
445
- after: stateOf(after, withInner),
446
- })
447
- }
448
- }
449
- return {
450
- ok: true,
451
- page,
452
- frame,
453
- file: found.file,
454
- hash: contentHash(applied.html),
455
- }
456
- })
457
- }
458
-
459
- export type RestoreRefusal = EditRefusal | 'changed'
460
-
461
- export type ElementRestore =
462
- | { readonly ok: true; readonly hash: string }
463
- | {
464
- readonly ok: false
465
- readonly reason: RestoreRefusal
466
- readonly detail: string
467
- }
468
-
469
- /**
470
- * Writes a recorded state back to one element under the file's lock, only
471
- * while the file holds as many elements as it did at the edit and the element
472
- * at that index still carries the tag and the state the record expects. An
473
- * unstyled state matches any unstyled sibling, so the count is what catches
474
- * an element inserted or removed ahead of the target. Anything else means the
475
- * file moved on since, so the restore refuses as `changed` rather than
476
- * overwrite a newer state or land on a neighbor. The state comes from the
477
- * server's own record, never a request, since inner content is written back
478
- * as markup.
479
- */
480
- export function restoreElement(
481
- root: string,
482
- page: string,
483
- frame: string,
484
- target: {
485
- readonly index: number
486
- readonly tag: string
487
- readonly count: number
488
- },
489
- expected: ElementState,
490
- to: ElementState,
491
- ): ElementRestore {
492
- const onPage = readPage(root, page)
493
- if (!onPage) return refuse('no-page', `page ${page} does not exist`)
494
- const found = onPage.frames.find((candidate) => candidate.name === frame)
495
- if (!found) {
496
- return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
497
- }
498
-
499
- const path = join(canvasDir(root), page, found.file)
500
- return withFileLock(path, (): ElementRestore => {
501
- const source = readFileSync(path, 'utf8')
502
- const bom = source.startsWith(BOM) ? BOM : ''
503
- const html = source.slice(bom.length)
504
- const current = readElement(html, target.index)
505
- if (
506
- sourceElements(html).length !== target.count ||
507
- current?.tag !== target.tag ||
508
- !sameState(current, expected)
509
- ) {
510
- return {
511
- ok: false,
512
- reason: 'changed',
513
- detail: `<${target.tag}> in ${page}/${frame} changed since this edit, so nothing was written`,
514
- }
515
- }
516
- const selected = readSelection(root)
517
- const rewritten = countElements(new HTMLRewriter(), (element, at) => {
518
- if (at !== target.index) return undefined
519
- if (to.style === undefined) element.removeAttribute('style')
520
- else element.setAttribute('style', to.style)
521
- if (to.inner !== undefined) {
522
- element.setInnerContent(to.inner, { html: true })
523
- }
524
- return undefined
525
- }).transform(html)
526
- const written = `${bom}${rewritten}`
527
-
528
- writeFrame(path, written)
529
- restampSelection(root, page, frame, selected, written)
530
- return { ok: true, hash: contentHash(written) }
531
- })
532
- }
533
-
534
- /**
535
- * Edits the element at an index of the file as it stands, for a caller that
536
- * holds no served frame to take an address from. The address it builds
537
- * carries the hash it read, so a write landing between the read and the lock
538
- * still refuses.
539
- */
540
- export function editFrameAtIndex(
541
- root: string,
542
- page: string,
543
- frame: string,
544
- index: number,
545
- change: EditChange,
546
- ): FrameEdit {
547
- const onPage = readPage(root, page)
548
- if (!onPage) return refuse('no-page', `page ${page} does not exist`)
549
- const found = onPage.frames.find((candidate) => candidate.name === frame)
550
- if (!found) {
551
- return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
552
- }
553
-
554
- const bytes = readFileSync(join(canvasDir(root), page, found.file))
555
- const elements = sourceElements(bytes.toString('utf8'))
556
- const element = Number.isInteger(index) ? elements[index] : undefined
557
- if (!element) {
558
- return refuse(
559
- 'invalid-address',
560
- `${page}/${frame} holds elements 0 to ${elements.length - 1}, so ${index} names none`,
561
- )
562
- }
563
- return editFrame(
564
- root,
565
- page,
566
- frame,
567
- {
568
- index,
569
- tag: element.tag,
570
- count: elements.length,
571
- hash: contentHash(bytes),
572
- },
573
- change,
574
- )
575
- }