@erclx/canon 5.17.0 → 5.19.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/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
- package/claude/skills/deploy-app/SKILL.md +45 -0
- package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
- package/claude/skills/deploy-app/references/vercel.md +72 -0
- package/docs/agents/architecture-record.md +12 -4
- package/docs/agents/canvas.md +13 -1
- package/docs/agents/commands.md +3 -2
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/context-audit.md +4 -4
- package/docs/agents/index.md +2 -2
- package/docs/agents/teach.md +7 -1
- package/docs/workflow/skill-map.md +9 -9
- package/package.json +2 -1
- package/src/audits/catalog.ts +46 -1
- package/src/canvas/address.ts +73 -39
- package/src/canvas/client/inspector.tsx +241 -33
- package/src/canvas/client/pages-panel.tsx +51 -0
- package/src/canvas/client/shell.css +126 -1
- package/src/canvas/client/state.ts +120 -4
- package/src/canvas/client/theme-panel.tsx +55 -0
- package/src/canvas/content.ts +14 -3
- package/src/canvas/edit.ts +360 -0
- package/src/canvas/server.ts +50 -5
- package/src/canvas/tokens.ts +82 -0
- package/src/claude/cases/misc.ts +5 -1
- package/src/commands/canvas.ts +95 -1
- package/src/commands/context.ts +85 -5
- package/src/commands/slides.ts +73 -4
- package/src/commands/teach.ts +112 -4
- package/src/context/architecture.ts +98 -18
- package/src/context/gate.ts +30 -3
- package/src/context/requirements.ts +77 -0
- package/src/gate/measures.ts +69 -28
- package/src/gate/stages.ts +5 -5
- package/src/serve/live.ts +113 -0
- package/src/serve/static.ts +60 -2
- package/src/slides/convert/export.ts +323 -0
- package/src/slides/convert/shapes.ts +764 -0
- package/src/slides/convert/svg.ts +38 -0
- package/src/slides/convert/walk.ts +444 -0
- package/src/teach/browser/reload.ts +9 -0
- package/src/teach/nav.ts +7 -6
- package/src/teach/workspace.ts +2 -1
- package/src/teach/write-if-changed.ts +17 -0
- package/standards/architecture.md +13 -7
- package/standards/requirements.md +4 -0
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
- package/tooling/cloudflare/reference.md +1 -1
- package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { computed, signal } from '@preact/signals'
|
|
2
2
|
import type { ElementAddress } from '@/canvas/address'
|
|
3
3
|
import type { Frame, Page } from '@/canvas/content'
|
|
4
|
+
import type { TokenGroup } from '@/canvas/tokens'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Everything the panels read. Type imports only from outside `client/`, since
|
|
@@ -15,6 +16,17 @@ export interface TokenSource {
|
|
|
15
16
|
readonly source: 'toolkit' | 'installed' | 'none'
|
|
16
17
|
readonly files?: readonly string[]
|
|
17
18
|
readonly notice?: string
|
|
19
|
+
/** What the stylesheet defines, which the token picker and Theme tab list. */
|
|
20
|
+
readonly groups?: readonly TokenGroup[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type LeftTab = 'pages' | 'theme'
|
|
24
|
+
|
|
25
|
+
/** One property change to the selected element, as the inspector posts it. */
|
|
26
|
+
export interface PendingEdit {
|
|
27
|
+
readonly key: string
|
|
28
|
+
readonly index: number
|
|
29
|
+
readonly property: string
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
export interface FrameRef {
|
|
@@ -98,6 +110,26 @@ export const draggingFrame = signal<string | undefined>(undefined)
|
|
|
98
110
|
/** Why the last write to the canvas failed, until the next one succeeds. */
|
|
99
111
|
export const writeError = signal<string | undefined>(undefined)
|
|
100
112
|
|
|
113
|
+
export const leftTab = signal<LeftTab>('pages')
|
|
114
|
+
|
|
115
|
+
/** The edit in flight, which holds its field until the server answers. */
|
|
116
|
+
export const pendingEdit = signal<PendingEdit | undefined>(undefined)
|
|
117
|
+
|
|
118
|
+
/** The edit that last landed, for a moment after it does. */
|
|
119
|
+
export const savedEdit = signal<PendingEdit | undefined>(undefined)
|
|
120
|
+
|
|
121
|
+
const SAVED_MS = 2500
|
|
122
|
+
|
|
123
|
+
/** Why the last edit wrote nothing, until the next one lands. */
|
|
124
|
+
export const editRefusal = signal<string | undefined>(undefined)
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The hash an edit answered by frame key, until that frame reloads. A second
|
|
128
|
+
* edit sent before the reload is made from the file the first one wrote, not
|
|
129
|
+
* from the document still on screen.
|
|
130
|
+
*/
|
|
131
|
+
export const editedHashes = signal<ReadonlyMap<string, string>>(new Map())
|
|
132
|
+
|
|
101
133
|
export const currentPage = computed<Page | undefined>(() => {
|
|
102
134
|
const all = pages.value
|
|
103
135
|
return all.find((page) => page.name === selectedPage.value) ?? all[0]
|
|
@@ -142,6 +174,17 @@ export function registerFrameDocument(key: string, doc: Document): void {
|
|
|
142
174
|
const next = new Map(frameDocuments.value)
|
|
143
175
|
next.set(key, doc)
|
|
144
176
|
frameDocuments.value = next
|
|
177
|
+
if (editedHashes.value.has(key)) {
|
|
178
|
+
const hashes = new Map(editedHashes.value)
|
|
179
|
+
hashes.delete(key)
|
|
180
|
+
editedHashes.value = hashes
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function reloadFrame(key: string): void {
|
|
185
|
+
const next = new Map(frameVersions.value)
|
|
186
|
+
next.set(key, (next.get(key) ?? 0) + 1)
|
|
187
|
+
frameVersions.value = next
|
|
145
188
|
}
|
|
146
189
|
|
|
147
190
|
export function toggleLayers(key: string): void {
|
|
@@ -181,10 +224,7 @@ export async function applyChange(
|
|
|
181
224
|
fetchImpl: typeof fetch = fetch,
|
|
182
225
|
): Promise<void> {
|
|
183
226
|
if (change.file.endsWith('.html')) {
|
|
184
|
-
|
|
185
|
-
const next = new Map(frameVersions.value)
|
|
186
|
-
next.set(key, (next.get(key) ?? 0) + 1)
|
|
187
|
-
frameVersions.value = next
|
|
227
|
+
reloadFrame(`${change.page}/${change.file}`)
|
|
188
228
|
}
|
|
189
229
|
await loadPages(fetchImpl)
|
|
190
230
|
}
|
|
@@ -267,6 +307,77 @@ export async function selectElement(
|
|
|
267
307
|
await write('/api/selection', { ...ref, element: address }, fetchImpl)
|
|
268
308
|
}
|
|
269
309
|
|
|
310
|
+
/** What a refused edit tells the operator, by the server's reason. */
|
|
311
|
+
const EDIT_NOTICES: Readonly<Record<string, string | undefined>> = {
|
|
312
|
+
'stale-address':
|
|
313
|
+
'The frame changed before this edit arrived, so nothing was saved. It reloads with the file as it stands now. Make the edit again there.',
|
|
314
|
+
'address-mismatch':
|
|
315
|
+
'Could not save, since the browser and the file count the elements of this frame differently.',
|
|
316
|
+
'not-text-only':
|
|
317
|
+
'Could not save the text, since this element holds other elements.',
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Posts one property change to the element the address names. A refusal
|
|
322
|
+
* says why and reloads the frame, so a pending value never sits over a file
|
|
323
|
+
* that moved under it.
|
|
324
|
+
*/
|
|
325
|
+
export async function editElement(
|
|
326
|
+
ref: FrameRef,
|
|
327
|
+
key: string,
|
|
328
|
+
address: ElementAddress,
|
|
329
|
+
property: string,
|
|
330
|
+
value: string,
|
|
331
|
+
fetchImpl: typeof fetch = fetch,
|
|
332
|
+
): Promise<void> {
|
|
333
|
+
const hash = editedHashes.value.get(key) ?? address.hash
|
|
334
|
+
pendingEdit.value = { key, index: address.index, property }
|
|
335
|
+
try {
|
|
336
|
+
const response = await fetchImpl('/api/frames/edit', {
|
|
337
|
+
method: 'POST',
|
|
338
|
+
headers: { 'content-type': 'application/json' },
|
|
339
|
+
body: JSON.stringify({
|
|
340
|
+
...ref,
|
|
341
|
+
element: { ...address, hash },
|
|
342
|
+
property,
|
|
343
|
+
value,
|
|
344
|
+
}),
|
|
345
|
+
})
|
|
346
|
+
const body: unknown = await response.json().catch(() => undefined)
|
|
347
|
+
const record =
|
|
348
|
+
typeof body === 'object' && body !== null
|
|
349
|
+
? (body as Record<string, unknown>)
|
|
350
|
+
: {}
|
|
351
|
+
if (!response.ok) {
|
|
352
|
+
const reason = typeof record.reason === 'string' ? record.reason : ''
|
|
353
|
+
const detail = typeof record.detail === 'string' ? record.detail : ''
|
|
354
|
+
editRefusal.value =
|
|
355
|
+
EDIT_NOTICES[reason] ??
|
|
356
|
+
(reason === 'invalid-edit'
|
|
357
|
+
? `Could not save that value (${detail}).`
|
|
358
|
+
: `Could not save (status ${response.status}). Check canon canvas serve is still running.`)
|
|
359
|
+
reloadFrame(key)
|
|
360
|
+
await loadPages(fetchImpl)
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
editRefusal.value = undefined
|
|
364
|
+
const saved = pendingEdit.value
|
|
365
|
+
savedEdit.value = saved
|
|
366
|
+
setTimeout(() => {
|
|
367
|
+
if (savedEdit.value === saved) savedEdit.value = undefined
|
|
368
|
+
}, SAVED_MS)
|
|
369
|
+
if (typeof record.hash === 'string') {
|
|
370
|
+
const hashes = new Map(editedHashes.value)
|
|
371
|
+
hashes.set(key, record.hash)
|
|
372
|
+
editedHashes.value = hashes
|
|
373
|
+
}
|
|
374
|
+
} catch (error) {
|
|
375
|
+
editRefusal.value = `Could not save (${error instanceof Error ? error.message : 'unknown'}). Check canon canvas serve is still running.`
|
|
376
|
+
} finally {
|
|
377
|
+
pendingEdit.value = undefined
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
270
381
|
/**
|
|
271
382
|
* Moves a frame on the surface without writing, which a drag calls on every
|
|
272
383
|
* pointer move. The next reread replaces it with what the layout holds.
|
|
@@ -369,4 +480,9 @@ export function resetState(): void {
|
|
|
369
480
|
expandedFrames.value = new Set()
|
|
370
481
|
draggingFrame.value = undefined
|
|
371
482
|
writeError.value = undefined
|
|
483
|
+
leftTab.value = 'pages'
|
|
484
|
+
pendingEdit.value = undefined
|
|
485
|
+
savedEdit.value = undefined
|
|
486
|
+
editRefusal.value = undefined
|
|
487
|
+
editedHashes.value = new Map()
|
|
372
488
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import type { JSX } from 'preact'
|
|
3
|
+
import { tokens } from '@/canvas/client/state'
|
|
4
|
+
import type { Token, TokenKind } from '@/canvas/tokens'
|
|
5
|
+
|
|
6
|
+
const KIND_LABEL: Record<TokenKind, string> = {
|
|
7
|
+
color: 'Color',
|
|
8
|
+
spacing: 'Spacing',
|
|
9
|
+
radius: 'Radius',
|
|
10
|
+
'font-family': 'Font family',
|
|
11
|
+
'font-size': 'Font size',
|
|
12
|
+
other: 'Other',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A swatch paints the value in the chrome's document, where the frame's own
|
|
17
|
+
* custom properties are not defined, so a value still naming one shows none.
|
|
18
|
+
*/
|
|
19
|
+
function Swatch({ token }: { readonly token: Token }): JSX.Element | null {
|
|
20
|
+
const paint = token.resolved ?? token.value
|
|
21
|
+
if (paint.includes('var(')) return null
|
|
22
|
+
return (
|
|
23
|
+
<span class="swatch" aria-hidden="true" style={{ background: paint }} />
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read-only: the frames' tokens are edited in their stylesheet, not here. */
|
|
28
|
+
export function ThemePanel(): JSX.Element {
|
|
29
|
+
const groups = tokens.value?.groups ?? []
|
|
30
|
+
return (
|
|
31
|
+
<section aria-label="Theme">
|
|
32
|
+
{groups.length === 0 ? (
|
|
33
|
+
<p class="empty">
|
|
34
|
+
No tokens resolve for this project, so there is nothing to list.{' '}
|
|
35
|
+
{tokens.value?.notice ?? ''}
|
|
36
|
+
</p>
|
|
37
|
+
) : (
|
|
38
|
+
groups.map((group) => (
|
|
39
|
+
<div key={group.kind}>
|
|
40
|
+
<h2 class="section-label">{KIND_LABEL[group.kind]}</h2>
|
|
41
|
+
<ul class="tokens" aria-label={`${KIND_LABEL[group.kind]} tokens`}>
|
|
42
|
+
{group.tokens.map((token) => (
|
|
43
|
+
<li key={token.name} class="token" title={token.value}>
|
|
44
|
+
{group.kind === 'color' ? <Swatch token={token} /> : null}
|
|
45
|
+
<span class="row-label">{token.name}</span>
|
|
46
|
+
<span class="row-meta token-value">{token.value}</span>
|
|
47
|
+
</li>
|
|
48
|
+
))}
|
|
49
|
+
</ul>
|
|
50
|
+
</div>
|
|
51
|
+
))
|
|
52
|
+
)}
|
|
53
|
+
</section>
|
|
54
|
+
)
|
|
55
|
+
}
|
package/src/canvas/content.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
unlinkSync,
|
|
13
13
|
writeFileSync,
|
|
14
14
|
} from 'node:fs'
|
|
15
|
-
import { join } from 'node:path'
|
|
15
|
+
import { basename, join } from 'node:path'
|
|
16
16
|
import {
|
|
17
17
|
type ElementAddress,
|
|
18
18
|
resolveAddress,
|
|
@@ -200,7 +200,18 @@ function sleep(ms: number): void {
|
|
|
200
200
|
* found it.
|
|
201
201
|
*/
|
|
202
202
|
function withLayoutLock(dir: string, run: () => FrameOutcome): FrameOutcome {
|
|
203
|
-
|
|
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}`
|
|
204
215
|
const deadline = Date.now() + LOCK_WAIT_MS
|
|
205
216
|
for (;;) {
|
|
206
217
|
try {
|
|
@@ -214,7 +225,7 @@ function withLayoutLock(dir: string, run: () => FrameOutcome): FrameOutcome {
|
|
|
214
225
|
continue
|
|
215
226
|
}
|
|
216
227
|
if (Date.now() >= deadline) {
|
|
217
|
-
return refuse('busy', `${
|
|
228
|
+
return refuse('busy', `${basename(target)} is being written, try again`)
|
|
218
229
|
}
|
|
219
230
|
sleep(LOCK_POLL_MS)
|
|
220
231
|
}
|
|
@@ -0,0 +1,360 @@
|
|
|
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
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The basic set an operator edits by hand. Each is a CSS property written into
|
|
23
|
+
* the element's inline style, except `text`, which replaces its text.
|
|
24
|
+
*/
|
|
25
|
+
export const STYLE_PROPERTIES = [
|
|
26
|
+
'color',
|
|
27
|
+
'background-color',
|
|
28
|
+
'font-size',
|
|
29
|
+
'font-weight',
|
|
30
|
+
'width',
|
|
31
|
+
'height',
|
|
32
|
+
'padding',
|
|
33
|
+
'gap',
|
|
34
|
+
'flex-direction',
|
|
35
|
+
] as const
|
|
36
|
+
|
|
37
|
+
export type StyleProperty = (typeof STYLE_PROPERTIES)[number]
|
|
38
|
+
|
|
39
|
+
export type EditProperty = StyleProperty | 'text'
|
|
40
|
+
|
|
41
|
+
export interface EditChange {
|
|
42
|
+
readonly property: string
|
|
43
|
+
/** Empty drops a style property, and sets an element's text to nothing. */
|
|
44
|
+
readonly value: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type EditRefusal = ContentRefusal | 'invalid-edit' | 'not-text-only'
|
|
48
|
+
|
|
49
|
+
export interface EditRefused {
|
|
50
|
+
readonly ok: false
|
|
51
|
+
readonly reason: EditRefusal
|
|
52
|
+
readonly detail: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type AppliedEdit = { readonly ok: true; readonly html: string }
|
|
56
|
+
|
|
57
|
+
export type FrameEdit =
|
|
58
|
+
| {
|
|
59
|
+
readonly ok: true
|
|
60
|
+
readonly page: string
|
|
61
|
+
readonly frame: string
|
|
62
|
+
readonly file: string
|
|
63
|
+
/** The file's hash after the edit, which the next edit is made from. */
|
|
64
|
+
readonly hash: string
|
|
65
|
+
}
|
|
66
|
+
| EditRefused
|
|
67
|
+
|
|
68
|
+
const MAX_VALUE_LENGTH = 200
|
|
69
|
+
|
|
70
|
+
/** Anything that would end the declaration and start another. */
|
|
71
|
+
const DECLARATION_BREAK = /[;{}<>\n\r]/
|
|
72
|
+
|
|
73
|
+
const BOM = ''
|
|
74
|
+
|
|
75
|
+
function refuse(reason: EditRefusal, detail: string): EditRefused {
|
|
76
|
+
return { ok: false, reason, detail }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isStyleProperty(property: string): property is StyleProperty {
|
|
80
|
+
return (STYLE_PROPERTIES as readonly string[]).includes(property)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isEditProperty(property: string): property is EditProperty {
|
|
84
|
+
return property === 'text' || isStyleProperty(property)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function checkChange(change: EditChange): EditRefused | undefined {
|
|
88
|
+
if (!isEditProperty(change.property)) {
|
|
89
|
+
return refuse(
|
|
90
|
+
'invalid-edit',
|
|
91
|
+
`${change.property} is not editable here. Edit one of text, ${STYLE_PROPERTIES.join(', ')}`,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
if (change.value.length > MAX_VALUE_LENGTH) {
|
|
95
|
+
return refuse(
|
|
96
|
+
'invalid-edit',
|
|
97
|
+
`the value runs past ${MAX_VALUE_LENGTH} characters`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
if (change.property !== 'text' && DECLARATION_BREAK.test(change.value)) {
|
|
101
|
+
return refuse(
|
|
102
|
+
'invalid-edit',
|
|
103
|
+
`${change.property} takes one value, with no ; { } < > or line break`,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
return undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Splits a style attribute at each semicolon outside parentheses and quotes,
|
|
111
|
+
* keeping every segment's own spacing so an untouched one is written back as
|
|
112
|
+
* it was.
|
|
113
|
+
*/
|
|
114
|
+
function declarations(style: string): string[] {
|
|
115
|
+
const segments: string[] = []
|
|
116
|
+
let depth = 0
|
|
117
|
+
let quote: string | undefined
|
|
118
|
+
let start = 0
|
|
119
|
+
for (let at = 0; at < style.length; at += 1) {
|
|
120
|
+
const char = style[at]
|
|
121
|
+
if (quote) {
|
|
122
|
+
if (char === quote) quote = undefined
|
|
123
|
+
} else if (char === '"' || char === "'") quote = char
|
|
124
|
+
else if (char === '(') depth += 1
|
|
125
|
+
else if (char === ')') depth = Math.max(0, depth - 1)
|
|
126
|
+
else if (char === ';' && depth === 0) {
|
|
127
|
+
segments.push(style.slice(start, at))
|
|
128
|
+
start = at + 1
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
segments.push(style.slice(start))
|
|
132
|
+
return segments
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function propertyOf(segment: string): string {
|
|
136
|
+
return segment.split(':', 1)[0]?.trim().toLowerCase() ?? ''
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Sets one property in a style attribute, or drops it on an empty value. The
|
|
141
|
+
* first declaration of it is replaced in place and any later one dropped, so
|
|
142
|
+
* nothing further in the attribute overrides the edit. Undefined means no
|
|
143
|
+
* style is left.
|
|
144
|
+
*/
|
|
145
|
+
export function setDeclaration(
|
|
146
|
+
style: string,
|
|
147
|
+
property: StyleProperty,
|
|
148
|
+
value: string,
|
|
149
|
+
): string | undefined {
|
|
150
|
+
const segments = declarations(style)
|
|
151
|
+
const matches = segments.filter((segment) => propertyOf(segment) === property)
|
|
152
|
+
const declaration = `${property}: ${value.trim()}`
|
|
153
|
+
|
|
154
|
+
let next: string[]
|
|
155
|
+
if (value.trim() === '') {
|
|
156
|
+
next = segments.filter((segment) => propertyOf(segment) !== property)
|
|
157
|
+
} else if (matches.length > 0) {
|
|
158
|
+
let isReplaced = false
|
|
159
|
+
next = segments.flatMap((segment) => {
|
|
160
|
+
if (propertyOf(segment) !== property) return [segment]
|
|
161
|
+
if (isReplaced) return []
|
|
162
|
+
isReplaced = true
|
|
163
|
+
const lead = segment.match(/^\s*/)?.[0] ?? ''
|
|
164
|
+
return [`${lead}${declaration}`]
|
|
165
|
+
})
|
|
166
|
+
} else {
|
|
167
|
+
const kept = style.replace(/[\s;]*$/, '')
|
|
168
|
+
return kept === '' ? declaration : `${kept}; ${declaration}`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (next.every((segment) => segment.trim() === '')) return undefined
|
|
172
|
+
return next.join(';').replace(/^[\s;]+/, '')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Whether the element at an index holds text alone, so replacing its text
|
|
177
|
+
* flattens no nested markup. An element closed only by implication stays open
|
|
178
|
+
* to the end and reads as holding everything after it, which refuses.
|
|
179
|
+
*/
|
|
180
|
+
function holdsTextAlone(html: string, index: number): boolean {
|
|
181
|
+
let isOpen = false
|
|
182
|
+
let hasChild = false
|
|
183
|
+
countElements(new HTMLRewriter(), (_element, at) => {
|
|
184
|
+
if (isOpen) hasChild = true
|
|
185
|
+
if (at !== index) return undefined
|
|
186
|
+
isOpen = true
|
|
187
|
+
return () => {
|
|
188
|
+
isOpen = false
|
|
189
|
+
}
|
|
190
|
+
}).transform(html)
|
|
191
|
+
return !hasChild
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Applies one change to the element the address names and leaves every other
|
|
196
|
+
* byte of the file as it was. The address is checked against the file first,
|
|
197
|
+
* the same way a pick is.
|
|
198
|
+
*/
|
|
199
|
+
export function applyEdit(
|
|
200
|
+
source: string,
|
|
201
|
+
address: ElementAddress,
|
|
202
|
+
change: EditChange,
|
|
203
|
+
): AppliedEdit | EditRefused {
|
|
204
|
+
const invalid = checkChange(change)
|
|
205
|
+
if (invalid) return invalid
|
|
206
|
+
|
|
207
|
+
/* The rewriter drops a leading byte order mark, so it is put back after. */
|
|
208
|
+
const bom = source.startsWith(BOM) ? BOM : ''
|
|
209
|
+
const html = source.slice(bom.length)
|
|
210
|
+
|
|
211
|
+
const check = resolveAddress(html, address)
|
|
212
|
+
if (!check.ok) return refuse(check.reason, check.detail)
|
|
213
|
+
|
|
214
|
+
const { property, value } = change
|
|
215
|
+
if (property === 'text') {
|
|
216
|
+
if (isRawText(address.tag) || !holdsTextAlone(html, address.index)) {
|
|
217
|
+
return refuse(
|
|
218
|
+
'not-text-only',
|
|
219
|
+
`<${address.tag}> holds other elements or raw content, so its text is not edited as one string`,
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let voidRefused = false
|
|
225
|
+
const rewritten = countElements(new HTMLRewriter(), (element, at) => {
|
|
226
|
+
if (at !== address.index) return undefined
|
|
227
|
+
if (property === 'text') {
|
|
228
|
+
if (!element.canHaveContent || element.selfClosing) {
|
|
229
|
+
voidRefused = true
|
|
230
|
+
return undefined
|
|
231
|
+
}
|
|
232
|
+
element.setInnerContent(value, { html: false })
|
|
233
|
+
return undefined
|
|
234
|
+
}
|
|
235
|
+
if (!isStyleProperty(property)) return undefined
|
|
236
|
+
const style = setDeclaration(
|
|
237
|
+
element.getAttribute('style') ?? '',
|
|
238
|
+
property,
|
|
239
|
+
value,
|
|
240
|
+
)
|
|
241
|
+
if (style === undefined) element.removeAttribute('style')
|
|
242
|
+
else element.setAttribute('style', style)
|
|
243
|
+
return undefined
|
|
244
|
+
}).transform(html)
|
|
245
|
+
|
|
246
|
+
if (voidRefused) {
|
|
247
|
+
return refuse('not-text-only', `<${address.tag}> cannot hold text`)
|
|
248
|
+
}
|
|
249
|
+
return { ok: true, html: `${bom}${rewritten}` }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Edits one element of a frame file under the file's lock. An address carrying
|
|
254
|
+
* a hash the file no longer has is refused rather than applied at an index the
|
|
255
|
+
* change may have shifted. A selection that was fresh before the edit is
|
|
256
|
+
* restamped after it, since the edit moves no element.
|
|
257
|
+
*/
|
|
258
|
+
export function editFrame(
|
|
259
|
+
root: string,
|
|
260
|
+
page: string,
|
|
261
|
+
frame: string,
|
|
262
|
+
address: ElementAddress,
|
|
263
|
+
change: EditChange,
|
|
264
|
+
): FrameEdit {
|
|
265
|
+
const invalid = checkChange(change)
|
|
266
|
+
if (invalid) return invalid
|
|
267
|
+
|
|
268
|
+
const onPage = readPage(root, page)
|
|
269
|
+
if (!onPage) return refuse('no-page', `page ${page} does not exist`)
|
|
270
|
+
const found = onPage.frames.find((candidate) => candidate.name === frame)
|
|
271
|
+
if (!found) {
|
|
272
|
+
return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const path = join(canvasDir(root), page, found.file)
|
|
276
|
+
return withFileLock(path, (): FrameEdit => {
|
|
277
|
+
const bytes = readFileSync(path)
|
|
278
|
+
if (address.hash !== undefined && address.hash !== contentHash(bytes)) {
|
|
279
|
+
return refuse(
|
|
280
|
+
'stale-address',
|
|
281
|
+
`${page}/${frame} changed after this edit was made, so nothing was written`,
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
const before = readSelection(root)
|
|
285
|
+
const isSelectionFresh =
|
|
286
|
+
before?.page === page &&
|
|
287
|
+
before.frame === frame &&
|
|
288
|
+
before.element !== undefined &&
|
|
289
|
+
!before.element.stale
|
|
290
|
+
|
|
291
|
+
const applied = applyEdit(bytes.toString('utf8'), address, change)
|
|
292
|
+
if (!applied.ok) return applied
|
|
293
|
+
|
|
294
|
+
const temp = `${path}.${process.pid}${TEMP_SUFFIX}`
|
|
295
|
+
writeFileSync(temp, applied.html)
|
|
296
|
+
renameSync(temp, path)
|
|
297
|
+
|
|
298
|
+
if (isSelectionFresh && before.element) {
|
|
299
|
+
writeSelection(root, {
|
|
300
|
+
page,
|
|
301
|
+
frame,
|
|
302
|
+
element: {
|
|
303
|
+
index: before.element.index,
|
|
304
|
+
tag: before.element.tag,
|
|
305
|
+
count: sourceElements(applied.html).length,
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
ok: true,
|
|
311
|
+
page,
|
|
312
|
+
frame,
|
|
313
|
+
file: found.file,
|
|
314
|
+
hash: contentHash(applied.html),
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Edits the element at an index of the file as it stands, for a caller that
|
|
321
|
+
* holds no served frame to take an address from. The address it builds
|
|
322
|
+
* carries the hash it read, so a write landing between the read and the lock
|
|
323
|
+
* still refuses.
|
|
324
|
+
*/
|
|
325
|
+
export function editFrameAtIndex(
|
|
326
|
+
root: string,
|
|
327
|
+
page: string,
|
|
328
|
+
frame: string,
|
|
329
|
+
index: number,
|
|
330
|
+
change: EditChange,
|
|
331
|
+
): FrameEdit {
|
|
332
|
+
const onPage = readPage(root, page)
|
|
333
|
+
if (!onPage) return refuse('no-page', `page ${page} does not exist`)
|
|
334
|
+
const found = onPage.frames.find((candidate) => candidate.name === frame)
|
|
335
|
+
if (!found) {
|
|
336
|
+
return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const bytes = readFileSync(join(canvasDir(root), page, found.file))
|
|
340
|
+
const elements = sourceElements(bytes.toString('utf8'))
|
|
341
|
+
const element = Number.isInteger(index) ? elements[index] : undefined
|
|
342
|
+
if (!element) {
|
|
343
|
+
return refuse(
|
|
344
|
+
'invalid-address',
|
|
345
|
+
`${page}/${frame} holds elements 0 to ${elements.length - 1}, so ${index} names none`,
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
return editFrame(
|
|
349
|
+
root,
|
|
350
|
+
page,
|
|
351
|
+
frame,
|
|
352
|
+
{
|
|
353
|
+
index,
|
|
354
|
+
tag: element.tag,
|
|
355
|
+
count: elements.length,
|
|
356
|
+
hash: contentHash(bytes),
|
|
357
|
+
},
|
|
358
|
+
change,
|
|
359
|
+
)
|
|
360
|
+
}
|