@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/server.ts
DELETED
|
@@ -1,766 +0,0 @@
|
|
|
1
|
-
import { type FSWatcher, mkdirSync, watch } from 'node:fs'
|
|
2
|
-
import {
|
|
3
|
-
countElements,
|
|
4
|
-
type ElementAddress,
|
|
5
|
-
HASH_ATTRIBUTE,
|
|
6
|
-
IMPLIED_ATTRIBUTE,
|
|
7
|
-
TOKENS_ATTRIBUTE,
|
|
8
|
-
} from '@/canvas/address'
|
|
9
|
-
import {
|
|
10
|
-
type BoxObserver,
|
|
11
|
-
type ContentRefused,
|
|
12
|
-
canvasDir,
|
|
13
|
-
contentHash,
|
|
14
|
-
listPages,
|
|
15
|
-
LOCK_SUFFIX,
|
|
16
|
-
moveFrame,
|
|
17
|
-
readSelection,
|
|
18
|
-
resizeFrame,
|
|
19
|
-
restoreBox,
|
|
20
|
-
TEMP_SUFFIX,
|
|
21
|
-
writeSelection,
|
|
22
|
-
} from '@/canvas/content'
|
|
23
|
-
import { type EditRefused, editFrame, restoreElement } from '@/canvas/edit'
|
|
24
|
-
import { readEditing } from '@/canvas/editing'
|
|
25
|
-
import {
|
|
26
|
-
type ApplyResult,
|
|
27
|
-
type Direction,
|
|
28
|
-
History,
|
|
29
|
-
type HistoryEntry,
|
|
30
|
-
} from '@/canvas/history'
|
|
31
|
-
import {
|
|
32
|
-
resolveFrameTokens,
|
|
33
|
-
type TokenOptions,
|
|
34
|
-
tokenGroups,
|
|
35
|
-
} from '@/canvas/tokens'
|
|
36
|
-
import { buildDesignCss } from '@/design/css'
|
|
37
|
-
import { brandFavicon } from '@/design/brand-favicon'
|
|
38
|
-
import {
|
|
39
|
-
bindFirstFree,
|
|
40
|
-
PORT_ATTEMPTS,
|
|
41
|
-
respond,
|
|
42
|
-
SERVE_HOST,
|
|
43
|
-
type ServeRefusal,
|
|
44
|
-
} from '@/serve/static'
|
|
45
|
-
|
|
46
|
-
/** Clear of `canon serve`'s default, so a lesson preview and the canvas both open. */
|
|
47
|
-
export const CANVAS_PORT = 8790
|
|
48
|
-
|
|
49
|
-
/** The URL prefix a frame and the files beside it are served under. */
|
|
50
|
-
export const FRAMES_PREFIX = '/frames/'
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* How long a burst of watch events for one file waits before it is sent. One
|
|
54
|
-
* editor save raises several, and each would otherwise reload the frame again.
|
|
55
|
-
*/
|
|
56
|
-
const SETTLE_MS = 40
|
|
57
|
-
|
|
58
|
-
export interface CanvasStarted {
|
|
59
|
-
readonly ok: true
|
|
60
|
-
readonly root: string
|
|
61
|
-
readonly content: string
|
|
62
|
-
readonly host: string
|
|
63
|
-
readonly port: number
|
|
64
|
-
readonly url: string
|
|
65
|
-
readonly stop: () => Promise<void>
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export interface CanvasRefused {
|
|
69
|
-
readonly ok: false
|
|
70
|
-
readonly reason: Extract<ServeRefusal, 'no-port' | 'bind-failed'>
|
|
71
|
-
readonly detail: string
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export type CanvasOutcome = CanvasStarted | CanvasRefused
|
|
75
|
-
|
|
76
|
-
export interface CanvasOptions {
|
|
77
|
-
readonly port?: number
|
|
78
|
-
/**
|
|
79
|
-
* What answers `/`. The command passes the HTML import of `index.html`, which
|
|
80
|
-
* `Bun.serve` bundles on request, and a test passes a plain response, since
|
|
81
|
-
* the test runner cannot load an HTML module.
|
|
82
|
-
*/
|
|
83
|
-
readonly shell: Bun.HTMLBundle | Response
|
|
84
|
-
readonly tokens?: TokenOptions
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export interface ChangeEvent {
|
|
88
|
-
readonly page: string
|
|
89
|
-
/**
|
|
90
|
-
* The path under the page folder, or empty when the change is to the
|
|
91
|
-
* top-level entry itself, being a page folder added, renamed, or removed.
|
|
92
|
-
* An empty file names no frame, so a reader rereads the page list alone.
|
|
93
|
-
*/
|
|
94
|
-
readonly file: string
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Escapes the one sequence that would end the injected element early. The
|
|
99
|
-
* hash is hex, so it needs no escaping inside the attribute.
|
|
100
|
-
*/
|
|
101
|
-
function styleElement(css: string, hash: string | undefined): string {
|
|
102
|
-
const stamp = hash === undefined ? '' : ` ${HASH_ATTRIBUTE}="${hash}"`
|
|
103
|
-
return `<style ${TOKENS_ATTRIBUTE}${stamp}>${css.replaceAll('</style', '<\\/style')}</style>`
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** The wrappers a browser builds whether or not the file states them. */
|
|
107
|
-
const WRAPPERS = new Set(['html', 'head', 'body'])
|
|
108
|
-
|
|
109
|
-
/** Which of the document wrappers the file states, counted as the address counts. */
|
|
110
|
-
function statedWrappers(html: string): Set<string> {
|
|
111
|
-
const stated = new Set<string>()
|
|
112
|
-
countElements(new HTMLRewriter(), (element) => {
|
|
113
|
-
const tag = element.tagName.toLowerCase()
|
|
114
|
-
if (WRAPPERS.has(tag)) stated.add(tag)
|
|
115
|
-
return undefined
|
|
116
|
-
}).transform(html)
|
|
117
|
-
return stated
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** Prepends markup inside the first element a selector matches. */
|
|
121
|
-
function prependFirst(html: string, selector: string, markup: string): string {
|
|
122
|
-
let isDone = false
|
|
123
|
-
return new HTMLRewriter()
|
|
124
|
-
.on(selector, {
|
|
125
|
-
element(element) {
|
|
126
|
-
if (isDone) return
|
|
127
|
-
isDone = true
|
|
128
|
-
element.prepend(markup, { html: true })
|
|
129
|
-
},
|
|
130
|
-
})
|
|
131
|
-
.transform(html)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* What a browser parses into the head it builds ahead of a fragment's first
|
|
136
|
-
* content. A comment or whitespace among them stays in that head too.
|
|
137
|
-
*/
|
|
138
|
-
const HEAD_CONTENT = new Set([
|
|
139
|
-
'base',
|
|
140
|
-
'link',
|
|
141
|
-
'meta',
|
|
142
|
-
'noscript',
|
|
143
|
-
'script',
|
|
144
|
-
'style',
|
|
145
|
-
'template',
|
|
146
|
-
'title',
|
|
147
|
-
])
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Opens a marked body where the browser would build its own: ahead of the
|
|
151
|
-
* first element or text outside the head content a file leads with, or at the
|
|
152
|
-
* end when the file holds nothing else.
|
|
153
|
-
*/
|
|
154
|
-
function openBodyBeforeContent(html: string, marker: string): string {
|
|
155
|
-
let isOpen = false
|
|
156
|
-
let headDepth = 0
|
|
157
|
-
const open = (target: HTMLRewriterTypes.Element | HTMLRewriterTypes.Text) => {
|
|
158
|
-
isOpen = true
|
|
159
|
-
target.before(marker, { html: true })
|
|
160
|
-
}
|
|
161
|
-
return new HTMLRewriter()
|
|
162
|
-
.on('*', {
|
|
163
|
-
element(element) {
|
|
164
|
-
if (isOpen || headDepth > 0) return
|
|
165
|
-
const tag = element.tagName.toLowerCase()
|
|
166
|
-
if (tag === 'html') return
|
|
167
|
-
if (!HEAD_CONTENT.has(tag)) {
|
|
168
|
-
open(element)
|
|
169
|
-
return
|
|
170
|
-
}
|
|
171
|
-
if (!element.canHaveContent || element.selfClosing) return
|
|
172
|
-
headDepth += 1
|
|
173
|
-
element.onEndTag(() => {
|
|
174
|
-
headDepth -= 1
|
|
175
|
-
})
|
|
176
|
-
},
|
|
177
|
-
})
|
|
178
|
-
.onDocument({
|
|
179
|
-
text(chunk) {
|
|
180
|
-
if (!isOpen && headDepth === 0 && chunk.text.trim() !== '') open(chunk)
|
|
181
|
-
},
|
|
182
|
-
end(end) {
|
|
183
|
-
if (!isOpen) end.append(marker, { html: true })
|
|
184
|
-
},
|
|
185
|
-
})
|
|
186
|
-
.transform(html)
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Puts the tokens first in the head, so a stylesheet the frame links itself
|
|
191
|
-
* still wins the cascade. A hash stamps the element even when no tokens
|
|
192
|
-
* resolve, since the shell sends it back with an element pick.
|
|
193
|
-
*
|
|
194
|
-
* A wrapper the file leaves out is written in marked, since the browser would
|
|
195
|
-
* build it anyway and the shell's count would run past the file's. An `html`
|
|
196
|
-
* goes right after the doctype, which keeps the frame out of quirks mode. A
|
|
197
|
-
* marked `head` is left open, so the head content a fragment leads with parses
|
|
198
|
-
* into it the way it would into the head a browser builds, and a file stating
|
|
199
|
-
* neither `head` nor `body` gets a marked body opened where that content ends.
|
|
200
|
-
* A file stating a `head` but no `body` is left to the browser.
|
|
201
|
-
*/
|
|
202
|
-
export function injectTokens(html: string, css: string, hash?: string): string {
|
|
203
|
-
if (css === '' && hash === undefined) return html
|
|
204
|
-
const style = styleElement(css, hash)
|
|
205
|
-
const stated = statedWrappers(html)
|
|
206
|
-
const isHeadStated = stated.has('head')
|
|
207
|
-
const withBody =
|
|
208
|
-
isHeadStated || stated.has('body')
|
|
209
|
-
? html
|
|
210
|
-
: openBodyBeforeContent(html, `<body ${IMPLIED_ATTRIBUTE}>`)
|
|
211
|
-
if (isHeadStated) {
|
|
212
|
-
const withHead = prependFirst(withBody, 'head', style)
|
|
213
|
-
return stated.has('html') ? withHead : markedHtml(withHead, '')
|
|
214
|
-
}
|
|
215
|
-
const head = `<head ${IMPLIED_ATTRIBUTE}>${style}`
|
|
216
|
-
return stated.has('html')
|
|
217
|
-
? prependFirst(withBody, 'html', head)
|
|
218
|
-
: markedHtml(withBody, head)
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Writes a marked `html` and what follows it right after the doctype. */
|
|
222
|
-
function markedHtml(html: string, lead: string): string {
|
|
223
|
-
const doctype = html.match(/^\s*<!doctype[^>]*>/i)?.[0] ?? ''
|
|
224
|
-
return `${doctype}<html ${IMPLIED_ATTRIBUTE}>${lead}${html.slice(doctype.length)}`
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Whether a request names this server by a loopback name and its own port.
|
|
229
|
-
* The bind keeps other machines out, and this keeps out a page the operator
|
|
230
|
-
* visits whose domain was rebound to 127.0.0.1, which reaches the socket with
|
|
231
|
-
* its own name in the Host header.
|
|
232
|
-
*/
|
|
233
|
-
export function isLoopbackHost(host: string | null, port: number): boolean {
|
|
234
|
-
return host === `${SERVE_HOST}:${port}` || host === `localhost:${port}`
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
type Handler = (
|
|
238
|
-
request: Request,
|
|
239
|
-
server: Bun.Server<undefined>,
|
|
240
|
-
) => Response | Promise<Response>
|
|
241
|
-
|
|
242
|
-
function guarded(handler: Handler): Handler {
|
|
243
|
-
return (request, server) =>
|
|
244
|
-
isLoopbackHost(request.headers.get('host'), server.port ?? 0)
|
|
245
|
-
? handler(request, server)
|
|
246
|
-
: new Response('Forbidden\n', {
|
|
247
|
-
status: 403,
|
|
248
|
-
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
249
|
-
})
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function json(body: unknown, status = 200): Response {
|
|
253
|
-
return new Response(JSON.stringify(body), {
|
|
254
|
-
status,
|
|
255
|
-
headers: {
|
|
256
|
-
'content-type': 'application/json; charset=utf-8',
|
|
257
|
-
'cache-control': 'no-store',
|
|
258
|
-
},
|
|
259
|
-
})
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const REFUSAL_STATUS: Record<EditRefused['reason'], number> = {
|
|
263
|
-
'invalid-edit': 400,
|
|
264
|
-
'not-text-only': 409,
|
|
265
|
-
'invalid-name': 400,
|
|
266
|
-
'invalid-size': 400,
|
|
267
|
-
'invalid-position': 400,
|
|
268
|
-
'no-page': 404,
|
|
269
|
-
'no-frame': 404,
|
|
270
|
-
exists: 409,
|
|
271
|
-
'malformed-layout': 409,
|
|
272
|
-
busy: 409,
|
|
273
|
-
'invalid-address': 400,
|
|
274
|
-
'address-mismatch': 409,
|
|
275
|
-
'stale-address': 409,
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function refusal(refused: ContentRefused | EditRefused): Response {
|
|
279
|
-
return json(refused, REFUSAL_STATUS[refused.reason])
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function badBody(detail: string): Response {
|
|
283
|
-
return json({ ok: false, reason: 'invalid-body', detail }, 400)
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* The Host guard cannot tell the shell from a page on another origin that
|
|
288
|
-
* reaches this socket, since a browser sends the loopback Host either way. A
|
|
289
|
-
* write also carries an Origin when a page sent it, so one naming any other
|
|
290
|
-
* origin is refused, and a JSON content type forces that page through a
|
|
291
|
-
* preflight this server never answers.
|
|
292
|
-
*/
|
|
293
|
-
function mutation(handler: (body: unknown) => Response): Handler {
|
|
294
|
-
return async (request, server) => {
|
|
295
|
-
const origin = request.headers.get('origin')
|
|
296
|
-
if (origin !== null && !isLoopbackOrigin(origin, server.port ?? 0)) {
|
|
297
|
-
return new Response('Forbidden\n', { status: 403 })
|
|
298
|
-
}
|
|
299
|
-
const type = request.headers.get('content-type') ?? ''
|
|
300
|
-
if (!type.startsWith('application/json')) {
|
|
301
|
-
return json(
|
|
302
|
-
{ ok: false, reason: 'invalid-body', detail: 'send application/json' },
|
|
303
|
-
415,
|
|
304
|
-
)
|
|
305
|
-
}
|
|
306
|
-
let body: unknown
|
|
307
|
-
try {
|
|
308
|
-
body = await request.json()
|
|
309
|
-
} catch {
|
|
310
|
-
return badBody('the body is not JSON')
|
|
311
|
-
}
|
|
312
|
-
return handler(body)
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function isLoopbackOrigin(origin: string, port: number): boolean {
|
|
317
|
-
return (
|
|
318
|
-
origin === `http://${SERVE_HOST}:${port}` ||
|
|
319
|
-
origin === `http://localhost:${port}`
|
|
320
|
-
)
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* Writes one entry back through the writer that recorded it. A busy lock
|
|
325
|
-
* keeps the entry for another try, and any other refusal means the target
|
|
326
|
-
* moved on or went, so the entry drops.
|
|
327
|
-
*/
|
|
328
|
-
function applyEntry(
|
|
329
|
-
root: string,
|
|
330
|
-
entry: HistoryEntry,
|
|
331
|
-
direction: Direction,
|
|
332
|
-
): ApplyResult {
|
|
333
|
-
const isUndo = direction === 'undo'
|
|
334
|
-
const outcome =
|
|
335
|
-
entry.kind === 'element'
|
|
336
|
-
? restoreElement(
|
|
337
|
-
root,
|
|
338
|
-
entry.page,
|
|
339
|
-
entry.frame,
|
|
340
|
-
entry,
|
|
341
|
-
isUndo ? entry.after : entry.before,
|
|
342
|
-
isUndo ? entry.before : entry.after,
|
|
343
|
-
)
|
|
344
|
-
: restoreBox(
|
|
345
|
-
root,
|
|
346
|
-
entry.page,
|
|
347
|
-
entry.frame,
|
|
348
|
-
isUndo ? entry.after : entry.before,
|
|
349
|
-
isUndo ? entry.before : entry.after,
|
|
350
|
-
)
|
|
351
|
-
if (outcome.ok) return 'applied'
|
|
352
|
-
return outcome.reason === 'busy' ? 'busy' : 'dropped'
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
function stepHistory(
|
|
356
|
-
root: string,
|
|
357
|
-
history: History,
|
|
358
|
-
direction: Direction,
|
|
359
|
-
): Response {
|
|
360
|
-
const apply = (entry: HistoryEntry) => applyEntry(root, entry, direction)
|
|
361
|
-
const outcome =
|
|
362
|
-
direction === 'undo' ? history.undo(apply) : history.redo(apply)
|
|
363
|
-
if (outcome === 'busy') {
|
|
364
|
-
return json(
|
|
365
|
-
{
|
|
366
|
-
ok: false,
|
|
367
|
-
reason: 'busy',
|
|
368
|
-
detail: 'the file is being written, try again',
|
|
369
|
-
},
|
|
370
|
-
409,
|
|
371
|
-
)
|
|
372
|
-
}
|
|
373
|
-
return json({ ok: true, outcome, history: history.flags() })
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
377
|
-
return typeof value === 'object' && value !== null
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function isAddressShape(value: unknown): value is ElementAddress {
|
|
381
|
-
return (
|
|
382
|
-
isRecord(value) &&
|
|
383
|
-
typeof value.index === 'number' &&
|
|
384
|
-
typeof value.tag === 'string' &&
|
|
385
|
-
typeof value.count === 'number' &&
|
|
386
|
-
(value.hash === undefined || typeof value.hash === 'string')
|
|
387
|
-
)
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
async function serveFrame(
|
|
391
|
-
root: string,
|
|
392
|
-
content: string,
|
|
393
|
-
request: Request,
|
|
394
|
-
tokens: TokenOptions,
|
|
395
|
-
): Promise<Response> {
|
|
396
|
-
const url = new URL(request.url)
|
|
397
|
-
url.pathname = `/${url.pathname.slice(FRAMES_PREFIX.length)}`
|
|
398
|
-
const file = await respond(content, new Request(url))
|
|
399
|
-
|
|
400
|
-
const type = file.headers.get('content-type') ?? ''
|
|
401
|
-
if (file.status !== 200 || !type.startsWith('text/html')) return file
|
|
402
|
-
|
|
403
|
-
const { css } = resolveFrameTokens(root, tokens)
|
|
404
|
-
/*
|
|
405
|
-
* Hashed over the bytes on disk, the same input `writeSelection` reads,
|
|
406
|
-
* since decoding to text drops a byte order mark the file still holds.
|
|
407
|
-
*/
|
|
408
|
-
const bytes = new Uint8Array(await file.arrayBuffer())
|
|
409
|
-
const html = new TextDecoder().decode(bytes)
|
|
410
|
-
return new Response(injectTokens(html, css, contentHash(bytes)), {
|
|
411
|
-
headers: { 'content-type': type, 'cache-control': 'no-store' },
|
|
412
|
-
})
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* Fans each change under the canvas folder out to every open shell. The first
|
|
417
|
-
* segment names the page and the rest names the file within it, so a
|
|
418
|
-
* top-level entry goes out with an empty file. Only an empty path is skipped.
|
|
419
|
-
*/
|
|
420
|
-
class ChangeStream {
|
|
421
|
-
private readonly clients = new Set<ReadableStreamDefaultController<string>>()
|
|
422
|
-
private readonly pending = new Map<string, ReturnType<typeof setTimeout>>()
|
|
423
|
-
private readonly watcher: FSWatcher
|
|
424
|
-
|
|
425
|
-
constructor(content: string) {
|
|
426
|
-
this.watcher = watch(content, { recursive: true }, (_event, filename) => {
|
|
427
|
-
if (filename) this.queue(filename.toString())
|
|
428
|
-
})
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/**
|
|
432
|
-
* A one-segment path is kept because the recursive watch reports a page
|
|
433
|
-
* folder appearing, going, or being renamed under its bare name with nothing
|
|
434
|
-
* beneath it. A stray file beside the pages goes out the same way, which
|
|
435
|
-
* costs the shell one page-list reread.
|
|
436
|
-
*/
|
|
437
|
-
private queue(path: string): void {
|
|
438
|
-
const [page, ...rest] = path.split(/[\\/]/)
|
|
439
|
-
if (page === undefined || page === '') return
|
|
440
|
-
/* A write's staging or lock file, gone a moment later. */
|
|
441
|
-
if (path.endsWith(TEMP_SUFFIX) || path.endsWith(LOCK_SUFFIX)) return
|
|
442
|
-
clearTimeout(this.pending.get(path))
|
|
443
|
-
this.pending.set(
|
|
444
|
-
path,
|
|
445
|
-
setTimeout(() => {
|
|
446
|
-
this.pending.delete(path)
|
|
447
|
-
this.send({ page, file: rest.join('/') })
|
|
448
|
-
}, SETTLE_MS),
|
|
449
|
-
)
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
private send(change: ChangeEvent): void {
|
|
453
|
-
const frame = `data: ${JSON.stringify(change)}\n\n`
|
|
454
|
-
for (const client of this.clients) client.enqueue(frame)
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
open(): Response {
|
|
458
|
-
let self: ReadableStreamDefaultController<string> | undefined
|
|
459
|
-
const stream = new ReadableStream<string>({
|
|
460
|
-
start: (controller) => {
|
|
461
|
-
self = controller
|
|
462
|
-
this.clients.add(controller)
|
|
463
|
-
controller.enqueue(': open\n\n')
|
|
464
|
-
},
|
|
465
|
-
cancel: () => {
|
|
466
|
-
if (self) this.clients.delete(self)
|
|
467
|
-
},
|
|
468
|
-
})
|
|
469
|
-
return new Response(stream, {
|
|
470
|
-
headers: {
|
|
471
|
-
'content-type': 'text/event-stream',
|
|
472
|
-
'cache-control': 'no-store',
|
|
473
|
-
},
|
|
474
|
-
})
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
close(): void {
|
|
478
|
-
this.watcher.close()
|
|
479
|
-
for (const timer of this.pending.values()) clearTimeout(timer)
|
|
480
|
-
for (const client of this.clients) {
|
|
481
|
-
try {
|
|
482
|
-
client.close()
|
|
483
|
-
} catch {
|
|
484
|
-
/* Already closed by the browser going away. */
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
this.clients.clear()
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
export function startCanvas(
|
|
492
|
-
root: string,
|
|
493
|
-
options: CanvasOptions,
|
|
494
|
-
): CanvasOutcome {
|
|
495
|
-
const content = canvasDir(root)
|
|
496
|
-
/* Created up front, since the change stream has to watch something. */
|
|
497
|
-
mkdirSync(content, { recursive: true })
|
|
498
|
-
|
|
499
|
-
const tokens = options.tokens ?? {}
|
|
500
|
-
/*
|
|
501
|
-
* Fonts embedded, so the chrome renders in its own face on a machine that
|
|
502
|
-
* never installed it rather than in whatever the fallback stack finds.
|
|
503
|
-
*/
|
|
504
|
-
const chromeCss = buildDesignCss(undefined, {
|
|
505
|
-
components: false,
|
|
506
|
-
embedFonts: true,
|
|
507
|
-
})
|
|
508
|
-
const changes = new ChangeStream(content)
|
|
509
|
-
/*
|
|
510
|
-
* Held here and written only by the shell's write routes, so an edit a CLI
|
|
511
|
-
* verb makes in its own process never enters it.
|
|
512
|
-
*/
|
|
513
|
-
const history = new History()
|
|
514
|
-
const recordBox =
|
|
515
|
-
(page: string, frame: string): BoxObserver =>
|
|
516
|
-
(before, after) => {
|
|
517
|
-
history.record({ kind: 'box', page, frame, before, after })
|
|
518
|
-
}
|
|
519
|
-
const first = options.port ?? CANVAS_PORT
|
|
520
|
-
|
|
521
|
-
let bound: ReturnType<typeof bindFirstFree>
|
|
522
|
-
try {
|
|
523
|
-
bound = bindFirstFree(first, (port) =>
|
|
524
|
-
Bun.serve({
|
|
525
|
-
hostname: SERVE_HOST,
|
|
526
|
-
port,
|
|
527
|
-
development: false,
|
|
528
|
-
/*
|
|
529
|
-
* Production mode turns port reuse on, so a second canvas would bind
|
|
530
|
-
* beside one already listening rather than walk past it.
|
|
531
|
-
*/
|
|
532
|
-
reusePort: false,
|
|
533
|
-
/* The change stream stays open for as long as the shell does. */
|
|
534
|
-
idleTimeout: 0,
|
|
535
|
-
routes: {
|
|
536
|
-
/*
|
|
537
|
-
* The shell and the chunks it bundles carry toolkit code and no
|
|
538
|
-
* project content, so they are the one route left unguarded.
|
|
539
|
-
*/
|
|
540
|
-
'/': options.shell,
|
|
541
|
-
'/api/pages': guarded(() => {
|
|
542
|
-
const resolved = resolveFrameTokens(root, tokens)
|
|
543
|
-
const { css, ...source } = resolved
|
|
544
|
-
const selected = readSelection(root)
|
|
545
|
-
return json({
|
|
546
|
-
pages: listPages(root),
|
|
547
|
-
tokens: { ...source, groups: tokenGroups(css) },
|
|
548
|
-
selection: selected && {
|
|
549
|
-
page: selected.page,
|
|
550
|
-
frame: selected.frame,
|
|
551
|
-
...(selected.element && {
|
|
552
|
-
element: {
|
|
553
|
-
index: selected.element.index,
|
|
554
|
-
tag: selected.element.tag,
|
|
555
|
-
stale: selected.element.stale,
|
|
556
|
-
},
|
|
557
|
-
}),
|
|
558
|
-
},
|
|
559
|
-
editing: readEditing(root, new Date()).map(
|
|
560
|
-
({ page, frame, by, until }) => ({ page, frame, by, until }),
|
|
561
|
-
),
|
|
562
|
-
history: history.flags(),
|
|
563
|
-
})
|
|
564
|
-
}),
|
|
565
|
-
'/api/history/undo': {
|
|
566
|
-
POST: guarded(mutation(() => stepHistory(root, history, 'undo'))),
|
|
567
|
-
},
|
|
568
|
-
'/api/history/redo': {
|
|
569
|
-
POST: guarded(mutation(() => stepHistory(root, history, 'redo'))),
|
|
570
|
-
},
|
|
571
|
-
'/api/frames/move': {
|
|
572
|
-
POST: guarded(
|
|
573
|
-
mutation((body) => {
|
|
574
|
-
if (
|
|
575
|
-
!isRecord(body) ||
|
|
576
|
-
typeof body.page !== 'string' ||
|
|
577
|
-
typeof body.frame !== 'string' ||
|
|
578
|
-
typeof body.x !== 'number' ||
|
|
579
|
-
typeof body.y !== 'number'
|
|
580
|
-
) {
|
|
581
|
-
return badBody('send page, frame, x, and y')
|
|
582
|
-
}
|
|
583
|
-
const { page, frame } = body
|
|
584
|
-
const outcome = moveFrame(
|
|
585
|
-
root,
|
|
586
|
-
page,
|
|
587
|
-
frame,
|
|
588
|
-
{ x: body.x, y: body.y },
|
|
589
|
-
recordBox(page, frame),
|
|
590
|
-
)
|
|
591
|
-
return outcome.ok ? json(outcome) : refusal(outcome)
|
|
592
|
-
}),
|
|
593
|
-
),
|
|
594
|
-
},
|
|
595
|
-
'/api/frames/resize': {
|
|
596
|
-
POST: guarded(
|
|
597
|
-
mutation((body) => {
|
|
598
|
-
if (
|
|
599
|
-
!isRecord(body) ||
|
|
600
|
-
typeof body.page !== 'string' ||
|
|
601
|
-
typeof body.frame !== 'string' ||
|
|
602
|
-
typeof body.x !== 'number' ||
|
|
603
|
-
typeof body.y !== 'number' ||
|
|
604
|
-
typeof body.width !== 'number' ||
|
|
605
|
-
typeof body.height !== 'number'
|
|
606
|
-
) {
|
|
607
|
-
return badBody('send page, frame, x, y, width, and height')
|
|
608
|
-
}
|
|
609
|
-
const { page, frame } = body
|
|
610
|
-
const outcome = resizeFrame(
|
|
611
|
-
root,
|
|
612
|
-
page,
|
|
613
|
-
frame,
|
|
614
|
-
{
|
|
615
|
-
x: body.x,
|
|
616
|
-
y: body.y,
|
|
617
|
-
width: body.width,
|
|
618
|
-
height: body.height,
|
|
619
|
-
},
|
|
620
|
-
recordBox(page, frame),
|
|
621
|
-
)
|
|
622
|
-
return outcome.ok ? json(outcome) : refusal(outcome)
|
|
623
|
-
}),
|
|
624
|
-
),
|
|
625
|
-
},
|
|
626
|
-
'/api/frames/edit': {
|
|
627
|
-
POST: guarded(
|
|
628
|
-
mutation((body) => {
|
|
629
|
-
if (
|
|
630
|
-
!isRecord(body) ||
|
|
631
|
-
typeof body.page !== 'string' ||
|
|
632
|
-
typeof body.frame !== 'string' ||
|
|
633
|
-
typeof body.property !== 'string' ||
|
|
634
|
-
typeof body.value !== 'string'
|
|
635
|
-
) {
|
|
636
|
-
return badBody(
|
|
637
|
-
'send page, frame, element, property, and value',
|
|
638
|
-
)
|
|
639
|
-
}
|
|
640
|
-
const { element } = body
|
|
641
|
-
/*
|
|
642
|
-
* The hash is what keeps an edit off an element the file has
|
|
643
|
-
* since moved, so the shell's edit never goes without it.
|
|
644
|
-
*/
|
|
645
|
-
if (!isAddressShape(element) || element.hash === undefined) {
|
|
646
|
-
return badBody(
|
|
647
|
-
'send an element as index, tag, count, and the hash it was served with',
|
|
648
|
-
)
|
|
649
|
-
}
|
|
650
|
-
const { page, frame, step } = body
|
|
651
|
-
if (step !== undefined && typeof step !== 'string') {
|
|
652
|
-
return badBody('send step as a string, or leave it out')
|
|
653
|
-
}
|
|
654
|
-
const outcome = editFrame(
|
|
655
|
-
root,
|
|
656
|
-
page,
|
|
657
|
-
frame,
|
|
658
|
-
element,
|
|
659
|
-
{
|
|
660
|
-
property: body.property,
|
|
661
|
-
value: body.value,
|
|
662
|
-
},
|
|
663
|
-
({ before, after }) => {
|
|
664
|
-
history.record({
|
|
665
|
-
kind: 'element',
|
|
666
|
-
page,
|
|
667
|
-
frame,
|
|
668
|
-
index: element.index,
|
|
669
|
-
tag: element.tag,
|
|
670
|
-
count: element.count,
|
|
671
|
-
before,
|
|
672
|
-
after,
|
|
673
|
-
...(step !== undefined && { step }),
|
|
674
|
-
})
|
|
675
|
-
},
|
|
676
|
-
)
|
|
677
|
-
return outcome.ok ? json(outcome) : refusal(outcome)
|
|
678
|
-
}),
|
|
679
|
-
),
|
|
680
|
-
},
|
|
681
|
-
'/api/selection': {
|
|
682
|
-
POST: guarded(
|
|
683
|
-
mutation((body) => {
|
|
684
|
-
if (!isRecord(body)) return badBody('send a frame or {}')
|
|
685
|
-
const { page, frame } = body
|
|
686
|
-
const target =
|
|
687
|
-
typeof page === 'string' && typeof frame === 'string'
|
|
688
|
-
? { page, frame }
|
|
689
|
-
: undefined
|
|
690
|
-
if (target === undefined && (page != null || frame != null)) {
|
|
691
|
-
return badBody('send both page and frame, or neither')
|
|
692
|
-
}
|
|
693
|
-
const { element } = body
|
|
694
|
-
if (element != null && target === undefined) {
|
|
695
|
-
return badBody('send an element with its page and frame')
|
|
696
|
-
}
|
|
697
|
-
if (element != null && !isAddressShape(element)) {
|
|
698
|
-
return badBody('send an element as index, tag, and count')
|
|
699
|
-
}
|
|
700
|
-
const outcome = writeSelection(
|
|
701
|
-
root,
|
|
702
|
-
target && element != null ? { ...target, element } : target,
|
|
703
|
-
)
|
|
704
|
-
return outcome.ok ? json(outcome) : refusal(outcome)
|
|
705
|
-
}),
|
|
706
|
-
),
|
|
707
|
-
},
|
|
708
|
-
'/api/chrome.css': guarded(
|
|
709
|
-
() =>
|
|
710
|
-
new Response(chromeCss, {
|
|
711
|
-
headers: {
|
|
712
|
-
'content-type': 'text/css; charset=utf-8',
|
|
713
|
-
'cache-control': 'no-store',
|
|
714
|
-
},
|
|
715
|
-
}),
|
|
716
|
-
),
|
|
717
|
-
'/api/icon.svg': guarded(
|
|
718
|
-
() =>
|
|
719
|
-
new Response(brandFavicon(), {
|
|
720
|
-
headers: { 'content-type': 'image/svg+xml' },
|
|
721
|
-
}),
|
|
722
|
-
),
|
|
723
|
-
'/api/events': guarded(() => changes.open()),
|
|
724
|
-
},
|
|
725
|
-
fetch: guarded((request) => {
|
|
726
|
-
const { pathname } = new URL(request.url)
|
|
727
|
-
if (pathname.startsWith(FRAMES_PREFIX)) {
|
|
728
|
-
return serveFrame(root, content, request, tokens)
|
|
729
|
-
}
|
|
730
|
-
return new Response('Not found\n', { status: 404 })
|
|
731
|
-
}),
|
|
732
|
-
}),
|
|
733
|
-
)
|
|
734
|
-
} catch (error) {
|
|
735
|
-
changes.close()
|
|
736
|
-
const code = (error as NodeJS.ErrnoException).code ?? 'unknown'
|
|
737
|
-
return {
|
|
738
|
-
ok: false,
|
|
739
|
-
reason: 'bind-failed',
|
|
740
|
-
detail: `could not bind ${SERVE_HOST}:${first} (${code})`,
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
if (!bound) {
|
|
745
|
-
changes.close()
|
|
746
|
-
return {
|
|
747
|
-
ok: false,
|
|
748
|
-
reason: 'no-port',
|
|
749
|
-
detail: `no free port between ${first} and ${first + PORT_ATTEMPTS - 1}`,
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
const { server, port } = bound
|
|
754
|
-
return {
|
|
755
|
-
ok: true,
|
|
756
|
-
root,
|
|
757
|
-
content,
|
|
758
|
-
host: SERVE_HOST,
|
|
759
|
-
port,
|
|
760
|
-
url: `http://${SERVE_HOST}:${port}/`,
|
|
761
|
-
stop: async () => {
|
|
762
|
-
changes.close()
|
|
763
|
-
await server.stop(true)
|
|
764
|
-
},
|
|
765
|
-
}
|
|
766
|
-
}
|