@erclx/canon 5.53.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 +10 -0
- package/README.md +10 -9
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +5 -5
- 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/draw-figure/REQUIREMENT.md +1 -1
- package/claude/skills/draw-figure/SKILL.md +1 -1
- package/claude/skills/pick-by-eye/REQUIREMENT.md +4 -4
- 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 +1 -1
- package/claude/skills/teach-craft/SKILL.md +32 -1
- package/claude/skills/teach-craft/references/adopted.md +113 -0
- package/claude/skills/teach-craft/references/lesson-craft.md +10 -2
- package/claude/skills/teach-craft/references/pedagogy.md +29 -2
- package/docs/agents/install-and-sync.md +0 -8
- package/docs/target/migrations.md +8 -3
- package/docs/target/projects.md +1 -1
- package/docs/workflow/skill-map.md +0 -1
- 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 -5
- package/src/claude/cases/misc.ts +0 -5
- package/src/claude/skills-audit.ts +1 -0
- package/src/cli/help.ts +0 -3
- package/src/cli.ts +0 -2
- package/src/commands/design.ts +16 -315
- package/src/commands/sync.ts +4 -5
- package/src/design/regen.ts +5 -32
- package/src/design/render.ts +1 -1
- package/src/design/tokens.ts +3 -2
- package/src/gate/stages.ts +4 -10
- package/src/gov/audit.ts +1 -6
- package/src/markdown/scope.ts +2 -4
- 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/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/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/canvas/edit.ts
DELETED
|
@@ -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 `"`, 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
|
-
}
|