@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.
Files changed (70) hide show
  1. package/README.md +33 -67
  2. package/index.ts +20 -83
  3. package/package.json +69 -72
  4. package/src/client/AnnotationProvider.tsx +218 -255
  5. package/src/client/AnnotationSidebar.tsx +393 -338
  6. package/src/client/FileIcon.tsx +105 -66
  7. package/src/client/PlanRenderer.tsx +607 -551
  8. package/src/client/RenderErrorContext.ts +7 -7
  9. package/src/client/ResponsePreview.tsx +192 -173
  10. package/src/client/annotationDom.ts +43 -97
  11. package/src/client/annotationDraft.ts +106 -96
  12. package/src/client/appContext.tsx +40 -40
  13. package/src/client/components/Callout.tsx +20 -20
  14. package/src/client/components/Checklist.tsx +27 -29
  15. package/src/client/components/Choice.tsx +178 -159
  16. package/src/client/components/CodeBlock.tsx +27 -26
  17. package/src/client/components/Diff.tsx +16 -17
  18. package/src/client/components/FilePreview.tsx +102 -74
  19. package/src/client/components/Image.tsx +41 -39
  20. package/src/client/components/Markdown.tsx +33 -32
  21. package/src/client/components/Mermaid.tsx +593 -542
  22. package/src/client/components/Note.tsx +8 -8
  23. package/src/client/components/Priority.tsx +10 -12
  24. package/src/client/components/ResponseNote.tsx +16 -15
  25. package/src/client/components/Section.tsx +28 -26
  26. package/src/client/components/Table.tsx +33 -30
  27. package/src/client/components/Task.tsx +58 -60
  28. package/src/client/components/UserInput.tsx +169 -152
  29. package/src/client/components/index.ts +16 -16
  30. package/src/client/components/renderMarkdownChildren.tsx +86 -68
  31. package/src/client/diffLines.ts +51 -39
  32. package/src/client/generateMarkdown.ts +197 -191
  33. package/src/client/hostContext.tsx +39 -34
  34. package/src/client/index.ts +44 -125
  35. package/src/client/pageGlobals.ts +25 -0
  36. package/src/client/persistenceSchemas.ts +41 -0
  37. package/src/client/preact-all.ts +4 -4
  38. package/src/client/runtime.ts +58 -111
  39. package/src/client/styles.css +1 -74
  40. package/src/engine/compiler.ts +411 -323
  41. package/src/engine/file-admission.ts +25 -0
  42. package/src/engine/paths.ts +44 -44
  43. package/src/engine/session.ts +515 -626
  44. package/src/engine/sessionSchemas.ts +71 -0
  45. package/src/engine/watcher.ts +70 -60
  46. package/src/engine/websocket.ts +134 -132
  47. package/annotate.ts +0 -94
  48. package/daemon.ts +0 -46
  49. package/src/client/AnnotationDraftFooter.tsx +0 -76
  50. package/src/client/AnnotationEditor.tsx +0 -320
  51. package/src/client/AnnotationList.tsx +0 -190
  52. package/src/client/Popover.tsx +0 -361
  53. package/src/client/SessionContext.tsx +0 -3
  54. package/src/client/annotationContext.ts +0 -130
  55. package/src/client/annotationMarkdown.ts +0 -102
  56. package/src/client/annotationTarget.ts +0 -73
  57. package/src/client/blockTarget.ts +0 -127
  58. package/src/client/highlightRange.ts +0 -262
  59. package/src/client/popoverPosition.ts +0 -46
  60. package/src/client/regionTarget.ts +0 -180
  61. package/src/client/testing/dom.ts +0 -74
  62. package/src/client/textTarget.ts +0 -31
  63. package/src/client/useRegionAnnotation.tsx +0 -188
  64. package/src/client/useTextAnnotation.tsx +0 -194
  65. package/src/client/utils.ts +0 -33
  66. package/src/engine/channel.ts +0 -278
  67. package/src/engine/http-utils.ts +0 -14
  68. package/src/engine/lifecycle.ts +0 -302
  69. package/src/engine/router.ts +0 -16
  70. package/src/engine/waiter.ts +0 -188
