@fabrika/canvas-kernel 0.1.45 → 0.3.1
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 +33 -67
- package/index.ts +20 -83
- package/package.json +69 -72
- package/src/client/AnnotationProvider.tsx +218 -255
- package/src/client/AnnotationSidebar.tsx +393 -338
- package/src/client/FileIcon.tsx +105 -66
- package/src/client/PlanRenderer.tsx +607 -551
- package/src/client/RenderErrorContext.ts +7 -7
- package/src/client/ResponsePreview.tsx +192 -173
- package/src/client/annotationDom.ts +43 -97
- package/src/client/annotationDraft.ts +106 -96
- package/src/client/appContext.tsx +40 -40
- package/src/client/components/Callout.tsx +20 -20
- package/src/client/components/Checklist.tsx +27 -29
- package/src/client/components/Choice.tsx +178 -159
- package/src/client/components/CodeBlock.tsx +27 -26
- package/src/client/components/Diff.tsx +16 -17
- package/src/client/components/FilePreview.tsx +102 -74
- package/src/client/components/Image.tsx +41 -39
- package/src/client/components/Markdown.tsx +33 -32
- package/src/client/components/Mermaid.tsx +593 -542
- package/src/client/components/Note.tsx +8 -8
- package/src/client/components/Priority.tsx +10 -12
- package/src/client/components/ResponseNote.tsx +16 -15
- package/src/client/components/Section.tsx +28 -26
- package/src/client/components/Table.tsx +33 -30
- package/src/client/components/Task.tsx +58 -60
- package/src/client/components/UserInput.tsx +169 -152
- package/src/client/components/index.ts +16 -16
- package/src/client/components/renderMarkdownChildren.tsx +86 -68
- package/src/client/diffLines.ts +51 -39
- package/src/client/generateMarkdown.ts +197 -191
- package/src/client/hostContext.tsx +39 -34
- package/src/client/index.ts +44 -125
- package/src/client/pageGlobals.ts +25 -0
- package/src/client/persistenceSchemas.ts +41 -0
- package/src/client/preact-all.ts +4 -4
- package/src/client/runtime.ts +58 -111
- package/src/client/styles.css +1 -74
- package/src/engine/compiler.ts +411 -323
- package/src/engine/file-admission.ts +25 -0
- package/src/engine/paths.ts +44 -44
- package/src/engine/session.ts +515 -626
- package/src/engine/sessionSchemas.ts +71 -0
- package/src/engine/watcher.ts +70 -60
- package/src/engine/websocket.ts +134 -132
- package/annotate.ts +0 -94
- package/daemon.ts +0 -46
- package/src/client/AnnotationDraftFooter.tsx +0 -76
- package/src/client/AnnotationEditor.tsx +0 -320
- package/src/client/AnnotationList.tsx +0 -190
- package/src/client/Popover.tsx +0 -361
- package/src/client/SessionContext.tsx +0 -3
- package/src/client/annotationContext.ts +0 -130
- package/src/client/annotationMarkdown.ts +0 -102
- package/src/client/annotationTarget.ts +0 -73
- package/src/client/blockTarget.ts +0 -127
- package/src/client/highlightRange.ts +0 -262
- package/src/client/popoverPosition.ts +0 -46
- package/src/client/regionTarget.ts +0 -180
- package/src/client/testing/dom.ts +0 -74
- package/src/client/textTarget.ts +0 -31
- package/src/client/useRegionAnnotation.tsx +0 -188
- package/src/client/useTextAnnotation.tsx +0 -194
- package/src/client/utils.ts +0 -33
- package/src/engine/channel.ts +0 -278
- package/src/engine/http-utils.ts +0 -14
- package/src/engine/lifecycle.ts +0 -302
- package/src/engine/router.ts +0 -16
- package/src/engine/waiter.ts +0 -188
package/src/engine/compiler.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import
|
|
1
|
+
import { randomUUID } from 'crypto'
|
|
2
|
+
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs'
|
|
3
|
+
import { parse as parseMermaid } from 'mermaid-parser-bundle'
|
|
4
|
+
import { tmpdir } from 'os'
|
|
5
|
+
import { join, resolve } from 'path'
|
|
6
|
+
import { type ComponentChildren, type ComponentType, Fragment, h, isValidElement, type Key } from 'preact'
|
|
7
|
+
import renderToString from 'preact-render-to-string'
|
|
8
|
+
import { assertCanvasFileReadable, type AssertReadable, CanvasFileAdmissionError, readCanvasFile } from './file-admission'
|
|
8
9
|
|
|
9
10
|
export type CompileResult =
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
| { ok: true; js: string }
|
|
12
|
+
| { ok: false; error: string }
|
|
12
13
|
|
|
13
|
-
type RequiredPropKind =
|
|
14
|
+
type RequiredPropKind = 'array' | 'string'
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* One name the compiler makes available to authored JSX without an import.
|
|
@@ -20,60 +21,62 @@ type RequiredPropKind = "array" | "string";
|
|
|
20
21
|
* of rendering blank in the browser.
|
|
21
22
|
*/
|
|
22
23
|
export interface CanvasComponentSpec {
|
|
23
|
-
|
|
24
|
+
requiredProps?: Record<string, RequiredPropKind>
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
export type CanvasComponents = Record<string, CanvasComponentSpec
|
|
27
|
+
export type CanvasComponents = Record<string, CanvasComponentSpec>
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* The components every canvas host provides. Order is the order they appear in
|
|
30
31
|
* the injected destructure, so keep it stable for readable generated code.
|
|
31
32
|
*/
|
|
32
33
|
export const KERNEL_COMPONENTS: CanvasComponents = {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
34
|
+
Section: { requiredProps: { title: 'string' } },
|
|
35
|
+
Item: { requiredProps: { id: 'string', label: 'string' } },
|
|
36
|
+
Task: { requiredProps: { id: 'string', label: 'string' } },
|
|
37
|
+
FilePreview: { requiredProps: { path: 'string' } },
|
|
38
|
+
CodeBlock: {},
|
|
39
|
+
Callout: {},
|
|
40
|
+
Mermaid: {},
|
|
41
|
+
Table: { requiredProps: { headers: 'array', rows: 'array' } },
|
|
42
|
+
Priority: { requiredProps: { level: 'string' } },
|
|
43
|
+
Checklist: { requiredProps: { items: 'array' } },
|
|
44
|
+
Note: {},
|
|
45
|
+
Diff: { requiredProps: { before: 'string', after: 'string' } },
|
|
46
|
+
Choice: { requiredProps: { id: 'string', label: 'string', options: 'array' } },
|
|
47
|
+
MultiChoice: { requiredProps: { id: 'string', label: 'string', options: 'array' } },
|
|
48
|
+
UserInput: { requiredProps: { id: 'string', label: 'string' } },
|
|
49
|
+
RangeInput: { requiredProps: { id: 'string', label: 'string' } },
|
|
50
|
+
ImageView: { requiredProps: { src: 'string' } },
|
|
51
|
+
Markdown: {},
|
|
52
|
+
useFeedback: {},
|
|
53
|
+
useAnnotations: {},
|
|
54
|
+
}
|
|
54
55
|
|
|
55
56
|
export interface CompileOptions {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
57
|
+
/** Admission for static file reads, including resolved imports. Not a JavaScript sandbox. */
|
|
58
|
+
assertReadable?: AssertReadable
|
|
59
|
+
/** Resolves `<FilePreview>` / `<Markdown file>` references at compile time. */
|
|
60
|
+
projectRoot?: string
|
|
61
|
+
/** Scratch dir for the temp `.jsx` handed to Bun.build. Created on demand. */
|
|
62
|
+
tempDir?: string
|
|
63
|
+
/**
|
|
64
|
+
* Host components to add on top of KERNEL_COMPONENTS, keyed by name. Each one
|
|
65
|
+
* must also be exported from the host's `#canvas/components` barrel — that is
|
|
66
|
+
* what the injected import actually reads at runtime. Re-declaring a kernel
|
|
67
|
+
* name overrides its spec.
|
|
68
|
+
*/
|
|
69
|
+
components?: CanvasComponents
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
/** Fallback scratch dir for hosts that compile without wiring CanvasPaths. */
|
|
70
|
-
const FALLBACK_COMPILE_DIR = join(tmpdir(),
|
|
73
|
+
const FALLBACK_COMPILE_DIR = join(tmpdir(), 'canvas-kernel', 'compile')
|
|
71
74
|
|
|
72
75
|
function componentImports(components: CanvasComponents): string {
|
|
73
|
-
|
|
76
|
+
return `import React from 'react';
|
|
74
77
|
import * as C from '#canvas/components';
|
|
75
|
-
const { ${Object.keys(components).join(
|
|
76
|
-
|
|
78
|
+
const { ${Object.keys(components).join(', ')} } = C;
|
|
79
|
+
`
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
// --- Validation via Preact --------------------------------------------------
|
|
@@ -82,130 +85,150 @@ const { ${Object.keys(components).join(", ")} } = C;
|
|
|
82
85
|
// and actually render the component tree. Any runtime error (undefined vars,
|
|
83
86
|
// broken template literals, …) is caught and reported back to the CLI.
|
|
84
87
|
|
|
88
|
+
interface StubProps {
|
|
89
|
+
children?: ComponentChildren
|
|
90
|
+
[key: string]: unknown
|
|
91
|
+
}
|
|
92
|
+
|
|
85
93
|
/** jsxDEV(type, props, key, isStaticChildren, source, self) → VNode */
|
|
86
|
-
function mockJsxDEV(type:
|
|
87
|
-
|
|
88
|
-
|
|
94
|
+
function mockJsxDEV(type: ComponentType<StubProps> | string, props: StubProps | null, key?: Key) {
|
|
95
|
+
const { children, ...rest } = props ?? {}
|
|
96
|
+
return h(type, { ...rest, key }, children)
|
|
89
97
|
}
|
|
90
98
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
99
|
+
function isObject(value: unknown): value is object {
|
|
100
|
+
return typeof value === 'object' && value !== null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isIterable(value: unknown): value is Iterable<unknown> {
|
|
104
|
+
return isObject(value) && Symbol.iterator in value && typeof value[Symbol.iterator] === 'function'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function errorMessage(error: unknown): string | undefined {
|
|
108
|
+
return isObject(error) && 'message' in error && typeof error.message === 'string' ? error.message : undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function childArray<T>(children: T | T[]): T[] {
|
|
112
|
+
return Array.isArray(children) ? children : [children]
|
|
94
113
|
}
|
|
95
114
|
|
|
96
115
|
/** Passthrough component — just renders its children */
|
|
97
|
-
const Stub = ({ children }: StubProps) => h(Fragment, null, children)
|
|
116
|
+
const Stub = ({ children }: StubProps) => h(Fragment, null, children)
|
|
98
117
|
|
|
99
118
|
function validatingStub(component: string, requiredProps: Record<string, RequiredPropKind>) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
119
|
+
return (props: StubProps) => {
|
|
120
|
+
for (const [prop, kind] of Object.entries(requiredProps)) {
|
|
121
|
+
const value = props[prop]
|
|
122
|
+
const valid = kind === 'array' ? Array.isArray(value) : typeof value === kind
|
|
123
|
+
if (!valid) {
|
|
124
|
+
throw new Error(`${component}: required prop \`${prop}\` must be ${kind === 'array' ? 'an array' : `a ${kind}`}`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return h(Fragment, null, props.children)
|
|
128
|
+
}
|
|
110
129
|
}
|
|
111
130
|
|
|
112
131
|
// Mermaid stub collects diagram sources for post-render validation
|
|
113
|
-
let collectedMermaidSources: string[] = []
|
|
132
|
+
let collectedMermaidSources: string[] = []
|
|
114
133
|
|
|
115
134
|
/** Names whose stub does more than check props, so a manifest entry cannot
|
|
116
135
|
* describe them. Hooks return shapes that survive destructuring. */
|
|
117
|
-
const SPECIAL_STUBS: Record<string,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function buildStubs(components: CanvasComponents): Record<string,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
136
|
+
const SPECIAL_STUBS: Record<string, unknown> = {
|
|
137
|
+
Mermaid: ({ children }: StubProps) => {
|
|
138
|
+
const source = typeof children === 'string' ? children : String(children ?? '')
|
|
139
|
+
if (source.trim()) collectedMermaidSources.push(source.trim())
|
|
140
|
+
return null
|
|
141
|
+
},
|
|
142
|
+
useFeedback: () => ({ submit: () => {}, value: null }),
|
|
143
|
+
useAnnotations: () => ({ annotations: [], addAnnotation: () => {} }),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildStubs(components: CanvasComponents): Record<string, unknown> {
|
|
147
|
+
const stubs: Record<string, unknown> = {}
|
|
148
|
+
for (const [name, spec] of Object.entries(components)) {
|
|
149
|
+
const special = SPECIAL_STUBS[name]
|
|
150
|
+
stubs[name] = special ?? (spec.requiredProps ? validatingStub(name, spec.requiredProps) : Stub)
|
|
151
|
+
}
|
|
152
|
+
return stubs
|
|
134
153
|
}
|
|
135
154
|
|
|
136
155
|
const MOCK_REACT = {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
156
|
+
createElement: h,
|
|
157
|
+
Fragment,
|
|
158
|
+
useState: <T>(init: T) => [init, () => {}],
|
|
159
|
+
useEffect: () => {},
|
|
160
|
+
useRef: <T>(init: T) => ({ current: init }),
|
|
161
|
+
useMemo: <T>(fn: () => T) => fn(),
|
|
162
|
+
useCallback: <T>(fn: T) => fn,
|
|
163
|
+
useReducer: <S>(_reducer: unknown, init: S) => [init, () => {}],
|
|
164
|
+
useContext: () => ({}),
|
|
165
|
+
createContext: () => ({ Provider: Stub, Consumer: Stub }),
|
|
166
|
+
useLayoutEffect: () => {},
|
|
167
|
+
useImperativeHandle: () => {},
|
|
168
|
+
useDebugValue: () => {},
|
|
169
|
+
useDeferredValue: <T>(v: T) => v,
|
|
170
|
+
useTransition: () => [false, (fn: () => void) => fn()],
|
|
171
|
+
useId: () => 'mock',
|
|
172
|
+
useSyncExternalStore: <T>(_subscribe: unknown, getSnapshot: () => T) => getSnapshot(),
|
|
173
|
+
memo: <T>(c: T) => c,
|
|
174
|
+
forwardRef: <T>(c: T) => c,
|
|
175
|
+
lazy: <T>(c: T) => c,
|
|
176
|
+
Children: {
|
|
177
|
+
map: <T, R>(c: T | T[], fn: (child: T, index: number) => R) => childArray(c).map(fn),
|
|
178
|
+
toArray: <T>(c: T | T[]) => childArray(c),
|
|
179
|
+
},
|
|
180
|
+
isValidElement: () => true,
|
|
181
|
+
cloneElement: h,
|
|
182
|
+
Suspense: Stub,
|
|
183
|
+
StrictMode: Stub,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function validateCompiledPlan(js: string, stubs: Record<string, unknown>): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
187
|
+
let code = js
|
|
188
|
+
|
|
189
|
+
// Strip ESM import statements
|
|
190
|
+
code = code.replace(/import\s+\w+\s+from\s+"[^"]+";?/g, '')
|
|
191
|
+
code = code.replace(/import\s+\*\s+as\s+\w+\s+from\s+"[^"]+";?/g, '')
|
|
192
|
+
code = code.replace(/import\s*\{[^}]*\}\s*from\s+"[^"]+";?/g, '')
|
|
193
|
+
|
|
194
|
+
// Strip export, extract default name
|
|
195
|
+
let defaultName: string | null = null
|
|
196
|
+
code = code.replace(/export\s*\{\s*(\w+)\s+as\s+default\s*\};?/g, (_, name) => {
|
|
197
|
+
defaultName = name
|
|
198
|
+
return ''
|
|
199
|
+
})
|
|
200
|
+
if (!defaultName) {
|
|
201
|
+
code = code.replace(/export\s+default\s+(\w+);?/g, (_, name) => {
|
|
202
|
+
defaultName = name
|
|
203
|
+
return ''
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const callDefault = defaultName
|
|
208
|
+
? `\nreturn typeof ${defaultName} === "function" ? ${defaultName}() : null;`
|
|
209
|
+
: '\nreturn null;'
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
collectedMermaidSources = []
|
|
213
|
+
const fn = new Function('React', 'C', 'jsxDEV', 'jsx', 'Fragment', code + callDefault)
|
|
214
|
+
const rendered: unknown = fn(MOCK_REACT, stubs, mockJsxDEV, mockJsxDEV, Fragment)
|
|
215
|
+
const tree = Array.isArray(rendered) ? h(Fragment, null, rendered) : rendered
|
|
216
|
+
if (isValidElement(tree)) renderToString(tree)
|
|
217
|
+
} catch (e) {
|
|
218
|
+
return { ok: false, error: errorMessage(e) || String(e) }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Validate collected Mermaid diagram sources
|
|
222
|
+
for (const source of collectedMermaidSources) {
|
|
223
|
+
try {
|
|
224
|
+
await parseMermaid(source)
|
|
225
|
+
} catch (e) {
|
|
226
|
+
const msg = (errorMessage(e) || 'Invalid diagram').split('\n')[0]
|
|
227
|
+
return { ok: false, error: `Mermaid syntax error: ${msg}` }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { ok: true }
|
|
209
232
|
}
|
|
210
233
|
|
|
211
234
|
/**
|
|
@@ -225,191 +248,256 @@ async function validateCompiledPlan(js: string, stubs: Record<string, any>): Pro
|
|
|
225
248
|
* with a caret — that suffix is also what lets the CLI classify the error as a
|
|
226
249
|
* user error and skip a pointless daemon restart (see push.ts).
|
|
227
250
|
*/
|
|
228
|
-
function formatBuildError(err:
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
251
|
+
function formatBuildError(err: unknown, lineOffset: number): string {
|
|
252
|
+
// Collect every candidate that might carry a `.position`, most specific first.
|
|
253
|
+
const candidates: unknown[] = []
|
|
254
|
+
if (positionOf(err)) candidates.push(err)
|
|
255
|
+
candidates.push(...nestedErrors(err))
|
|
256
|
+
if (!candidates.length && isIterable(err)) {
|
|
257
|
+
try {
|
|
258
|
+
candidates.push(...err)
|
|
259
|
+
} catch {}
|
|
260
|
+
}
|
|
261
|
+
const detail = candidates.find((candidate) => positionOf(candidate)) ?? candidates[0] ?? err
|
|
262
|
+
const [message = 'Syntax error'] = (errorMessage(detail) || errorMessage(err) || 'Syntax error').split('\n')
|
|
263
|
+
|
|
264
|
+
const pos = positionOf(detail)
|
|
265
|
+
if (!pos || !('line' in pos) || typeof pos.line !== 'number') return message
|
|
266
|
+
|
|
267
|
+
const column = 'column' in pos && typeof pos.column === 'number' ? pos.column : undefined
|
|
268
|
+
const loc = ` (line ${pos.line - lineOffset}, col ${column})`
|
|
269
|
+
let frame = ''
|
|
270
|
+
if ('lineText' in pos && typeof pos.lineText === 'string') {
|
|
271
|
+
const caret = ' '.repeat(Math.max(0, column ?? 0)) + '^'
|
|
272
|
+
frame = `\n ${pos.lineText}\n ${caret}`
|
|
273
|
+
}
|
|
274
|
+
return `${message}${loc}${frame}`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** A Bun `BuildMessage` carries its source location as `.position`. */
|
|
278
|
+
function positionOf(value: unknown): object | undefined {
|
|
279
|
+
return isObject(value) && 'position' in value && isObject(value.position) ? value.position : undefined
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** An `AggregateError` thrown by `Bun.build()` carries its `BuildMessage`s as `.errors`. */
|
|
283
|
+
function nestedErrors(value: unknown): unknown[] {
|
|
284
|
+
return isObject(value) && 'errors' in value && Array.isArray(value.errors) ? value.errors : []
|
|
249
285
|
}
|
|
250
286
|
|
|
251
287
|
export async function compileJsx(jsx: string, options: CompileOptions = {}): Promise<CompileResult> {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
288
|
+
const { projectRoot } = options
|
|
289
|
+
const components = options.components ? { ...KERNEL_COMPONENTS, ...options.components } : KERNEL_COMPONENTS
|
|
290
|
+
const componentPreamble = componentImports(components)
|
|
291
|
+
// Strip leading pragma / block / line comments (e.g. `/** @jsxImportSource ... */`).
|
|
292
|
+
// Authored JSX sometimes carries a JSX-runtime pragma from another toolchain;
|
|
293
|
+
// Canvas injects its own imports and uses the React automatic runtime, so the
|
|
294
|
+
// pragma is unused. Left in place it sits at the top level of the wrapped
|
|
295
|
+
// fragment as plain text (JSX only treats `{/* */}` as a comment) and renders
|
|
296
|
+
// literally on the page.
|
|
297
|
+
let stripped: string
|
|
298
|
+
do {
|
|
299
|
+
stripped = jsx
|
|
300
|
+
jsx = jsx.replace(/^\s*(?:\/\*[\s\S]*?\*\/|\/\/[^\n]*)\s*/, '')
|
|
301
|
+
} while (jsx !== stripped)
|
|
302
|
+
|
|
303
|
+
// Resolve file contents at compile time
|
|
304
|
+
if (projectRoot) {
|
|
305
|
+
try {
|
|
306
|
+
jsx = resolveFilePreviews(jsx, projectRoot, options.assertReadable)
|
|
307
|
+
jsx = resolveMarkdownFiles(jsx, projectRoot, options.assertReadable)
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (error instanceof CanvasFileAdmissionError) return { ok: false, error: error.message }
|
|
310
|
+
throw error
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Strip template literals and strings so `export default` inside CodeBlock
|
|
315
|
+
// content (e.g. {`export default defineConfig(...)`}) doesn't false-positive.
|
|
316
|
+
const strippedJsx = jsx
|
|
317
|
+
.replace(/\{`[^`]*`\}/gs, '') // {`...`} template expressions
|
|
318
|
+
.replace(/`[^`]*`/gs, '') // standalone template literals
|
|
319
|
+
.replace(/"[^"]*"/g, '') // double-quoted strings
|
|
320
|
+
.replace(/'[^']*'/g, '') // single-quoted strings
|
|
321
|
+
const hasDefaultExport = /export\s+default\b/.test(strippedJsx)
|
|
322
|
+
|
|
323
|
+
const source = hasDefaultExport
|
|
324
|
+
? `${componentPreamble}\n${jsx}`
|
|
325
|
+
: `${componentPreamble}\nexport default function Plan() {\n return (<>${jsx}</>);\n}\n`
|
|
326
|
+
|
|
327
|
+
// Offset: injected import lines + wrapper lines (export default function
|
|
328
|
+
// Plan() + return). Used to map Bun's line numbers back to the authored file.
|
|
329
|
+
const wrapperLines = hasDefaultExport ? 1 : 2
|
|
330
|
+
const lineOffset = componentPreamble.split('\n').length - 1 + wrapperLines
|
|
331
|
+
|
|
332
|
+
// Pre-validate syntax with Bun.Transpiler — gives precise error positions,
|
|
333
|
+
// unlike Bun.build() which throws opaque "Bundle failed" for syntax errors.
|
|
334
|
+
try {
|
|
335
|
+
// Guarded builds validate each executable module in onLoad before Bun can run macros.
|
|
336
|
+
if (!options.assertReadable) {
|
|
337
|
+
const transpiler = new Bun.Transpiler({ loader: 'jsx' })
|
|
338
|
+
transpiler.transformSync(source)
|
|
339
|
+
}
|
|
340
|
+
} catch (syntaxError) {
|
|
341
|
+
return { ok: false, error: formatBuildError(syntaxError, lineOffset) }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const compileDir = options.tempDir ?? FALLBACK_COMPILE_DIR
|
|
345
|
+
mkdirSync(compileDir, { recursive: true })
|
|
346
|
+
const tmpFile = join(compileDir, `plan-${randomUUID()}.jsx`)
|
|
347
|
+
const configFile = `${tmpFile}.json`
|
|
348
|
+
let admissionFailed = false
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
writeFileSync(tmpFile, source)
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
if (options.assertReadable) writeFileSync(configFile, '{}')
|
|
355
|
+
const buildOptions: Bun.BuildConfig = {
|
|
356
|
+
entrypoints: [tmpFile],
|
|
357
|
+
format: 'esm',
|
|
358
|
+
external: ['react', 'react-dom', '#canvas/components', '#canvas/runtime'],
|
|
359
|
+
jsx: { runtime: 'automatic', importSource: 'react' },
|
|
360
|
+
...(options.assertReadable
|
|
361
|
+
? {
|
|
362
|
+
tsconfig: configFile,
|
|
363
|
+
plugins: [{
|
|
364
|
+
name: 'canvas-file-admission',
|
|
365
|
+
setup(build: Bun.PluginBuilder) {
|
|
366
|
+
build.onResolve({ filter: /^data:/i }, () => {
|
|
367
|
+
admissionFailed = true
|
|
368
|
+
throw new CanvasFileAdmissionError()
|
|
369
|
+
})
|
|
370
|
+
build.onLoad({ filter: /.*/, namespace: 'file' }, ({ path, loader }) => {
|
|
371
|
+
try {
|
|
372
|
+
assertCanvasFileReadable(path, options.assertReadable)
|
|
373
|
+
} catch {
|
|
374
|
+
admissionFailed = true
|
|
375
|
+
throw new CanvasFileAdmissionError()
|
|
376
|
+
}
|
|
377
|
+
if (loader === 'js' || loader === 'jsx' || loader === 'ts' || loader === 'tsx') {
|
|
378
|
+
const contents = readFileSync(path)
|
|
379
|
+
// Bun 1.3.14 supports macro:false, but its TranspilerOptions declaration omits false.
|
|
380
|
+
new Bun.Transpiler({ loader, macro: false } as unknown as Bun.TranspilerOptions).transformSync(contents)
|
|
381
|
+
return { contents, loader }
|
|
382
|
+
}
|
|
383
|
+
return undefined
|
|
384
|
+
})
|
|
385
|
+
},
|
|
386
|
+
}],
|
|
387
|
+
}
|
|
388
|
+
: {}),
|
|
389
|
+
}
|
|
390
|
+
const result = await Bun.build(buildOptions)
|
|
391
|
+
if (admissionFailed) return { ok: false, error: new CanvasFileAdmissionError().message }
|
|
392
|
+
|
|
393
|
+
if (!result.success) {
|
|
394
|
+
// Resolver metadata can bypass onLoad; its diagnostics may contain unadmitted source text.
|
|
395
|
+
if (options.assertReadable) return { ok: false, error: 'Canvas compilation failed' }
|
|
396
|
+
const errorLogs = result.logs.filter((l) => l.level === 'error')
|
|
397
|
+
const errors = errorLogs
|
|
398
|
+
.map((l) => formatBuildError(l, lineOffset))
|
|
399
|
+
.join('\n')
|
|
400
|
+
return { ok: false, error: errors || 'Bundle failed' }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const [output] = result.outputs
|
|
404
|
+
if (!output) return { ok: false, error: 'Bundle produced no output' }
|
|
405
|
+
|
|
406
|
+
const js = await output.text()
|
|
407
|
+
const validation = await validateCompiledPlan(js, buildStubs(components))
|
|
408
|
+
if (!validation.ok) {
|
|
409
|
+
return { ok: false, error: `Runtime error: ${validation.error}` }
|
|
410
|
+
}
|
|
411
|
+
return { ok: true, js }
|
|
412
|
+
} catch (buildError) {
|
|
413
|
+
if (admissionFailed) return { ok: false, error: new CanvasFileAdmissionError().message }
|
|
414
|
+
if (options.assertReadable) return { ok: false, error: 'Canvas compilation failed' }
|
|
415
|
+
// A real syntax error surfaces as an AggregateError carrying BuildMessages
|
|
416
|
+
// (with positions) — format it precisely. The opaque "Unknown Error, TODO"
|
|
417
|
+
// corruption case (internal bundler state gets corrupted in long-running
|
|
418
|
+
// processes) has no sub-errors; pass it through so the CLI restarts the
|
|
419
|
+
// daemon and retries.
|
|
420
|
+
const hasDetail = positionOf(buildError) || nestedErrors(buildError).length > 0
|
|
421
|
+
if (hasDetail) {
|
|
422
|
+
return { ok: false, error: formatBuildError(buildError, lineOffset) }
|
|
423
|
+
}
|
|
424
|
+
return { ok: false, error: errorMessage(buildError) || 'Unknown Error' }
|
|
425
|
+
}
|
|
426
|
+
} catch (e) {
|
|
427
|
+
if (options.assertReadable) return { ok: false, error: 'Canvas compilation failed' }
|
|
428
|
+
return { ok: false, error: errorMessage(e) ?? String(e) }
|
|
429
|
+
} finally {
|
|
430
|
+
try {
|
|
431
|
+
unlinkSync(tmpFile)
|
|
432
|
+
} catch {}
|
|
433
|
+
try {
|
|
434
|
+
unlinkSync(configFile)
|
|
435
|
+
} catch {}
|
|
436
|
+
}
|
|
351
437
|
}
|
|
352
438
|
|
|
353
439
|
/**
|
|
354
440
|
* Find <Markdown file="..." /> tags and inject __content with the file contents
|
|
355
441
|
* resolved at compile time.
|
|
356
442
|
*/
|
|
357
|
-
function resolveMarkdownFiles(jsx: string, projectRoot: string): string {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
443
|
+
function resolveMarkdownFiles(jsx: string, projectRoot: string, assertReadable?: AssertReadable): string {
|
|
444
|
+
return jsx.replace(
|
|
445
|
+
/<Markdown\b([^>]*?)\/>/g,
|
|
446
|
+
(match, attrs: string) => {
|
|
447
|
+
const filePath = attrs.match(/file=["']([^"']+)["']/)?.[1]
|
|
448
|
+
if (!filePath) return match
|
|
449
|
+
|
|
450
|
+
const absPath = assertReadable ? resolve(projectRoot, filePath) : join(projectRoot, filePath)
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
const content = readCanvasFile(absPath, assertReadable)
|
|
454
|
+
const escaped = JSON.stringify(content)
|
|
455
|
+
return `<Markdown${attrs} __content={${escaped}} />`
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (error instanceof CanvasFileAdmissionError) throw error
|
|
458
|
+
return match
|
|
459
|
+
}
|
|
460
|
+
},
|
|
461
|
+
)
|
|
375
462
|
}
|
|
376
463
|
|
|
377
464
|
/**
|
|
378
465
|
* Find <FilePreview path="..." /> tags and inject __content with the file contents
|
|
379
466
|
* resolved at compile time so the browser doesn't need to fetch.
|
|
380
467
|
*/
|
|
381
|
-
function resolveFilePreviews(jsx: string, projectRoot: string): string {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
468
|
+
function resolveFilePreviews(jsx: string, projectRoot: string, assertReadable?: AssertReadable): string {
|
|
469
|
+
// Match <FilePreview ... /> (self-closing)
|
|
470
|
+
return jsx.replace(
|
|
471
|
+
/<FilePreview\b([^>]*?)\/>/g,
|
|
472
|
+
(match, attrs: string) => {
|
|
473
|
+
const filePath = attrs.match(/path=["']([^"']+)["']/)?.[1]
|
|
474
|
+
if (!filePath) return match
|
|
475
|
+
|
|
476
|
+
const absPath = assertReadable ? resolve(projectRoot, filePath) : join(projectRoot, filePath)
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
let content = readCanvasFile(absPath, assertReadable)
|
|
480
|
+
|
|
481
|
+
// Apply lines filter if present
|
|
482
|
+
const linesMatch = attrs.match(/lines=\{?\[(\d+)\s*,\s*(\d+)\]\}?/)
|
|
483
|
+
if (linesMatch?.[1] && linesMatch[2]) {
|
|
484
|
+
const start = parseInt(linesMatch[1], 10)
|
|
485
|
+
const end = parseInt(linesMatch[2], 10)
|
|
486
|
+
content = content.split('\n').slice(start - 1, end).join('\n')
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Escape for embedding in JSX string
|
|
490
|
+
const escaped = JSON.stringify(content)
|
|
491
|
+
return `<FilePreview${attrs} __content={${escaped}} />`
|
|
492
|
+
} catch (error) {
|
|
493
|
+
if (error instanceof CanvasFileAdmissionError) throw error
|
|
494
|
+
return match
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
)
|
|
410
498
|
}
|
|
411
499
|
|
|
412
500
|
/** @deprecated Use `compileJsx(jsx, { projectRoot, tempDir })`. */
|
|
413
501
|
export async function compilePlan(jsx: string, projectRoot?: string): Promise<CompileResult> {
|
|
414
|
-
|
|
502
|
+
return compileJsx(jsx, { projectRoot })
|
|
415
503
|
}
|