@erclx/canon 5.14.1 → 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/docs/agents/commands.md +3 -0
- package/docs/agents/demo.md +34 -7
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/sandbox.md +25 -0
- 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 +5 -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 +108 -0
- 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/equivalence.ts +847 -0
- package/src/serve/static.ts +25 -11
- package/src/surface-root.ts +0 -1
- 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/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,213 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import type { JSX, RefObject } from 'preact'
|
|
3
|
+
import { useEffect, useRef } from 'preact/hooks'
|
|
4
|
+
import type { Frame } from '@/canvas/content'
|
|
5
|
+
import {
|
|
6
|
+
currentPage,
|
|
7
|
+
fitView,
|
|
8
|
+
frameKey,
|
|
9
|
+
frameTheme,
|
|
10
|
+
frameVersions,
|
|
11
|
+
panBy,
|
|
12
|
+
type Theme,
|
|
13
|
+
toggleFrameTheme,
|
|
14
|
+
view,
|
|
15
|
+
zoomAt,
|
|
16
|
+
} from '@/canvas/client/state'
|
|
17
|
+
|
|
18
|
+
/** One wheel notch or one button press. */
|
|
19
|
+
const ZOOM_STEP = 1.2
|
|
20
|
+
|
|
21
|
+
export interface SurfaceProps {
|
|
22
|
+
readonly viewportRef: RefObject<HTMLDivElement | null>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function frameSrc(page: string, frame: Frame, version: number): string {
|
|
26
|
+
const path = `/frames/${encodeURIComponent(page)}/${encodeURIComponent(frame.file)}`
|
|
27
|
+
return version === 0 ? path : `${path}?v=${version}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Sets the frame document's theme the way the token stylesheet reads it. The
|
|
32
|
+
* frame shares the shell's origin, so its document is reachable once loaded.
|
|
33
|
+
*/
|
|
34
|
+
function applyFrameTheme(iframe: HTMLIFrameElement | null, value: Theme): void {
|
|
35
|
+
const root = iframe?.contentDocument?.documentElement
|
|
36
|
+
if (!root) return
|
|
37
|
+
root.dataset.theme = value
|
|
38
|
+
root.style.colorScheme = value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function FrameView({
|
|
42
|
+
page,
|
|
43
|
+
frame,
|
|
44
|
+
}: {
|
|
45
|
+
readonly page: string
|
|
46
|
+
readonly frame: Frame
|
|
47
|
+
}): JSX.Element {
|
|
48
|
+
const key = frameKey(page, frame)
|
|
49
|
+
const version = frameVersions.value.get(key) ?? 0
|
|
50
|
+
const value = frameTheme(key)
|
|
51
|
+
const iframe = useRef<HTMLIFrameElement>(null)
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
applyFrameTheme(iframe.current, value)
|
|
55
|
+
}, [value])
|
|
56
|
+
|
|
57
|
+
const next = value === 'dark' ? 'light' : 'dark'
|
|
58
|
+
return (
|
|
59
|
+
<figure
|
|
60
|
+
class="frame"
|
|
61
|
+
data-frame={frame.name}
|
|
62
|
+
style={{
|
|
63
|
+
left: `${frame.x}px`,
|
|
64
|
+
top: `${frame.y}px`,
|
|
65
|
+
width: `${frame.width}px`,
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<figcaption class="frame-label">
|
|
69
|
+
<span class="frame-name" title={frame.name}>
|
|
70
|
+
{frame.name}
|
|
71
|
+
</span>
|
|
72
|
+
<span class="frame-size">
|
|
73
|
+
{frame.width} × {frame.height}
|
|
74
|
+
</span>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
class="frame-theme"
|
|
78
|
+
aria-label={`Show ${frame.name} in ${next} theme`}
|
|
79
|
+
title={`Show in ${next} theme`}
|
|
80
|
+
onClick={() => toggleFrameTheme(key)}
|
|
81
|
+
>
|
|
82
|
+
{value === 'dark' ? 'Dark' : 'Light'}
|
|
83
|
+
</button>
|
|
84
|
+
</figcaption>
|
|
85
|
+
<iframe
|
|
86
|
+
ref={iframe}
|
|
87
|
+
title={frame.name}
|
|
88
|
+
src={frameSrc(page, frame, version)}
|
|
89
|
+
width={frame.width}
|
|
90
|
+
height={frame.height}
|
|
91
|
+
style={{ height: `${frame.height}px` }}
|
|
92
|
+
onLoad={() => applyFrameTheme(iframe.current, frameTheme(key))}
|
|
93
|
+
/>
|
|
94
|
+
</figure>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function viewportSize(viewport: RefObject<HTMLDivElement | null>): {
|
|
99
|
+
width: number
|
|
100
|
+
height: number
|
|
101
|
+
} {
|
|
102
|
+
const rect = viewport.current?.getBoundingClientRect()
|
|
103
|
+
return { width: rect?.width ?? 0, height: rect?.height ?? 0 }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function Toolbar({ viewportRef }: SurfaceProps): JSX.Element {
|
|
107
|
+
const zoomCentered = (factor: number) => {
|
|
108
|
+
const { width, height } = viewportSize(viewportRef)
|
|
109
|
+
zoomAt(factor, width / 2, height / 2)
|
|
110
|
+
}
|
|
111
|
+
return (
|
|
112
|
+
<div class="toolbar" role="toolbar" aria-label="Zoom">
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
class="icon-button"
|
|
116
|
+
aria-label="Zoom out"
|
|
117
|
+
onClick={() => zoomCentered(1 / ZOOM_STEP)}
|
|
118
|
+
>
|
|
119
|
+
−
|
|
120
|
+
</button>
|
|
121
|
+
<output class="zoom" aria-live="polite">
|
|
122
|
+
{Math.round(view.value.zoom * 100)}%
|
|
123
|
+
</output>
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
class="icon-button"
|
|
127
|
+
aria-label="Zoom in"
|
|
128
|
+
onClick={() => zoomCentered(ZOOM_STEP)}
|
|
129
|
+
>
|
|
130
|
+
+
|
|
131
|
+
</button>
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
class="text-button"
|
|
135
|
+
onClick={() => {
|
|
136
|
+
const { width, height } = viewportSize(viewportRef)
|
|
137
|
+
fitView(width, height)
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
Fit
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
147
|
+
const page = currentPage.value
|
|
148
|
+
const drag = useRef<{ x: number; y: number } | undefined>(undefined)
|
|
149
|
+
|
|
150
|
+
/* Fits a page once when it opens, never on a reload of the same page. */
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
const { width, height } = viewportSize(viewportRef)
|
|
153
|
+
fitView(width, height)
|
|
154
|
+
}, [page?.name])
|
|
155
|
+
|
|
156
|
+
const handleWheel = (event: WheelEvent) => {
|
|
157
|
+
event.preventDefault()
|
|
158
|
+
if (event.ctrlKey || event.metaKey) {
|
|
159
|
+
const rect = viewportRef.current?.getBoundingClientRect()
|
|
160
|
+
const factor = event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP
|
|
161
|
+
zoomAt(
|
|
162
|
+
factor,
|
|
163
|
+
event.clientX - (rect?.left ?? 0),
|
|
164
|
+
event.clientY - (rect?.top ?? 0),
|
|
165
|
+
)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
panBy(-event.deltaX, -event.deltaY)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
172
|
+
const target = event.target as HTMLElement | null
|
|
173
|
+
if (target?.closest('.frame, .toolbar')) return
|
|
174
|
+
drag.current = { x: event.clientX, y: event.clientY }
|
|
175
|
+
viewportRef.current?.setPointerCapture?.(event.pointerId)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
179
|
+
const start = drag.current
|
|
180
|
+
if (!start) return
|
|
181
|
+
panBy(event.clientX - start.x, event.clientY - start.y)
|
|
182
|
+
drag.current = { x: event.clientX, y: event.clientY }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const handlePointerUp = () => {
|
|
186
|
+
drag.current = undefined
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const { x, y, zoom } = view.value
|
|
190
|
+
return (
|
|
191
|
+
<main class="surface" aria-label="Canvas">
|
|
192
|
+
<div
|
|
193
|
+
ref={viewportRef}
|
|
194
|
+
class="viewport"
|
|
195
|
+
onWheel={handleWheel}
|
|
196
|
+
onPointerDown={handlePointerDown}
|
|
197
|
+
onPointerMove={handlePointerMove}
|
|
198
|
+
onPointerUp={handlePointerUp}
|
|
199
|
+
onPointerCancel={handlePointerUp}
|
|
200
|
+
>
|
|
201
|
+
<div
|
|
202
|
+
class="plane"
|
|
203
|
+
style={{ transform: `translate(${x}px, ${y}px) scale(${zoom})` }}
|
|
204
|
+
>
|
|
205
|
+
{page?.frames.map((frame) => (
|
|
206
|
+
<FrameView key={frame.name} page={page.name} frame={frame} />
|
|
207
|
+
))}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
<Toolbar viewportRef={viewportRef} />
|
|
211
|
+
</main>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
@@ -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>
|