@@ -1,16 +1,17 @@
1
- import { writeFileSync, unlinkSync, readFileSync, mkdirSync } from "fs";
2
- import { join } from "path";
3
- import { tmpdir } from "os";
4
- import { randomUUID } from "crypto";
5
- import { h, Fragment, type ComponentChildren } from "preact";
6
- import renderToString from "preact-render-to-string";
7
- import { parse as parseMermaid } from "mermaid-parser-bundle";
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
- | { ok: true; js: string }
11
- | { ok: false; error: string };
11
+ | { ok: true; js: string }
12
+ | { ok: false; error: string }
12
13
 
13
- type RequiredPropKind = "array" | "string";
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
- requiredProps?: Record<string, RequiredPropKind>;
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
- Section: { requiredProps: { title: "string" } },
34
- Item: { requiredProps: { id: "string", label: "string" } },
35
- Task: { requiredProps: { id: "string", label: "string" } },
36
- FilePreview: { requiredProps: { path: "string" } },
37
- CodeBlock: {},
38
- Callout: {},
39
- Mermaid: {},
40
- Table: { requiredProps: { headers: "array", rows: "array" } },
41
- Priority: { requiredProps: { level: "string" } },
42
- Checklist: { requiredProps: { items: "array" } },
43
- Note: {},
44
- Diff: { requiredProps: { before: "string", after: "string" } },
45
- Choice: { requiredProps: { id: "string", label: "string", options: "array" } },
46
- MultiChoice: { requiredProps: { id: "string", label: "string", options: "array" } },
47
- UserInput: { requiredProps: { id: "string", label: "string" } },
48
- RangeInput: { requiredProps: { id: "string", label: "string" } },
49
- ImageView: { requiredProps: { src: "string" } },
50
- Markdown: {},
51
- useFeedback: {},
52
- useAnnotations: {},
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
- /** Resolves `<FilePreview>` / `<Markdown file>` references at compile time. */
57
- projectRoot?: string;
58
- /** Scratch dir for the temp `.jsx` handed to Bun.build. Created on demand. */
59
- tempDir?: string;
60
- /**
61
- * Host components to add on top of KERNEL_COMPONENTS, keyed by name. Each one
62
- * must also be exported from the host's `#canvas/components` barrel — that is
63
- * what the injected import actually reads at runtime. Re-declaring a kernel
64
- * name overrides its spec.
65
- */
66
- components?: CanvasComponents;
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(), "canvas-kernel", "compile");
73
+ const FALLBACK_COMPILE_DIR = join(tmpdir(), 'canvas-kernel', 'compile')
71
74
 
