@erclx/canon 5.14.0 → 5.15.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 +3 -2
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/draft-screencast/REQUIREMENT.md +2 -1
- package/claude/skills/draft-screencast/SKILL.md +14 -9
- package/claude/skills/read-frames/SKILL.md +3 -2
- package/claude/skills/record-screencast/REQUIREMENT.md +3 -0
- package/claude/skills/record-screencast/SKILL.md +28 -5
- package/claude/skills/review-pr/SKILL.md +1 -1
- package/claude/skills/review-pr/references/close-out.md +1 -1
- package/claude/skills/review-pr/references/post.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +10 -10
- package/claude/skills/role-orchestrator/scripts/poll.ts +628 -0
- package/claude/skills/role-orchestrator/scripts/repo.ts +69 -0
- package/claude/skills/role-orchestrator/scripts/watch.ts +267 -0
- package/docs/agents/commands.md +3 -0
- package/docs/agents/demo.md +34 -7
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/pr-reads.md +1 -1
- package/docs/agents/sandbox.md +28 -3
- package/docs/agents/state-scoped-risk.md +2 -2
- package/docs/target/migrations.md +1 -1
- package/docs/workflow/ai-workflow.md +2 -3
- package/docs/workflow/skill-map.md +1 -1
- package/governance/renames.toml +7 -5
- package/governance/rules/canon/630-requirements.md +1 -1
- package/package.json +8 -6
- package/scripts/core/check-color-source.sh +11 -1
- package/scripts/core/install-check.sh +13 -4
- package/scripts/lib/sandbox-dispatch.sh +1 -1
- package/scripts/lib/sandbox-fixtures.sh +2 -2
- package/scripts/lib/sandbox-path.sh +1 -1
- package/scripts/manage-sandbox.sh +2 -2
- package/src/canvas/client/app.tsx +85 -0
- package/src/canvas/client/main.tsx +62 -0
- package/src/canvas/client/pages-panel.tsx +123 -0
- package/src/canvas/client/shell.css +319 -0
- package/src/canvas/client/state.ts +186 -0
- package/src/canvas/client/surface.tsx +213 -0
- package/src/canvas/content.ts +326 -0
- package/src/canvas/index.html +14 -0
- package/src/canvas/server.ts +302 -0
- package/src/canvas/tokens.ts +71 -0
- package/src/claude/seeds.ts +0 -1
- package/src/cli.ts +2 -0
- package/src/commands/canvas.ts +325 -0
- package/src/commands/claude.ts +9 -14
- package/src/commands/demo.ts +6 -6
- package/src/commands/sandbox.ts +110 -2
- package/src/context/citations.ts +1 -1
- package/src/context/classify/prompts.ts +1 -1
- package/src/context/folders.ts +0 -1
- package/src/demo/compile.ts +8 -4
- package/src/demo/container.ts +1 -1
- package/src/gov/renames.ts +3 -2
- package/src/help.ts +1 -0
- package/src/record-root.ts +1 -0
- package/src/sandbox/census.ts +1 -1
- package/src/sandbox/coverage.ts +1 -1
- package/src/sandbox/equivalence.ts +847 -0
- package/src/sandbox/expect.ts +0 -1
- package/src/serve/static.ts +25 -11
- package/src/shipped/references.ts +8 -10
- package/src/surface-root.ts +0 -1
- package/src/targets/pulls.ts +2 -2
- package/standards/architecture.md +1 -1
- package/standards/context.md +1 -1
- package/standards/design.md +1 -1
- package/standards/index.md +0 -1
- package/standards/requirements.md +4 -6
- package/standards/wireframes.md +2 -2
- package/tooling/claude/reference.md +0 -1
- package/tooling/claude/seeds/canon/REQUIREMENTS.md +0 -2
- package/tsconfig.json +1 -1
- package/claude/skills/role-orchestrator/scripts/poll.sh +0 -504
- package/claude/skills/role-orchestrator/scripts/watch.sh +0 -181
- package/governance/rules/canon/645-decisions.md +0 -12
- package/standards/decisions.md +0 -105
- package/tooling/claude/seeds/canon/decisions/index.md +0 -8
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
statSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { recordDir } from '@/record-root'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The content format. A page is a folder under the record root's `canvas/`, a
|
|
15
|
+
* frame is one `.html` file in it, and `layout.json` beside them holds each
|
|
16
|
+
* frame's box. The HTML stays a plain file so an agent edits a frame the way it
|
|
17
|
+
* edits any other file, and the box lives apart from it so moving a frame never
|
|
18
|
+
* rewrites its markup.
|
|
19
|
+
*/
|
|
20
|
+
export const CANVAS_FOLDER = 'canvas'
|
|
21
|
+
|
|
22
|
+
export const LAYOUT_FILE = 'layout.json'
|
|
23
|
+
|
|
24
|
+
const FRAME_EXTENSION = '.html'
|
|
25
|
+
|
|
26
|
+
/** The size a frame takes when nothing states one. */
|
|
27
|
+
export const DEFAULT_FRAME = { width: 1440, height: 900 } as const
|
|
28
|
+
|
|
29
|
+
/** Horizontal space between frames placed in a row. */
|
|
30
|
+
export const FRAME_GAP = 80
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One path segment with no dot leading it, so a name can neither climb out of
|
|
34
|
+
* the canvas folder nor hide as a dotfile the listing skips.
|
|
35
|
+
*/
|
|
36
|
+
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
37
|
+
|
|
38
|
+
export interface Box {
|
|
39
|
+
readonly x: number
|
|
40
|
+
readonly y: number
|
|
41
|
+
readonly width: number
|
|
42
|
+
readonly height: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Frame extends Box {
|
|
46
|
+
readonly name: string
|
|
47
|
+
/** Relative to the page folder. */
|
|
48
|
+
readonly file: string
|
|
49
|
+
/** Whether the layout stated this box, or the reader placed it in a row. */
|
|
50
|
+
readonly placed: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Page {
|
|
54
|
+
readonly name: string
|
|
55
|
+
readonly frames: readonly Frame[]
|
|
56
|
+
/** Set when `layout.json` exists and does not parse. Frames still list. */
|
|
57
|
+
readonly layoutIssue?: 'malformed'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type ContentRefusal =
|
|
61
|
+
| 'invalid-name'
|
|
62
|
+
| 'invalid-size'
|
|
63
|
+
| 'exists'
|
|
64
|
+
| 'no-page'
|
|
65
|
+
| 'malformed-layout'
|
|
66
|
+
|
|
67
|
+
export interface ContentRefused {
|
|
68
|
+
readonly ok: false
|
|
69
|
+
readonly reason: ContentRefusal
|
|
70
|
+
readonly detail: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type PageOutcome =
|
|
74
|
+
| { readonly ok: true; readonly page: string }
|
|
75
|
+
| ContentRefused
|
|
76
|
+
|
|
77
|
+
export type FrameOutcome =
|
|
78
|
+
| {
|
|
79
|
+
readonly ok: true
|
|
80
|
+
readonly page: string
|
|
81
|
+
readonly frame: string
|
|
82
|
+
readonly file: string
|
|
83
|
+
readonly box: Box
|
|
84
|
+
}
|
|
85
|
+
| ContentRefused
|
|
86
|
+
|
|
87
|
+
export function canvasDir(root: string): string {
|
|
88
|
+
return recordDir(root, CANVAS_FOLDER)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function pagePath(root: string, page: string): string {
|
|
92
|
+
return join(canvasDir(root), page)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function refuse(reason: ContentRefusal, detail: string): ContentRefused {
|
|
96
|
+
return { ok: false, reason, detail }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isValidName(name: string): boolean {
|
|
100
|
+
return NAME_PATTERN.test(name) && !name.includes('..')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isDirectory(path: string): boolean {
|
|
104
|
+
return statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isBox(value: unknown): value is Box {
|
|
108
|
+
if (typeof value !== 'object' || value === null) return false
|
|
109
|
+
const box = value as Record<string, unknown>
|
|
110
|
+
return ['x', 'y', 'width', 'height'].every(
|
|
111
|
+
(key) => typeof box[key] === 'number' && Number.isFinite(box[key]),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface LayoutRead {
|
|
116
|
+
readonly boxes: ReadonlyMap<string, Box>
|
|
117
|
+
readonly malformed: boolean
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Reads what a layout states and nothing else. An entry with a box that is not
|
|
122
|
+
* four finite numbers is skipped rather than refused, since one bad entry
|
|
123
|
+
* should cost that frame its position and not the whole page its frames.
|
|
124
|
+
*/
|
|
125
|
+
function readLayout(dir: string): LayoutRead {
|
|
126
|
+
const path = join(dir, LAYOUT_FILE)
|
|
127
|
+
if (!existsSync(path)) return { boxes: new Map(), malformed: false }
|
|
128
|
+
|
|
129
|
+
let parsed: unknown
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
132
|
+
} catch {
|
|
133
|
+
return { boxes: new Map(), malformed: true }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const frames =
|
|
137
|
+
typeof parsed === 'object' && parsed !== null
|
|
138
|
+
? (parsed as { frames?: unknown }).frames
|
|
139
|
+
: undefined
|
|
140
|
+
if (typeof frames !== 'object' || frames === null) {
|
|
141
|
+
return { boxes: new Map(), malformed: true }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const boxes = new Map<string, Box>()
|
|
145
|
+
for (const [name, box] of Object.entries(frames)) {
|
|
146
|
+
if (isBox(box)) {
|
|
147
|
+
boxes.set(name, {
|
|
148
|
+
x: box.x,
|
|
149
|
+
y: box.y,
|
|
150
|
+
width: box.width,
|
|
151
|
+
height: box.height,
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { boxes, malformed: false }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function writeLayout(dir: string, boxes: ReadonlyMap<string, Box>): void {
|
|
159
|
+
const frames = Object.fromEntries(boxes)
|
|
160
|
+
writeFileSync(
|
|
161
|
+
join(dir, LAYOUT_FILE),
|
|
162
|
+
`${JSON.stringify({ frames }, null, 2)}\n`,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function frameNames(dir: string): string[] {
|
|
167
|
+
return readdirSync(dir)
|
|
168
|
+
.filter((entry) => entry.endsWith(FRAME_EXTENSION))
|
|
169
|
+
.map((entry) => entry.slice(0, -FRAME_EXTENSION.length))
|
|
170
|
+
.filter(isValidName)
|
|
171
|
+
.sort((a, b) => a.localeCompare(b))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The x a frame starts at when it joins the row after every box given. */
|
|
175
|
+
function nextX(boxes: Iterable<Box>): number {
|
|
176
|
+
let right: number | undefined
|
|
177
|
+
for (const box of boxes) {
|
|
178
|
+
const edge = box.x + box.width
|
|
179
|
+
right = right === undefined ? edge : Math.max(right, edge)
|
|
180
|
+
}
|
|
181
|
+
return right === undefined ? 0 : right + FRAME_GAP
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function readPage(root: string, page: string): Page | undefined {
|
|
185
|
+
if (!isValidName(page)) return undefined
|
|
186
|
+
const dir = pagePath(root, page)
|
|
187
|
+
if (!isDirectory(dir)) return undefined
|
|
188
|
+
|
|
189
|
+
const { boxes, malformed } = readLayout(dir)
|
|
190
|
+
const names = frameNames(dir)
|
|
191
|
+
|
|
192
|
+
const placed = names.filter((name) => boxes.has(name))
|
|
193
|
+
const frames: Frame[] = []
|
|
194
|
+
for (const name of placed) {
|
|
195
|
+
const box = boxes.get(name)
|
|
196
|
+
if (box) {
|
|
197
|
+
frames.push({
|
|
198
|
+
name,
|
|
199
|
+
file: `${name}${FRAME_EXTENSION}`,
|
|
200
|
+
...box,
|
|
201
|
+
placed: true,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const name of names.filter((candidate) => !boxes.has(candidate))) {
|
|
207
|
+
frames.push({
|
|
208
|
+
name,
|
|
209
|
+
file: `${name}${FRAME_EXTENSION}`,
|
|
210
|
+
x: nextX(frames),
|
|
211
|
+
y: 0,
|
|
212
|
+
...DEFAULT_FRAME,
|
|
213
|
+
placed: false,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
frames.sort((a, b) => a.name.localeCompare(b.name))
|
|
218
|
+
return malformed
|
|
219
|
+
? { name: page, frames, layoutIssue: 'malformed' }
|
|
220
|
+
: { name: page, frames }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function listPages(root: string): Page[] {
|
|
224
|
+
const dir = canvasDir(root)
|
|
225
|
+
if (!isDirectory(dir)) return []
|
|
226
|
+
|
|
227
|
+
return readdirSync(dir)
|
|
228
|
+
.filter(isValidName)
|
|
229
|
+
.filter((name) => isDirectory(join(dir, name)))
|
|
230
|
+
.sort((a, b) => a.localeCompare(b))
|
|
231
|
+
.flatMap((name) => readPage(root, name) ?? [])
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function addPage(root: string, page: string): PageOutcome {
|
|
235
|
+
if (!isValidName(page)) {
|
|
236
|
+
return refuse('invalid-name', `${page} is not a valid page name`)
|
|
237
|
+
}
|
|
238
|
+
const dir = pagePath(root, page)
|
|
239
|
+
if (existsSync(dir)) return refuse('exists', `page ${page} already exists`)
|
|
240
|
+
|
|
241
|
+
mkdirSync(dir, { recursive: true })
|
|
242
|
+
return { ok: true, page }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function renamePage(
|
|
246
|
+
root: string,
|
|
247
|
+
from: string,
|
|
248
|
+
to: string,
|
|
249
|
+
): PageOutcome {
|
|
250
|
+
if (!isValidName(from) || !isValidName(to)) {
|
|
251
|
+
return refuse(
|
|
252
|
+
'invalid-name',
|
|
253
|
+
`${isValidName(from) ? to : from} is not a valid page name`,
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
const source = pagePath(root, from)
|
|
257
|
+
if (!isDirectory(source))
|
|
258
|
+
return refuse('no-page', `page ${from} does not exist`)
|
|
259
|
+
const target = pagePath(root, to)
|
|
260
|
+
if (existsSync(target)) return refuse('exists', `page ${to} already exists`)
|
|
261
|
+
|
|
262
|
+
renameSync(source, target)
|
|
263
|
+
return { ok: true, page: to }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function frameScaffold(name: string): string {
|
|
267
|
+
return `<!doctype html>
|
|
268
|
+
<html lang="en">
|
|
269
|
+
<head>
|
|
270
|
+
<meta charset="utf-8" />
|
|
271
|
+
<title>${name}</title>
|
|
272
|
+
</head>
|
|
273
|
+
<body>
|
|
274
|
+
<main>
|
|
275
|
+
<h1>${name}</h1>
|
|
276
|
+
</main>
|
|
277
|
+
</body>
|
|
278
|
+
</html>
|
|
279
|
+
`
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface FrameSize {
|
|
283
|
+
readonly width: number
|
|
284
|
+
readonly height: number
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function addFrame(
|
|
288
|
+
root: string,
|
|
289
|
+
page: string,
|
|
290
|
+
frame: string,
|
|
291
|
+
size: FrameSize,
|
|
292
|
+
): FrameOutcome {
|
|
293
|
+
if (!isValidName(page)) {
|
|
294
|
+
return refuse('invalid-name', `${page} is not a valid page name`)
|
|
295
|
+
}
|
|
296
|
+
if (!isValidName(frame)) {
|
|
297
|
+
return refuse('invalid-name', `${frame} is not a valid frame name`)
|
|
298
|
+
}
|
|
299
|
+
const isPositive = (value: number) => Number.isFinite(value) && value > 0
|
|
300
|
+
if (!isPositive(size.width) || !isPositive(size.height)) {
|
|
301
|
+
return refuse('invalid-size', 'width and height must be positive numbers')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const dir = pagePath(root, page)
|
|
305
|
+
if (!isDirectory(dir)) return refuse('no-page', `page ${page} does not exist`)
|
|
306
|
+
const file = `${frame}${FRAME_EXTENSION}`
|
|
307
|
+
if (existsSync(join(dir, file))) {
|
|
308
|
+
return refuse('exists', `frame ${frame} already exists on ${page}`)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/*
|
|
312
|
+
* Placed against the boxes a reader would draw, unplaced frames included, so
|
|
313
|
+
* a new frame never lands under one the layout has yet to name.
|
|
314
|
+
*/
|
|
315
|
+
const current = readPage(root, page)?.frames ?? []
|
|
316
|
+
const { boxes, malformed } = readLayout(dir)
|
|
317
|
+
/* Writing over a layout that does not parse would erase every box in it. */
|
|
318
|
+
if (malformed) {
|
|
319
|
+
return refuse('malformed-layout', `${page}/${LAYOUT_FILE} does not parse`)
|
|
320
|
+
}
|
|
321
|
+
const box: Box = { x: nextX(current), y: 0, ...size }
|
|
322
|
+
|
|
323
|
+
writeFileSync(join(dir, file), frameScaffold(frame))
|
|
324
|
+
writeLayout(dir, new Map([...boxes, [frame, box]]))
|
|
325
|
+
return { ok: true, page, frame, file, box }
|
|
326
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Canvas</title>
|
|
7
|
+
<link rel="icon" href="data:," />
|
|
8
|
+
<link rel="stylesheet" href="./client/shell.css" />
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="app"></div>
|
|
12
|
+
<script type="module" src="./client/main.tsx"></script>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { type FSWatcher, mkdirSync, watch } from 'node:fs'
|
|
2
|
+
import { canvasDir, listPages } from '@/canvas/content'
|
|
3
|
+
import { resolveFrameTokens, type TokenOptions } from '@/canvas/tokens'
|
|
4
|
+
import { buildDesignCss } from '@/design/css'
|
|
5
|
+
import {
|
|
6
|
+
bindFirstFree,
|
|
7
|
+
PORT_ATTEMPTS,
|
|
8
|
+
respond,
|
|
9
|
+
SERVE_HOST,
|
|
10
|
+
type ServeRefusal,
|
|
11
|
+
} from '@/serve/static'
|
|
12
|
+
|
|
13
|
+
/** Clear of `canon serve`'s default, so a lesson preview and the canvas both open. */
|
|
14
|
+
export const CANVAS_PORT = 8790
|
|
15
|
+
|
|
16
|
+
/** The URL prefix a frame and the files beside it are served under. */
|
|
17
|
+
export const FRAMES_PREFIX = '/frames/'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How long a burst of watch events for one file waits before it is sent. One
|
|
21
|
+
* editor save raises several, and each would otherwise reload the frame again.
|
|
22
|
+
*/
|
|
23
|
+
const SETTLE_MS = 40
|
|
24
|
+
|
|
25
|
+
export interface CanvasStarted {
|
|
26
|
+
readonly ok: true
|
|
27
|
+
readonly root: string
|
|
28
|
+
readonly content: string
|
|
29
|
+
readonly host: string
|
|
30
|
+
readonly port: number
|
|
31
|
+
readonly url: string
|
|
32
|
+
readonly stop: () => Promise<void>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CanvasRefused {
|
|
36
|
+
readonly ok: false
|
|
37
|
+
readonly reason: Extract<ServeRefusal, 'no-port' | 'bind-failed'>
|
|
38
|
+
readonly detail: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type CanvasOutcome = CanvasStarted | CanvasRefused
|
|
42
|
+
|
|
43
|
+
export interface CanvasOptions {
|
|
44
|
+
readonly port?: number
|
|
45
|
+
/**
|
|
46
|
+
* What answers `/`. The command passes the HTML import of `index.html`, which
|
|
47
|
+
* `Bun.serve` bundles on request, and a test passes a plain response, since
|
|
48
|
+
* the test runner cannot load an HTML module.
|
|
49
|
+
*/
|
|
50
|
+
readonly shell: Bun.HTMLBundle | Response
|
|
51
|
+
readonly tokens?: TokenOptions
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ChangeEvent {
|
|
55
|
+
readonly page: string
|
|
56
|
+
/**
|
|
57
|
+
* The path under the page folder, or empty when the change is to the
|
|
58
|
+
* top-level entry itself, being a page folder added, renamed, or removed.
|
|
59
|
+
* An empty file names no frame, so a reader rereads the page list alone.
|
|
60
|
+
*/
|
|
61
|
+
readonly file: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Escapes the one sequence that would end the injected element early. */
|
|
65
|
+
function styleElement(css: string): string {
|
|
66
|
+
return `<style data-canvas-tokens>${css.replaceAll('</style', '<\\/style')}</style>`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Puts the tokens first in the head, so a stylesheet the frame links itself
|
|
71
|
+
* still wins the cascade. A frame with no head gets the element ahead of
|
|
72
|
+
* everything, which a browser hoists into the head it builds.
|
|
73
|
+
*/
|
|
74
|
+
export function injectTokens(html: string, css: string): string {
|
|
75
|
+
if (css === '') return html
|
|
76
|
+
const element = styleElement(css)
|
|
77
|
+
const head = html.match(/<head(?:\s[^>]*)?>/i)
|
|
78
|
+
if (head?.index === undefined) return `${element}${html}`
|
|
79
|
+
const at = head.index + head[0].length
|
|
80
|
+
return `${html.slice(0, at)}${element}${html.slice(at)}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether a request names this server by a loopback name and its own port.
|
|
85
|
+
* The bind keeps other machines out, and this keeps out a page the operator
|
|
86
|
+
* visits whose domain was rebound to 127.0.0.1, which reaches the socket with
|
|
87
|
+
* its own name in the Host header.
|
|
88
|
+
*/
|
|
89
|
+
export function isLoopbackHost(host: string | null, port: number): boolean {
|
|
90
|
+
return host === `${SERVE_HOST}:${port}` || host === `localhost:${port}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type Handler = (
|
|
94
|
+
request: Request,
|
|
95
|
+
server: Bun.Server<undefined>,
|
|
96
|
+
) => Response | Promise<Response>
|
|
97
|
+
|
|
98
|
+
function guarded(handler: Handler): Handler {
|
|
99
|
+
return (request, server) =>
|
|
100
|
+
isLoopbackHost(request.headers.get('host'), server.port ?? 0)
|
|
101
|
+
? handler(request, server)
|
|
102
|
+
: new Response('Forbidden\n', {
|
|
103
|
+
status: 403,
|
|
104
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function json(body: unknown): Response {
|
|
109
|
+
return new Response(JSON.stringify(body), {
|
|
110
|
+
headers: {
|
|
111
|
+
'content-type': 'application/json; charset=utf-8',
|
|
112
|
+
'cache-control': 'no-store',
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function serveFrame(
|
|
118
|
+
root: string,
|
|
119
|
+
content: string,
|
|
120
|
+
request: Request,
|
|
121
|
+
tokens: TokenOptions,
|
|
122
|
+
): Promise<Response> {
|
|
123
|
+
const url = new URL(request.url)
|
|
124
|
+
url.pathname = `/${url.pathname.slice(FRAMES_PREFIX.length)}`
|
|
125
|
+
const file = await respond(content, new Request(url))
|
|
126
|
+
|
|
127
|
+
const type = file.headers.get('content-type') ?? ''
|
|
128
|
+
if (file.status !== 200 || !type.startsWith('text/html')) return file
|
|
129
|
+
|
|
130
|
+
const { css } = resolveFrameTokens(root, tokens)
|
|
131
|
+
return new Response(injectTokens(await file.text(), css), {
|
|
132
|
+
headers: { 'content-type': type, 'cache-control': 'no-store' },
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Fans each change under the canvas folder out to every open shell. The first
|
|
138
|
+
* segment names the page and the rest names the file within it, so a
|
|
139
|
+
* top-level entry goes out with an empty file. Only an empty path is skipped.
|
|
140
|
+
*/
|
|
141
|
+
class ChangeStream {
|
|
142
|
+
private readonly clients = new Set<ReadableStreamDefaultController<string>>()
|
|
143
|
+
private readonly pending = new Map<string, ReturnType<typeof setTimeout>>()
|
|
144
|
+
private readonly watcher: FSWatcher
|
|
145
|
+
|
|
146
|
+
constructor(content: string) {
|
|
147
|
+
this.watcher = watch(content, { recursive: true }, (_event, filename) => {
|
|
148
|
+
if (filename) this.queue(filename.toString())
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A one-segment path is kept because the recursive watch reports a page
|
|
154
|
+
* folder appearing, going, or being renamed under its bare name with nothing
|
|
155
|
+
* beneath it. A stray file beside the pages goes out the same way, which
|
|
156
|
+
* costs the shell one page-list reread.
|
|
157
|
+
*/
|
|
158
|
+
private queue(path: string): void {
|
|
159
|
+
const [page, ...rest] = path.split(/[\\/]/)
|
|
160
|
+
if (page === undefined || page === '') return
|
|
161
|
+
clearTimeout(this.pending.get(path))
|
|
162
|
+
this.pending.set(
|
|
163
|
+
path,
|
|
164
|
+
setTimeout(() => {
|
|
165
|
+
this.pending.delete(path)
|
|
166
|
+
this.send({ page, file: rest.join('/') })
|
|
167
|
+
}, SETTLE_MS),
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private send(change: ChangeEvent): void {
|
|
172
|
+
const frame = `data: ${JSON.stringify(change)}\n\n`
|
|
173
|
+
for (const client of this.clients) client.enqueue(frame)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
open(): Response {
|
|
177
|
+
let self: ReadableStreamDefaultController<string> | undefined
|
|
178
|
+
const stream = new ReadableStream<string>({
|
|
179
|
+
start: (controller) => {
|
|
180
|
+
self = controller
|
|
181
|
+
this.clients.add(controller)
|
|
182
|
+
controller.enqueue(': open\n\n')
|
|
183
|
+
},
|
|
184
|
+
cancel: () => {
|
|
185
|
+
if (self) this.clients.delete(self)
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
return new Response(stream, {
|
|
189
|
+
headers: {
|
|
190
|
+
'content-type': 'text/event-stream',
|
|
191
|
+
'cache-control': 'no-store',
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
close(): void {
|
|
197
|
+
this.watcher.close()
|
|
198
|
+
for (const timer of this.pending.values()) clearTimeout(timer)
|
|
199
|
+
for (const client of this.clients) {
|
|
200
|
+
try {
|
|
201
|
+
client.close()
|
|
202
|
+
} catch {
|
|
203
|
+
/* Already closed by the browser going away. */
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
this.clients.clear()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function startCanvas(
|
|
211
|
+
root: string,
|
|
212
|
+
options: CanvasOptions,
|
|
213
|
+
): CanvasOutcome {
|
|
214
|
+
const content = canvasDir(root)
|
|
215
|
+
/* Created up front, since the change stream has to watch something. */
|
|
216
|
+
mkdirSync(content, { recursive: true })
|
|
217
|
+
|
|
218
|
+
const tokens = options.tokens ?? {}
|
|
219
|
+
/*
|
|
220
|
+
* Fonts embedded, so the chrome renders in its own face on a machine that
|
|
221
|
+
* never installed it rather than in whatever the fallback stack finds.
|
|
222
|
+
*/
|
|
223
|
+
const chromeCss = buildDesignCss(undefined, {
|
|
224
|
+
components: false,
|
|
225
|
+
embedFonts: true,
|
|
226
|
+
})
|
|
227
|
+
const changes = new ChangeStream(content)
|
|
228
|
+
const first = options.port ?? CANVAS_PORT
|
|
229
|
+
|
|
230
|
+
let bound: ReturnType<typeof bindFirstFree>
|
|
231
|
+
try {
|
|
232
|
+
bound = bindFirstFree(first, (port) =>
|
|
233
|
+
Bun.serve({
|
|
234
|
+
hostname: SERVE_HOST,
|
|
235
|
+
port,
|
|
236
|
+
development: false,
|
|
237
|
+
/* The change stream stays open for as long as the shell does. */
|
|
238
|
+
idleTimeout: 0,
|
|
239
|
+
routes: {
|
|
240
|
+
/*
|
|
241
|
+
* The shell and the chunks it bundles carry toolkit code and no
|
|
242
|
+
* project content, so they are the one route left unguarded.
|
|
243
|
+
*/
|
|
244
|
+
'/': options.shell,
|
|
245
|
+
'/api/pages': guarded(() => {
|
|
246
|
+
const resolved = resolveFrameTokens(root, tokens)
|
|
247
|
+
const { css: _css, ...source } = resolved
|
|
248
|
+
return json({ pages: listPages(root), tokens: source })
|
|
249
|
+
}),
|
|
250
|
+
'/api/chrome.css': guarded(
|
|
251
|
+
() =>
|
|
252
|
+
new Response(chromeCss, {
|
|
253
|
+
headers: {
|
|
254
|
+
'content-type': 'text/css; charset=utf-8',
|
|
255
|
+
'cache-control': 'no-store',
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
),
|
|
259
|
+
'/api/events': guarded(() => changes.open()),
|
|
260
|
+
},
|
|
261
|
+
fetch: guarded((request) => {
|
|
262
|
+
const { pathname } = new URL(request.url)
|
|
263
|
+
if (pathname.startsWith(FRAMES_PREFIX)) {
|
|
264
|
+
return serveFrame(root, content, request, tokens)
|
|
265
|
+
}
|
|
266
|
+
return new Response('Not found\n', { status: 404 })
|
|
267
|
+
}),
|
|
268
|
+
}),
|
|
269
|
+
)
|
|
270
|
+
} catch (error) {
|
|
271
|
+
changes.close()
|
|
272
|
+
const code = (error as NodeJS.ErrnoException).code ?? 'unknown'
|
|
273
|
+
return {
|
|
274
|
+
ok: false,
|
|
275
|
+
reason: 'bind-failed',
|
|
276
|
+
detail: `could not bind ${SERVE_HOST}:${first} (${code})`,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!bound) {
|
|
281
|
+
changes.close()
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
reason: 'no-port',
|
|
285
|
+
detail: `no free port between ${first} and ${first + PORT_ATTEMPTS - 1}`,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const { server, port } = bound
|
|
290
|
+
return {
|
|
291
|
+
ok: true,
|
|
292
|
+
root,
|
|
293
|
+
content,
|
|
294
|
+
host: SERVE_HOST,
|
|
295
|
+
port,
|
|
296
|
+
url: `http://${SERVE_HOST}:${port}/`,
|
|
297
|
+
stop: async () => {
|
|
298
|
+
changes.close()
|
|
299
|
+
await server.stop(true)
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR } from '@/design/adapter'
|
|
4
|
+
import { buildDesignCss } from '@/design/css'
|
|
5
|
+
import { isOwnCheckout } from '@/project-root'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The stylesheet injected into every frame, so a frame drawn with
|
|
9
|
+
* `var(--color-*)` shows the project's own values. Parsing a `DESIGN.md` into
|
|
10
|
+
* custom properties is left out, since no such mapping exists, and a frame can
|
|
11
|
+
* always link a stylesheet of its own.
|
|
12
|
+
*/
|
|
13
|
+
export type FrameTokens =
|
|
14
|
+
| { readonly source: 'toolkit'; readonly css: string }
|
|
15
|
+
| {
|
|
16
|
+
readonly source: 'installed'
|
|
17
|
+
readonly css: string
|
|
18
|
+
/** Relative to the root, in the order the stylesheet concatenates them. */
|
|
19
|
+
readonly files: readonly string[]
|
|
20
|
+
}
|
|
21
|
+
| { readonly source: 'none'; readonly css: ''; readonly notice: string }
|
|
22
|
+
|
|
23
|
+
export interface TokenOptions {
|
|
24
|
+
/** Overrides the checkout test, which reads this package's own name. */
|
|
25
|
+
readonly isOwnCheckout?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const BASE_FILE = 'base.css'
|
|
29
|
+
|
|
30
|
+
const NONE_NOTICE = `No token stylesheet, so frames render unstyled. Run canon design install to add ${join(DESIGN_INSTALL_DIR, BASE_FILE)}, put overrides under ${join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)}/, or link a stylesheet from the frame.`
|
|
31
|
+
|
|
32
|
+
/** The installed files in cascade order: the base first, then each override. */
|
|
33
|
+
function installedFiles(root: string): string[] {
|
|
34
|
+
const files: string[] = []
|
|
35
|
+
const base = join(DESIGN_INSTALL_DIR, BASE_FILE)
|
|
36
|
+
if (existsSync(join(root, base))) files.push(base)
|
|
37
|
+
|
|
38
|
+
const projectDir = join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)
|
|
39
|
+
if (existsSync(join(root, projectDir))) {
|
|
40
|
+
const overrides = readdirSync(join(root, projectDir))
|
|
41
|
+
.filter((name) => name.endsWith('.css'))
|
|
42
|
+
.sort((a, b) => a.localeCompare(b))
|
|
43
|
+
.map((name) => join(projectDir, name))
|
|
44
|
+
files.push(...overrides)
|
|
45
|
+
}
|
|
46
|
+
return files
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The toolkit's own module where the root is this checkout, else the installed
|
|
51
|
+
* base plus its project overrides, else nothing with a notice naming where
|
|
52
|
+
* tokens would come from.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveFrameTokens(
|
|
55
|
+
root: string,
|
|
56
|
+
options: TokenOptions = {},
|
|
57
|
+
): FrameTokens {
|
|
58
|
+
if (options.isOwnCheckout ?? isOwnCheckout(root)) {
|
|
59
|
+
return { source: 'toolkit', css: buildDesignCss() }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const files = installedFiles(root)
|
|
63
|
+
if (files.length > 0) {
|
|
64
|
+
const css = files
|
|
65
|
+
.map((file) => `/* ${file} */\n${readFileSync(join(root, file), 'utf8')}`)
|
|
66
|
+
.join('\n')
|
|
67
|
+
return { source: 'installed', css, files }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { source: 'none', css: '', notice: NONE_NOTICE }
|
|
71
|
+
}
|