72
75
  function componentImports(components: CanvasComponents): string {
73
- return `import React from 'react';
76
+ return `import React from 'react';
74
77
  import * as C from '#canvas/components';
75
- const { ${Object.keys(components).join(", ")} } = C;
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: any, props: any, key?: any) {
87
- const { children, ...rest } = props || {};
88
- return h(type, { ...rest, key }, children);
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
- interface StubProps {
92
- children?: ComponentChildren;
93
- [key: string]: unknown;
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
- return (props: StubProps) => {
101
- for (const [prop, kind] of Object.entries(requiredProps)) {
102
- const value = props[prop];
103
- const valid = kind === "array" ? Array.isArray(value) : typeof value === kind;
104
- if (!valid) {
105
- throw new Error(`${component}: required prop \`${prop}\` must be ${kind === "array" ? "an array" : `a ${kind}`}`);
106
- }
107
- }
108
- return h(Fragment, null, props.children);
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, any> = {
118
- Mermaid: ({ children }: any) => {
119
- const source = typeof children === "string" ? children : String(children ?? "");
120
- if (source.trim()) collectedMermaidSources.push(source.trim());
121
- return null;
122
- },
123
- useFeedback: () => ({ submit: () => {}, value: null }),
124
- useAnnotations: () => ({ annotations: [], addAnnotation: () => {} }),
125
- };
126
-
127
- function buildStubs(components: CanvasComponents): Record<string, any> {
128
- const stubs: Record<string, any> = {};
129
- for (const [name, spec] of Object.entries(components)) {
130
- const special = SPECIAL_STUBS[name];
131
- stubs[name] = special ?? (spec.requiredProps ? validatingStub(name, spec.requiredProps) : Stub);
132
- }
133
- return stubs;
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
- createElement: h,
138
- Fragment,
139
- useState: (init: any) => [init, () => {}],
140
- useEffect: () => {},
141
- useRef: (init: any) => ({ current: init }),
142
- useMemo: (fn: any) => fn(),
143
- useCallback: (fn: any) => fn,
144
- useReducer: (r: any, init: any) => [init, () => {}],
145
- useContext: () => ({}),
146
- createContext: () => ({ Provider: Stub, Consumer: Stub }),
147
- useLayoutEffect: () => {},
148
- useImperativeHandle: () => {},
149
- useDebugValue: () => {},
150
- useDeferredValue: (v: any) => v,
151
- useTransition: () => [false, (fn: any) => fn()],
152
- useId: () => "mock",
153
- useSyncExternalStore: (sub: any, get: any) => get(),
154
- memo: (c: any) => c,
155
- forwardRef: (c: any) => c,
156
- lazy: (c: any) => c,
157
- Children: { map: (c: any, fn: any) => (Array.isArray(c) ? c : [c]).map(fn), toArray: (c: any) => (Array.isArray(c) ? c : [c]) },
158
- isValidElement: () => true,
159
- cloneElement: h,
160
- Suspense: Stub,
161
- StrictMode: Stub,
162
- };
163
-
164
- async function validateCompiledPlan(js: string, stubs: Record<string, any>): Promise<{ ok: true } | { ok: false; error: string }> {
165
- let code = js;
166
-
167
- // Strip ESM import statements
168
- code = code.replace(/import\s+\w+\s+from\s+"[^"]+";?/g, "");
169
- code = code.replace(/import\s+\*\s+as\s+\w+\s+from\s+"[^"]+";?/g, "");
170
- code = code.replace(/import\s*\{[^}]*\}\s*from\s+"[^"]+";?/g, "");
171
-
172
- // Strip export, extract default name
173
- let defaultName: string | null = null;
174
- code = code.replace(/export\s*\{\s*(\w+)\s+as\s+default\s*\};?/g, (_, name) => {
175
- defaultName = name;
176
- return "";
177
- });
178
- if (!defaultName) {
179
- code = code.replace(/export\s+default\s+(\w+);?/g, (_, name) => {
180
- defaultName = name;
181
- return "";
182
- });
183
- }
184
-
185
- const callDefault = defaultName
186
- ? `\nreturn typeof ${defaultName} === "function" ? ${defaultName}() : null;`
187
- : "\nreturn null;";
188
-
189
- try {
190
- collectedMermaidSources = [];
191
- const fn = new Function("React", "C", "jsxDEV", "jsx", "Fragment", code + callDefault);
192
- const vnode = fn(MOCK_REACT, stubs, mockJsxDEV, mockJsxDEV, Fragment);
193
- if (vnode) renderToString(vnode);
194
- } catch (e: any) {
195
- return { ok: false, error: e?.message || String(e) };
196
- }
197
-
198
- // Validate collected Mermaid diagram sources
199
- for (const source of collectedMermaidSources) {
200
- try {
201
- await parseMermaid(source);
202
- } catch (e: any) {
203
- const msg = (e?.message || "Invalid diagram").split("\n")[0];
204
- return { ok: false, error: `Mermaid syntax error: ${msg}` };
205
- }
206
- }
207
-
208
- return { ok: true };
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: any, lineOffset: number): string {
229
- // Collect every candidate that might carry a `.position`, most specific first.
230
- const candidates: any[] = [];
231
- if (err?.position) candidates.push(err);
232
- if (Array.isArray(err?.errors)) candidates.push(...err.errors);
233
- if (!candidates.length && err && typeof err[Symbol.iterator] === "function") {
234
- try { candidates.push(...err); } catch {}
235
- }
236
- const detail = candidates.find((c) => c?.position) ?? candidates[0] ?? err;
237
- const [message = "Syntax error"] = String(detail?.message || err?.message || "Syntax error").split("\n");
238
-
239
- const pos = detail?.position;
240
- if (!pos || typeof pos.line !== "number") return message;
241
-
242
- const loc = ` (line ${pos.line - lineOffset}, col ${pos.column})`;
243
- let frame = "";
244
- if (typeof pos.lineText === "string") {
245
- const caret = " ".repeat(Math.max(0, pos.column ?? 0)) + "^";
246
- frame = `\n ${pos.lineText}\n ${caret}`;
247
- }
248
- return `${message}${loc}${frame}`;
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
- const { projectRoot } = options;
253
- const components = options.components ? { ...KERNEL_COMPONENTS, ...options.components } : KERNEL_COMPONENTS;
254
- const componentPreamble = componentImports(components);
255
- // Strip leading pragma / block / line comments (e.g. `/** @jsxImportSource ... */`).
256
- // Authored JSX sometimes carries a JSX-runtime pragma from another toolchain;
257
- // Canvas injects its own imports and uses the React automatic runtime, so the
258
- // pragma is unused. Left in place it sits at the top level of the wrapped
259
- // fragment as plain text (JSX only treats `{/* */}` as a comment) and renders
260
- // literally on the page.
261
- let stripped: string;
262
- do {
263
- stripped = jsx;
264
- jsx = jsx.replace(/^\s*(?:\/\*[\s\S]*?\*\/|\/\/[^\n]*)\s*/, "");
265
- } while (jsx !== stripped);
266
-
267
- // Resolve file contents at compile time
268
- if (projectRoot) {
269
- jsx = resolveFilePreviews(jsx, projectRoot);
270
- jsx = resolveMarkdownFiles(jsx, projectRoot);
271
- }
272
-
273
- // Strip template literals and strings so `export default` inside CodeBlock
274
- // content (e.g. {`export default defineConfig(...)`}) doesn't false-positive.
275
- const strippedJsx = jsx
276
- .replace(/\{`[^`]*`\}/gs, "") // {`...`} template expressions
277
- .replace(/`[^`]*`/gs, "") // standalone template literals
278
- .replace(/"[^"]*"/g, "") // double-quoted strings
279
- .replace(/'[^']*'/g, ""); // single-quoted strings
280
- const hasDefaultExport = /export\s+default\b/.test(strippedJsx);
281
-
282
- const source = hasDefaultExport
283
- ? `${componentPreamble}\n${jsx}`
284
- : `${componentPreamble}\nexport default function Plan() {\n return (<>${jsx}</>);\n}\n`;
285
-
286
- // Offset: injected import lines + wrapper lines (export default function
287
- // Plan() + return). Used to map Bun's line numbers back to the authored file.
288
- const wrapperLines = hasDefaultExport ? 1 : 2;
289
- const lineOffset = componentPreamble.split("\n").length - 1 + wrapperLines;
290
-
291
- // Pre-validate syntax with Bun.Transpiler — gives precise error positions,
292
- // unlike Bun.build() which throws opaque "Bundle failed" for syntax errors.
293
- try {
294
- const transpiler = new Bun.Transpiler({ loader: "jsx" });
295
- transpiler.transformSync(source);
296
- } catch (syntaxError: any) {
297
- return { ok: false, error: formatBuildError(syntaxError, lineOffset) };
298
- }
299
-
300
- const compileDir = options.tempDir ?? FALLBACK_COMPILE_DIR;
301
- mkdirSync(compileDir, { recursive: true });
302
- const tmpFile = join(compileDir, `plan-${randomUUID()}.jsx`);
303
-
304
- try {
305
- writeFileSync(tmpFile, source);
306
-
307
- try {
308
- const result = await Bun.build({
309
- entrypoints: [tmpFile],
310
- format: "esm",
311
- external: ["react", "react-dom", "#canvas/components", "#canvas/runtime"],
312
- jsx: { runtime: "automatic", importSource: "react" },
313
- });
314
-
315
- if (!result.success) {
316
- const errorLogs = result.logs.filter((l) => l.level === "error");
317
- const errors = errorLogs
318
- .map((l) => formatBuildError(l, lineOffset))
319
- .join("\n");
320
- return { ok: false, error: errors || "Bundle failed" };
321
- }
322
-
323
- const [output] = result.outputs;
324
- if (!output) return { ok: false, error: "Bundle produced no output" };
325
-
326
- const js = await output.text();
327
- const validation = await validateCompiledPlan(js, buildStubs(components));
328
- if (!validation.ok) {
329
- return { ok: false, error: `Runtime error: ${validation.error}` };
330
- }
331
- return { ok: true, js };
332
- } catch (buildError: any) {
333
- // A real syntax error surfaces as an AggregateError carrying BuildMessages
334
- // (with positions) — format it precisely. The opaque "Unknown Error, TODO"
335
- // corruption case (internal bundler state gets corrupted in long-running
336
- // processes) has no sub-errors; pass it through so the CLI restarts the
337
- // daemon and retries.
338
- const hasDetail =
339
- buildError?.position ||
340
- (Array.isArray(buildError?.errors) && buildError.errors.length > 0);
341
- if (hasDetail) {
342
- return { ok: false, error: formatBuildError(buildError, lineOffset) };
343
- }
344
- return { ok: false, error: buildError?.message || "Unknown Error" };
345
- }
346
- } catch (e: any) {
347
- return { ok: false, error: e.message };
348
- } finally {
349
- try { unlinkSync(tmpFile); } catch {}
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
- return jsx.replace(
359
- /<Markdown\b([^>]*?)\/>/g,
360
- (match, attrs: string) => {
361
- const filePath = attrs.match(/file=["']([^"']+)["']/)?.[1];
362
- if (!filePath) return match;
363
-
364
- const absPath = join(projectRoot, filePath);
365
-
366
- try {
367
- const content = readFileSync(absPath, "utf-8");
368
- const escaped = JSON.stringify(content);
369
- return `<Markdown${attrs} __content={${escaped}} />`;
370
- } catch {
371
- return match;
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
- // Match <FilePreview ... /> (self-closing)
383
- return jsx.replace(
384
- /<FilePreview\b([^>]*?)\/>/g,
385
- (match, attrs: string) => {
386
- const filePath = attrs.match(/path=["']([^"']+)["']/)?.[1];
387
- if (!filePath) return match;
388
-
389
- const absPath = join(projectRoot, filePath);
390
-
391
- try {
392
- let content = readFileSync(absPath, "utf-8");
393
-
394
- // Apply lines filter if present
395
- const linesMatch = attrs.match(/lines=\{?\[(\d+)\s*,\s*(\d+)\]\}?/);
396
- if (linesMatch?.[1] && linesMatch[2]) {
397
- const start = parseInt(linesMatch[1], 10);
398
- const end = parseInt(linesMatch[2], 10);
399
- content = content.split("\n").slice(start - 1, end).join("\n");
400
- }
401
-
402
- // Escape for embedding in JSX string
403
- const escaped = JSON.stringify(content);
404
- return `<FilePreview${attrs} __content={${escaped}} />`;
405
- } catch {
406
- return match;
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
- return compileJsx(jsx, { projectRoot });
502
+ return compileJsx(jsx, { projectRoot })
415
503
  }