frameyard 0.3.0 → 0.4.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.
Files changed (104) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/GLOSSARY.md +42 -2
  3. package/README.md +162 -101
  4. package/dist/bindings.d.ts +42 -0
  5. package/dist/bindings.d.ts.map +1 -0
  6. package/dist/bindings.js +144 -0
  7. package/dist/bindings.js.map +1 -0
  8. package/dist/cli.js +25 -10
  9. package/dist/cli.js.map +1 -1
  10. package/dist/contracts.d.ts +73 -47
  11. package/dist/contracts.d.ts.map +1 -1
  12. package/dist/contracts.js +14 -159
  13. package/dist/contracts.js.map +1 -1
  14. package/dist/errors.d.ts +15 -4
  15. package/dist/errors.d.ts.map +1 -1
  16. package/dist/errors.js +8 -0
  17. package/dist/errors.js.map +1 -1
  18. package/dist/html/index.d.ts +5 -0
  19. package/dist/html/index.d.ts.map +1 -0
  20. package/dist/html/index.js +3 -0
  21. package/dist/html/index.js.map +1 -0
  22. package/dist/html/parse.d.ts +30 -0
  23. package/dist/html/parse.d.ts.map +1 -0
  24. package/dist/html/parse.js +80 -0
  25. package/dist/html/parse.js.map +1 -0
  26. package/dist/html/validate.d.ts +14 -0
  27. package/dist/html/validate.d.ts.map +1 -0
  28. package/dist/html/validate.js +931 -0
  29. package/dist/html/validate.js.map +1 -0
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/preview/main.d.ts +10 -3
  35. package/dist/preview/main.d.ts.map +1 -1
  36. package/dist/preview/main.js +186 -88
  37. package/dist/preview/main.js.map +1 -1
  38. package/dist/project.d.ts +2 -0
  39. package/dist/project.d.ts.map +1 -1
  40. package/dist/project.js +4 -0
  41. package/dist/project.js.map +1 -1
  42. package/dist/renderer/browser.d.ts +11 -1
  43. package/dist/renderer/browser.d.ts.map +1 -1
  44. package/dist/renderer/browser.js +62 -10
  45. package/dist/renderer/browser.js.map +1 -1
  46. package/dist/renderer/index.d.ts +25 -5
  47. package/dist/renderer/index.d.ts.map +1 -1
  48. package/dist/renderer/index.js +64 -9
  49. package/dist/renderer/index.js.map +1 -1
  50. package/dist/runtime/bindings.d.ts +11 -0
  51. package/dist/runtime/bindings.d.ts.map +1 -0
  52. package/dist/runtime/bindings.js +164 -0
  53. package/dist/runtime/bindings.js.map +1 -0
  54. package/dist/runtime/diagnostics.d.ts +4 -0
  55. package/dist/runtime/diagnostics.d.ts.map +1 -0
  56. package/dist/runtime/diagnostics.js +107 -0
  57. package/dist/runtime/diagnostics.js.map +1 -0
  58. package/dist/runtime/host.d.ts +12 -0
  59. package/dist/runtime/host.d.ts.map +1 -0
  60. package/dist/runtime/host.js +701 -0
  61. package/dist/runtime/host.js.map +1 -0
  62. package/dist/runtime/index.d.ts +0 -29
  63. package/dist/runtime/index.d.ts.map +1 -1
  64. package/dist/runtime/index.js +0 -360
  65. package/dist/runtime/index.js.map +1 -1
  66. package/dist/runtime/media.js +7 -7
  67. package/dist/runtime/media.js.map +1 -1
  68. package/dist/scaffold.d.ts.map +1 -1
  69. package/dist/scaffold.js +167 -79
  70. package/dist/scaffold.js.map +1 -1
  71. package/dist/server/composition-html.d.ts +20 -0
  72. package/dist/server/composition-html.d.ts.map +1 -0
  73. package/dist/server/composition-html.js +45 -0
  74. package/dist/server/composition-html.js.map +1 -0
  75. package/dist/server/preview-html.d.ts.map +1 -1
  76. package/dist/server/preview-html.js +19 -14
  77. package/dist/server/preview-html.js.map +1 -1
  78. package/dist/server/server.d.ts.map +1 -1
  79. package/dist/server/server.js +94 -6
  80. package/dist/server/server.js.map +1 -1
  81. package/dist/version.d.ts +1 -1
  82. package/dist/version.js +1 -1
  83. package/docs/adr/0001-keep-composition-authoring-web-native.md +2 -0
  84. package/docs/adr/0006-seek-embedded-media-from-the-frame-clock.md +3 -1
  85. package/docs/adr/0007-make-html-documents-the-composition-unit.md +31 -0
  86. package/docs/migration-0.4.md +279 -0
  87. package/docs/v0.1-design.md +76 -55
  88. package/examples/demo/README.md +7 -3
  89. package/examples/demo/compositions/demo.controller.js +120 -0
  90. package/examples/demo/compositions/demo.html +253 -0
  91. package/examples/demo/video.config.js +1 -1
  92. package/package.json +4 -2
  93. package/skills/frameyard/README.md +4 -0
  94. package/skills/frameyard/SKILL.md +38 -11
  95. package/skills/frameyard/references/audio-assets.md +25 -14
  96. package/skills/frameyard/references/bindings.md +73 -0
  97. package/skills/frameyard/references/common-mistakes.md +59 -0
  98. package/skills/frameyard/references/controllers.md +126 -0
  99. package/skills/frameyard/references/deterministic-authoring.md +143 -52
  100. package/skills/frameyard/references/diagnostics.md +90 -0
  101. package/skills/frameyard/references/failure-recovery.md +37 -14
  102. package/skills/frameyard/references/json-results.md +41 -7
  103. package/skills/frameyard/references/subtitles-karaoke.md +10 -18
  104. package/examples/demo/compositions/demo.js +0 -206
package/dist/scaffold.js CHANGED
@@ -5,22 +5,100 @@ This project produces deterministic, parameterized video with Frameyard. Follow
5
5
 
6
6
  ## Project layout
7
7
 
8
- - \`video.config.js\` explicitly maps composition IDs to entry modules. There is no directory scanning; a new composition is invisible until it is listed.
9
- - \`package.json\` pins the CLI version in \`dev\`, \`check\`, \`render\`, and \`still\` scripts. Run \`dev\` with \`npm run dev\` or \`pnpm dev\`; for the others, pass the composition id: \`npm run check -- hello\` (npm) or \`pnpm check hello\` (pnpm).
10
- - \`compositions/\` contains native JavaScript ES modules. Import bare \`frameyard\` and \`frameyard/runtime\`; the preview server supplies the import map.
11
- - \`props/\` contains JSON props, merged shallowly over \`defaultProps\` and deep-frozen.
8
+ - \`video.config.js\` explicitly maps composition IDs to \`.html\` composition documents. There is no directory scanning; a new composition is invisible until it is listed.
9
+ - \`compositions/\` contains HTML/CSS compositions. An optional controller lives beside the document as a \`.controller.js\` module; import \`frameyard/runtime\` for \`random("stable:key")\` and other browser APIs.
10
+ - \`props/\` contains JSON props, merged shallowly over the composition's \`data-fy="props"\` defaults and deep-frozen.
12
11
  - \`assets/\` (create it when needed) contains audio, images, fonts, and generated assets.
12
+ - \`package.json\` pins the CLI version in \`dev\`, \`check\`, \`render\`, and \`still\` scripts. Run \`dev\` with \`npm run dev\` or \`pnpm dev\`; for the others, pass the composition id: \`npm run check -- hello\` (npm) or \`pnpm check hello\` (pnpm).
13
+
14
+ \`width\`, \`height\`, \`fps\`, and \`duration-in-frames\` are fixed on \`<fy-composition>\`. Do not derive them from props.
15
+
16
+ ## Composition skeleton
17
+
18
+ \`\`\`html
19
+ <!doctype html>
20
+ <html>
21
+ <head>
22
+ <meta charset="utf-8">
23
+ <script type="application/json" data-fy="props">{ "title": "Hello" }</script>
24
+ </head>
25
+ <body>
26
+ <fy-composition width="1280" height="720" fps="30" duration-in-frames="150">
27
+ <h1 data-fy-text="title"></h1>
28
+ </fy-composition>
29
+ </body>
30
+ </html>
31
+ \`\`\`
32
+
33
+ ## Bindings
34
+
35
+ - \`data-fy-text="path"\` sets text content.
36
+ - \`data-fy-attr-src="path"\` (and other \`data-fy-attr-*\` names) sets an attribute.
37
+ - \`data-fy-class-active="path"\` toggles a class.
38
+ - \`data-fy-if="path"\` removes the element when the value is false.
39
+ - \`<template data-fy-repeat="items">\` clones its content for each item; use \`item\` and \`index\`, and style stagger with \`--fy-index\`.
40
+ - Paths are dot-separated (\`user.name\`, \`items.0.label\`). Missing paths warn and resolve to an empty value.
41
+ - Primitive top-level props are also available as \`--fy-prop-<name>\` CSS custom properties.
42
+
43
+ ## Animation
44
+
45
+ - CSS \`@keyframes\` and Web Animations API animations are paused and seeked from the frame clock.
46
+ - \`data-fy-anim-start\`, \`data-fy-anim-duration\`, and \`data-fy-anim-easing\` schedule one animation per element in composition frames. The CSS \`animation\` shorthand supplies keyframes and fill mode.
47
+ - Frame-state variables are set on \`:root\`: \`--fy-frame\`, \`--fy-time\`, \`--fy-progress\`, \`--fy-fps\`, \`--fy-duration\`, and \`--fy-duration-frames\`.
48
+ - Use \`--fy-progress\` for progress bars and \`--fy-frame\`/\`--fy-time\` in \`calc()\` for simple computed motion.
49
+ - CSS transitions are not deterministic; do not use them.
50
+
51
+ ## Controller
52
+
53
+ Exactly one optional controller is allowed, inline or external:
54
+
55
+ \`\`\`html
56
+ <script type="text/fy-controller">
57
+ export default {
58
+ mount({ root, props }) {},
59
+ update({ frame, props, root }) {},
60
+ };
61
+ </script>
62
+ \`\`\`
63
+
64
+ \`\`\`html
65
+ <script type="text/fy-controller" src="./hello.controller.js"></script>
66
+ \`\`\`
67
+
68
+ - \`mount\` may be asynchronous; \`update\` and \`validateProps\` are synchronous and seek-safe; \`dispose\` releases resources.
69
+ - Use inline for small, single-use controllers; extract to an external module for Canvas 2D, WebGL, karaoke, reuse, tests, or a large controller.
70
+ - A controller may be validation-only and export just \`validateProps(props)\`.
71
+ - The controller is the only executable code. Other scripts and \`on*\` handlers are rejected.
72
+ - Controller-created DOM is not re-bound; declarative bindings apply once before \`mount\`.
73
+
74
+ ## Determinism rules
75
+
76
+ - A frame may depend only on its integer \`frame\`, immutable \`props\`, and assets loaded before frame production.
77
+ - Never use \`Math.random()\`, \`Date.now()\`, \`performance.now()\`, timers, independent \`requestAnimationFrame()\` loops, CSS transitions, or state left from a previously visited frame as animation inputs.
78
+ - Use \`random("stable:key")\` from \`frameyard/runtime\` for randomness.
79
+ - Load remote data, fonts, and images before frame production. Network requests are blocked during capture; a \`fetch()\` from \`update()\` is a bug.
80
+ - Redraw Canvas 2D and WebGL from the current frame. Production pins software WebGL for reproducible pixels.
81
+ - Embedded video and audio use local project files and \`data-fy-start\` (frames), \`data-fy-trim\` (seconds), optional \`data-fy-duration\` (seconds), and \`data-fy-volume\` (0-1). Let the runtime seek video; do not call \`play()\`, \`pause()\`, or set \`currentTime\` in \`update()\`.
82
+ - Media and transcript \`src\` values resolve from the project root. The controller \`src\` and ordinary HTML asset URLs resolve relative to the composition document.
13
83
 
14
- \`width\`, \`height\`, \`fps\`, and \`durationInFrames\` are fixed composition metadata. Do not derive them from props.
84
+ ## Audio and transcripts
85
+
86
+ - Audio clips are \`<audio>\` elements placed by composition frames and trimmed by seconds. Frameyard does not time-stretch audio or change composition duration to fit it.
87
+ - An unmuted embedded video contributes its audio track to the same mix; use \`muted\` or \`data-fy-audio="off"\` for a visual-only video.
88
+ - Declare required transcripts with \`<fy-transcript src="assets/voice.transcript.json" words></fy-transcript>\`.
89
+ - Produce speech with \`npx frameyard tts\` and transcripts with \`npx frameyard transcribe\`; never call \`qwen-tts\` or \`whisper-cli\` directly, and never run inference inside a controller.
90
+ - Do not overwrite or regenerate generated assets without explicit approval.
91
+ - \`words\` requires measured word timings; never interpolate segment timings.
92
+ - Commit \`frameyard.assets.json\` when present; do not edit hashes or manifest entries by hand.
15
93
 
16
94
  ## Agent workflow
17
95
 
18
- 1. Read \`video.config.js\`, the composition source, and the props file before changing behavior.
19
- 2. Author the composition or props with native HTML, CSS, SVG, Canvas 2D, WebGL, embedded video, and JavaScript. Do not add a bundling step.
20
- 3. Validate every frame: \`npx frameyard check <id> --json\`. Use \`--props <file>\` to validate a specific props file.
96
+ 1. Read \`video.config.js\`, the composition HTML, the optional controller, and the props file before changing behavior.
97
+ 2. Author the composition with native HTML, CSS, SVG, Canvas 2D, WebGL, embedded video, and an optional controller. Do not add a bundling step.
98
+ 3. Validate every frame: \`npx frameyard check <id> --json\`. Static validation runs before Chromium and reports stable diagnostic codes with source locations. Use \`--props <file>\` to validate a specific props file.
21
99
  4. Inspect a representative frame: \`npx frameyard still <id> --frame <n> --out <file.png> --json\`, then view the PNG when the host supports images. State explicitly when visual inspection is not possible.
22
100
  5. Render: \`npx frameyard render <id> --props <file> --out <file.mp4> [--concurrency <n>] --json\`.
23
- 6. Report the composition, props, validation result, still path and inspection status, video path, and any failure. Never claim success when \`check\` fails, a still is missing, or a render exits nonzero.
101
+ 6. Report the composition, controller, props, validation result, diagnostics, still path and inspection status, video path, and any failure. Never claim success when \`check\` fails, a still is missing, or a render exits nonzero.
24
102
 
25
103
  ## Commands
26
104
 
@@ -37,27 +115,6 @@ npx frameyard render hello --props props/hello.json --out hello.mp4 --json
37
115
  - Use \`--concurrency <n>\` for faster local rendering when useful; the default is \`1\` and the maximum is \`16\`. It changes capture scheduling only, not composition behavior or output frame order.
38
116
  - \`still\` and \`render\` replace existing outputs only with \`--overwrite\`. Generated assets are replaced only with \`--force\`, and only with user approval.
39
117
 
40
- ## Determinism rules
41
-
42
- - A frame may depend only on its integer \`frame\`, immutable \`props\`, and assets loaded before frame production.
43
- - \`mount()\` may await loading work. \`update({ frame, props })\` is synchronous and seek-safe. \`dispose()\` releases resources.
44
- - Keep mutable state in module-scope variables, not on \`this\`.
45
- - Use \`random("stable:key")\` from \`frameyard/runtime\` for randomness.
46
- - Never use \`Math.random()\`, \`Date.now()\`, \`performance.now()\`, timers, independent \`requestAnimationFrame()\` loops, CSS transitions, or state left from a previously visited frame as animation inputs.
47
- - CSS \`@keyframes\` and the Web Animations API are valid; the runtime seeks them.
48
- - Load remote data, fonts, and images during \`mount()\` or before the first frame. Network requests are blocked during capture; a \`fetch()\` from \`update()\` is a bug.
49
- - Redraw Canvas 2D and WebGL from the current frame. Production pins software WebGL for reproducible pixels.
50
- - Embedded video must use a local project file in the composition container's light DOM and be created, with its \`data-frameyard-*\` attributes, in \`mount()\`. Let the runtime seek it; do not call \`play()\`, \`pause()\`, or set \`currentTime\` in \`update()\`. Use \`data-frameyard-start\` (frames), \`data-frameyard-trim\` (seconds), optional \`data-frameyard-duration\` (seconds), and optional \`data-frameyard-volume\` (0-1).
51
-
52
- ## Audio and transcripts
53
-
54
- - Audio clips are placed by composition frames and trimmed by seconds. Frameyard does not time-stretch audio or change composition duration to fit it.
55
- - An unmuted embedded video contributes its audio track to the same mix; use \`muted\` or \`data-frameyard-audio="off"\` for a visual-only video.
56
- - Produce speech with \`npx frameyard tts\` and transcripts with \`npx frameyard transcribe\`; never call \`qwen-tts\` or \`whisper-cli\` directly, and never run inference inside \`mount()\` or \`update()\`.
57
- - Do not overwrite or regenerate generated assets without explicit approval.
58
- - \`words: true\` requires measured word timings; never interpolate segment timings.
59
- - Commit \`frameyard.assets.json\` when present; do not edit hashes or manifest entries by hand.
60
-
61
118
  The official \`frameyard\` agent skill contains the full host-neutral contract. Install it with \`npx skills add vladiantio/frameyard\` or see the \`skills/frameyard/\` directory in the \`frameyard\` npm package. This file is the project-local summary.
62
119
 
63
120
  This project was scaffolded with frameyard ${VERSION}. \`npx frameyard\` resolves the latest published version; run \`npx frameyard@${VERSION}\` when you need the CLI version that scaffolded the project.
@@ -66,6 +123,8 @@ const PROJECT_README = `# My Frameyard project
66
123
 
67
124
  Coding agents should read \`AGENTS.md\` for the project rules and the check, still, and render workflow.
68
125
 
126
+ The composition is a plain HTML document at \`compositions/hello.html\`. Its metadata, default props, markup, CSS animation, and bindings live in that one file.
127
+
69
128
  Start the preview:
70
129
 
71
130
  \`\`\`sh
@@ -90,58 +149,87 @@ npm run still -- hello --frame 42 --out hello-42.png
90
149
  \`\`\`
91
150
  `;
92
151
  const SCAFFOLD_TEMPLATES = {
93
- "video.config.js": `export default {\n compositions: {\n hello: "./compositions/hello.js",\n },\n};\n`,
152
+ "video.config.js": `export default {\n compositions: {\n hello: "./compositions/hello.html",\n },\n};\n`,
94
153
  "props/hello.json": `${JSON.stringify({ title: "Hello, Frameyard", accent: "#b8f36b" }, null, 2)}\n`,
95
154
  "AGENTS.md": AGENTS_GUIDE,
96
155
  "README.md": PROJECT_README,
97
- "compositions/hello.js": `import { defineComposition } from "frameyard";
98
-
99
- let title;
100
- let sweep;
101
- let glow;
102
-
103
- export default defineComposition({
104
- width: 1280,
105
- height: 720,
106
- fps: 30,
107
- durationInFrames: 150,
108
- defaultProps: { title: "Hello, Frameyard", accent: "#b8f36b" },
109
- validateProps(props) {
110
- const errors = [];
111
- if (typeof props.title !== "string") errors.push("title must be a string");
112
- if (typeof props.accent !== "string") errors.push("accent must be a string");
113
- return errors;
114
- },
115
- mount({ container, props }) {
116
- container.style.setProperty("--accent", props.accent);
117
- container.innerHTML =
118
- '<style>' +
119
- '#hello { position: relative; width: 100%; height: 100%; display: grid; place-items: center; overflow: hidden; background: #0b0d10; color: #e8edf3; font-family: Inter, system-ui, sans-serif; }' +
120
- '#hello h1 { position: relative; margin: 0; font-size: 96px; letter-spacing: -0.04em; }' +
121
- '#hello p { position: relative; margin: 18px 0 0; color: #8a97a6; font-size: 26px; }' +
122
- '#glow { position: absolute; width: 520px; height: 520px; border-radius: 50%; background: var(--accent); filter: blur(120px); opacity: 0.16; }' +
123
- '#sweep { position: absolute; left: 0; bottom: 0; width: 0%; height: 4px; background: var(--accent); }' +
124
- '</style>' +
125
- '<div id="hello"><div id="glow"></div><div style="position: relative; text-align: center"><h1 id="title"></h1><p>Deterministic video, authored on the web.</p></div><div id="sweep"></div></div>';
126
- title = container.querySelector("#title");
127
- sweep = container.querySelector("#sweep");
128
- glow = container.querySelector("#glow");
129
- },
130
- update({ frame, props }) {
131
- title.textContent = props.title;
132
- const intro = Math.min(1, frame / 24);
133
- title.style.opacity = String(intro);
134
- title.style.transform = "translateY(" + (1 - intro) * 28 + "px)";
135
- glow.style.transform =
136
- "translate(" + Math.sin(frame / 26) * 120 + "px," + Math.cos(frame / 34) * 80 + "px)";
137
- sweep.style.width = (frame / 149) * 100 + "%";
138
- },
139
- dispose() {
140
- title = undefined;
141
- sweep = undefined;
142
- glow = undefined;
143
- },
144
- });
156
+ "compositions/hello.html": `<!doctype html>
157
+ <html>
158
+ <head>
159
+ <meta charset="utf-8">
160
+ <script type="application/json" data-fy="props">
161
+ { "title": "Hello, Frameyard", "accent": "#b8f36b" }
162
+ </script>
163
+ <style>
164
+ @keyframes fade-up {
165
+ from { opacity: 0; transform: translateY(28px); }
166
+ to { opacity: 1; transform: translateY(0); }
167
+ }
168
+ @keyframes drift {
169
+ from { transform: translate(-120px, -80px); }
170
+ to { transform: translate(120px, 80px); }
171
+ }
172
+ #hello {
173
+ position: relative;
174
+ width: 100%;
175
+ height: 100%;
176
+ display: grid;
177
+ place-items: center;
178
+ overflow: hidden;
179
+ background: #0b0d10;
180
+ color: #e8edf3;
181
+ font-family: Inter, system-ui, sans-serif;
182
+ }
183
+ #hello h1 {
184
+ position: relative;
185
+ margin: 0;
186
+ font-size: 96px;
187
+ letter-spacing: -0.04em;
188
+ opacity: 0;
189
+ animation: fade-up 1s cubic-bezier(0.215, 0.61, 0.355, 1) both;
190
+ }
191
+ #hello p {
192
+ position: relative;
193
+ margin: 18px 0 0;
194
+ color: #8a97a6;
195
+ font-size: 26px;
196
+ }
197
+ #glow {
198
+ position: absolute;
199
+ left: 50%;
200
+ top: 50%;
201
+ width: 520px;
202
+ height: 520px;
203
+ margin: -260px 0 0 -260px;
204
+ border-radius: 50%;
205
+ background: var(--fy-prop-accent);
206
+ filter: blur(120px);
207
+ opacity: 0.16;
208
+ animation: drift 12s ease-in-out infinite alternate;
209
+ }
210
+ #sweep {
211
+ position: absolute;
212
+ left: 0;
213
+ bottom: 0;
214
+ width: calc(var(--fy-progress) * 100%);
215
+ height: 4px;
216
+ background: var(--fy-prop-accent);
217
+ }
218
+ </style>
219
+ </head>
220
+ <body>
221
+ <fy-composition width="1280" height="720" fps="30" duration-in-frames="150">
222
+ <div id="hello">
223
+ <div id="glow"></div>
224
+ <div style="position: relative; text-align: center">
225
+ <h1 id="title" data-fy-text="title" data-fy-anim-start="0" data-fy-anim-duration="24"></h1>
226
+ <p>Deterministic video, authored on the web.</p>
227
+ </div>
228
+ <div id="sweep"></div>
229
+ </div>
230
+ </fy-composition>
231
+ </body>
232
+ </html>
145
233
  `,
146
234
  };
147
235
  // Keep the generated package name valid for npm and pnpm.
@@ -1 +1 @@
1
- {"version":3,"file":"scaffold.js","sourceRoot":"","sources":["../src/scaffold.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC,MAAM,YAAY,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;6CA6DwB,OAAO,kFAAkF,OAAO;CAC5I,CAAC;AAEF,MAAM,cAAc,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BtB,CAAC;AAEF,MAAM,kBAAkB,GAA2B;IACjD,iBAAiB,EAAE,wFAAwF;IAC3G,kBAAkB,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,kBAAkB,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI;IACpG,WAAW,EAAE,YAAY;IACzB,WAAW,EAAE,cAAc;IAC3B,uBAAuB,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgD1B;CACA,CAAC;AAEF,0DAA0D;AAC1D,SAAS,WAAW,CAAC,WAAmB;IACtC,MAAM,UAAU,GAAG,WAAW;SAC3B,WAAW,EAAE;SACb,OAAO,CAAC,gBAAgB,EAAE,GAAG,CAAC;SAC9B,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC;SAC/B,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC;SACb,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;IAC3B,OAAO,UAAU,KAAK,EAAE,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAC,UAAU,CAAC;AAC9D,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,WAAmB;IAC/C,OAAO;QACL,cAAc,EAAE,GAAG,IAAI,CAAC,SAAS,CAC/B;YACE,IAAI,EAAE,WAAW,CAAC,WAAW,CAAC;YAC9B,OAAO,EAAE,IAAI;YACb,IAAI,EAAE,QAAQ;YACd,OAAO,EAAE;gBACP,GAAG,EAAE,uBAAuB,OAAO,MAAM;gBACzC,KAAK,EAAE,uBAAuB,OAAO,QAAQ;gBAC7C,MAAM,EAAE,uBAAuB,OAAO,SAAS;gBAC/C,KAAK,EAAE,uBAAuB,OAAO,QAAQ;aAC9C;SACF,EACD,IAAI,EACJ,CAAC,CACF,IAAI;QACL,GAAG,kBAAkB;KACtB,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,sBAAsB;IACtB,mBAAmB;IACnB,2BAA2B;IAC3B,4CAA4C;CAC7C,CAAC"}
1
+ {"version":3,"file":"scaffold.js","sourceRoot":"","sources":["../src/scaffold.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC,MAAM,YAAY,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;6CAsHwB,OAAO,kFAAkF,OAAO;CAC5I,CAAC;AAEF,MAAM,cAAc,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BtB,CAAC;AAEF,MAAM,kBAAkB,GAA2B;IACjD,iBAAiB,EAAE,0FAA0F;IAC7G,kBAAkB,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,kBAAkB,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI;IACpG,WAAW,EAAE,YAAY;IACzB,WAAW,EAAE,cAAc;IAC3B,yBAAyB,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6E5B;CACA,CAAC;AAEF,0DAA0D;AAC1D,SAAS,WAAW,CAAC,WAAmB;IACtC,MAAM,UAAU,GAAG,WAAW;SAC3B,WAAW,EAAE;SACb,OAAO,CAAC,gBAAgB,EAAE,GAAG,CAAC;SAC9B,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC;SAC/B,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC;SACb,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;IAC3B,OAAO,UAAU,KAAK,EAAE,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAC,UAAU,CAAC;AAC9D,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,WAAmB;IAC/C,OAAO;QACL,cAAc,EAAE,GAAG,IAAI,CAAC,SAAS,CAC/B;YACE,IAAI,EAAE,WAAW,CAAC,WAAW,CAAC;YAC9B,OAAO,EAAE,IAAI;YACb,IAAI,EAAE,QAAQ;YACd,OAAO,EAAE;gBACP,GAAG,EAAE,uBAAuB,OAAO,MAAM;gBACzC,KAAK,EAAE,uBAAuB,OAAO,QAAQ;gBAC7C,MAAM,EAAE,uBAAuB,OAAO,SAAS;gBAC/C,KAAK,EAAE,uBAAuB,OAAO,QAAQ;aAC9C;SACF,EACD,IAAI,EACJ,CAAC,CACF,IAAI;QACL,GAAG,kBAAkB;KACtB,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,sBAAsB;IACtB,mBAAmB;IACnB,2BAA2B;IAC3B,4CAA4C;CAC7C,CAAC"}
@@ -0,0 +1,20 @@
1
+ export declare const FRAMEWORK_IMPORT_MAP: {
2
+ readonly frameyard: "/__frameyard/framework/index.js";
3
+ readonly "frameyard/runtime": "/__frameyard/framework/runtime/index.js";
4
+ };
5
+ export declare const COMPOSITION_HOST_SCRIPT_URL = "/__frameyard/framework/runtime/host.js";
6
+ export declare const COMPOSITION_HOST_STYLE = "html, body { margin: 0; width: 100%; height: 100%; } fy-composition { display: block; width: 100%; height: 100%; } fy-composition[data-fy-pending] { visibility: hidden !important; }";
7
+ export interface CompositionHostInjectionOptions {
8
+ html: string;
9
+ importMap?: Record<string, string>;
10
+ hostScriptUrl?: string;
11
+ styleText?: string;
12
+ }
13
+ /**
14
+ * Injects the framework import map, sizing style, and composition host into a
15
+ * composition document. The host script is a deferred module, so it runs after
16
+ * the document is parsed; author executable scripts are rejected by static
17
+ * validation before this function is called.
18
+ */
19
+ export declare function injectCompositionHost(options: CompositionHostInjectionOptions): string;
20
+ //# sourceMappingURL=composition-html.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"composition-html.d.ts","sourceRoot":"","sources":["../../src/server/composition-html.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,oBAAoB;;;CAGvB,CAAC;AAEX,eAAO,MAAM,2BAA2B,2CAA2C,CAAC;AAEpF,eAAO,MAAM,sBAAsB,0LACsJ,CAAC;AAE1L,MAAM,WAAW,+BAA+B;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,+BAA+B,GAAG,MAAM,CA4BtF"}
@@ -0,0 +1,45 @@
1
+ import { defaultTreeAdapter, html, parse, serialize } from "parse5";
2
+ import { getElementByTagName } from "../html/parse.js";
3
+ export const FRAMEWORK_IMPORT_MAP = {
4
+ frameyard: "/__frameyard/framework/index.js",
5
+ "frameyard/runtime": "/__frameyard/framework/runtime/index.js",
6
+ };
7
+ export const COMPOSITION_HOST_SCRIPT_URL = "/__frameyard/framework/runtime/host.js";
8
+ export const COMPOSITION_HOST_STYLE = "html, body { margin: 0; width: 100%; height: 100%; } fy-composition { display: block; width: 100%; height: 100%; } fy-composition[data-fy-pending] { visibility: hidden !important; }";
9
+ /**
10
+ * Injects the framework import map, sizing style, and composition host into a
11
+ * composition document. The host script is a deferred module, so it runs after
12
+ * the document is parsed; author executable scripts are rejected by static
13
+ * validation before this function is called.
14
+ */
15
+ export function injectCompositionHost(options) {
16
+ const document = parse(options.html);
17
+ const head = getElementByTagName(document, "head");
18
+ if (!head) {
19
+ throw new Error("Composition document has no <head> element.");
20
+ }
21
+ const importMap = createElement("script", [
22
+ { name: "type", value: "importmap" },
23
+ { name: "data-fy-host", value: "" },
24
+ ]);
25
+ appendText(importMap, JSON.stringify({ imports: options.importMap ?? FRAMEWORK_IMPORT_MAP }));
26
+ const style = createElement("style", [{ name: "data-fy-host-style", value: "" }]);
27
+ appendText(style, options.styleText ?? COMPOSITION_HOST_STYLE);
28
+ const host = createElement("script", [
29
+ { name: "type", value: "module" },
30
+ { name: "src", value: options.hostScriptUrl ?? COMPOSITION_HOST_SCRIPT_URL },
31
+ { name: "data-fy-host", value: "" },
32
+ ]);
33
+ head.childNodes.unshift(importMap, style, host);
34
+ for (const child of [importMap, style, host]) {
35
+ child.parentNode = head;
36
+ }
37
+ return serialize(document);
38
+ }
39
+ function createElement(tagName, attrs) {
40
+ return defaultTreeAdapter.createElement(tagName, html.NS.HTML, attrs);
41
+ }
42
+ function appendText(element, value) {
43
+ defaultTreeAdapter.appendChild(element, defaultTreeAdapter.createTextNode(value));
44
+ }
45
+ //# sourceMappingURL=composition-html.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"composition-html.js","sourceRoot":"","sources":["../../src/server/composition-html.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AACpE,OAAO,EAAE,mBAAmB,EAAoB,MAAM,kBAAkB,CAAC;AAEzE,MAAM,CAAC,MAAM,oBAAoB,GAAG;IAClC,SAAS,EAAE,iCAAiC;IAC5C,mBAAmB,EAAE,yCAAyC;CACtD,CAAC;AAEX,MAAM,CAAC,MAAM,2BAA2B,GAAG,wCAAwC,CAAC;AAEpF,MAAM,CAAC,MAAM,sBAAsB,GACjC,uLAAuL,CAAC;AAS1L;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CAAC,OAAwC;IAC5E,MAAM,QAAQ,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,MAAM,IAAI,GAAG,mBAAmB,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IACnD,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,MAAM,IAAI,KAAK,CAAC,6CAA6C,CAAC,CAAC;IACjE,CAAC;IAED,MAAM,SAAS,GAAG,aAAa,CAAC,QAAQ,EAAE;QACxC,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE;QACpC,EAAE,IAAI,EAAE,cAAc,EAAE,KAAK,EAAE,EAAE,EAAE;KACpC,CAAC,CAAC;IACH,UAAU,CAAC,SAAS,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,SAAS,IAAI,oBAAoB,EAAE,CAAC,CAAC,CAAC;IAE9F,MAAM,KAAK,GAAG,aAAa,CAAC,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,oBAAoB,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC;IAClF,UAAU,CAAC,KAAK,EAAE,OAAO,CAAC,SAAS,IAAI,sBAAsB,CAAC,CAAC;IAE/D,MAAM,IAAI,GAAG,aAAa,CAAC,QAAQ,EAAE;QACnC,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE;QACjC,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,CAAC,aAAa,IAAI,2BAA2B,EAAE;QAC5E,EAAE,IAAI,EAAE,cAAc,EAAE,KAAK,EAAE,EAAE,EAAE;KACpC,CAAC,CAAC;IAEH,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,SAAS,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC;IAChD,KAAK,MAAM,KAAK,IAAI,CAAC,SAAS,EAAE,KAAK,EAAE,IAAI,CAAC,EAAE,CAAC;QAC7C,KAAK,CAAC,UAAU,GAAG,IAAI,CAAC;IAC1B,CAAC;IAED,OAAO,SAAS,CAAC,QAAQ,CAAC,CAAC;AAC7B,CAAC;AAED,SAAS,aAAa,CAAC,OAAe,EAAE,KAA6C;IACnF,OAAO,kBAAkB,CAAC,aAAa,CAAC,OAAO,EAAE,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AACxE,CAAC;AAED,SAAS,UAAU,CAAC,OAAoB,EAAE,KAAa;IACrD,kBAAkB,CAAC,WAAW,CAAC,OAAO,EAAE,kBAAkB,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC;AACpF,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"preview-html.d.ts","sourceRoot":"","sources":["../../src/server/preview-html.ts"],"names":[],"mappings":"AAWA,wBAAgB,iBAAiB,IAAI,MAAM,CA6N1C"}
1
+ {"version":3,"file":"preview-html.d.ts","sourceRoot":"","sources":["../../src/server/preview-html.ts"],"names":[],"mappings":"AAWA,wBAAgB,iBAAiB,IAAI,MAAM,CAkO1C"}
@@ -148,7 +148,21 @@ export function renderPreviewHtml() {
148
148
  repeating-conic-gradient(#0d1014 0% 25%, #0b0d10 0% 50%) 50% / 24px 24px;
149
149
  }
150
150
  .stage-wrap { position: relative; flex: 0 0 auto; }
151
- .stage { transform-origin: top left; overflow: hidden; background: #000; }
151
+ .stage-frame { position: absolute; top: 0; left: 0; border: 0; transform-origin: top left; background: #000; }
152
+ .diagnostics {
153
+ border: 1px solid var(--line);
154
+ background: var(--panel-2);
155
+ border-radius: 7px;
156
+ padding: 8px 10px;
157
+ max-height: 26vh;
158
+ overflow: auto;
159
+ font-family: var(--mono);
160
+ font-size: 11px;
161
+ line-height: 1.5;
162
+ }
163
+ .diagnostics .diagnostic { display: block; white-space: pre-wrap; }
164
+ .diagnostics .warning { color: #e8c76a; }
165
+ .diagnostics .error { color: var(--danger); }
152
166
  .transport {
153
167
  grid-row: 3;
154
168
  grid-column: 2;
@@ -170,18 +184,6 @@ export function renderPreviewHtml() {
170
184
  .audio { display: flex; align-items: center; gap: 7px; color: var(--muted); font-family: var(--mono); font-size: 11px; }
171
185
  .audio input[type="range"] { width: 72px; accent-color: var(--accent); }
172
186
 
173
- body.render #app { display: block; height: auto; }
174
- body.render .topbar, body.render .panel, body.render .transport { display: none; }
175
- body.render .viewport {
176
- position: fixed;
177
- inset: 0;
178
- display: block;
179
- padding: 0;
180
- overflow: hidden;
181
- background: #000;
182
- }
183
- body.render .stage-wrap { position: absolute; inset: 0; width: auto !important; height: auto !important; }
184
- body.render .stage { transform: none !important; }
185
187
  </style>
186
188
  </head>
187
189
  <body>
@@ -201,13 +203,16 @@ export function renderPreviewHtml() {
201
203
  </div>
202
204
  <button id="apply" class="primary" type="button">Apply props</button>
203
205
  <div id="error" class="error" role="alert" hidden></div>
206
+ <div id="diagnostics" class="diagnostics" hidden></div>
204
207
  <div class="hint">
205
208
  Frames are derived from <code>frame</code> and <code>props</code> only.
206
209
  Wall-clock animation and unseeded randomness are not deterministic.
207
210
  </div>
208
211
  </aside>
209
212
  <main id="viewport" class="viewport">
210
- <div id="stage-wrap" class="stage-wrap"><div id="stage" class="stage"></div></div>
213
+ <div id="stage-wrap" class="stage-wrap">
214
+ <iframe id="stage-frame" class="stage-frame" title="Composition" hidden></iframe>
215
+ </div>
211
216
  </main>
212
217
  <footer class="transport">
213
218
  <button id="play" class="play" type="button" aria-label="Play">&#9654;</button>
@@ -1 +1 @@
1
- {"version":3,"file":"preview-html.js","sourceRoot":"","sources":["../../src/server/preview-html.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAC/B;IACE,OAAO,EAAE;QACP,SAAS,EAAE,iCAAiC;QAC5C,mBAAmB,EAAE,yCAAyC;KAC/D;CACF,EACD,IAAI,EACJ,CAAC,CACF,CAAC;AAEF,MAAM,UAAU,iBAAiB;IAC/B,OAAO;;;;;;2BAMkB,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAqN7B,CAAC;AACT,CAAC"}
1
+ {"version":3,"file":"preview-html.js","sourceRoot":"","sources":["../../src/server/preview-html.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAC/B;IACE,OAAO,EAAE;QACP,SAAS,EAAE,iCAAiC;QAC5C,mBAAmB,EAAE,yCAAyC;KAC/D;CACF,EACD,IAAI,EACJ,CAAC,CACF,CAAC;AAEF,MAAM,UAAU,iBAAiB;IAC/B,OAAO;;;;;;2BAMkB,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QA0N7B,CAAC;AACT,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/server/server.ts"],"names":[],"mappings":"AAMA,OAAO,EAAe,KAAK,OAAO,EAAE,MAAM,eAAe,CAAC;AAG1D,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0EAA0E;IAC1E,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA+JD,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,aAAa,CAAC,CAmG9F"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/server/server.ts"],"names":[],"mappings":"AAQA,OAAO,EAAe,KAAK,OAAO,EAA2B,MAAM,eAAe,CAAC;AAInF,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0EAA0E;IAC1E,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAyOD,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,aAAa,CAAC,CA+H9F"}
@@ -1,10 +1,12 @@
1
1
  import { createReadStream } from "node:fs";
2
- import { stat } from "node:fs/promises";
2
+ import { readFile, stat } from "node:fs/promises";
3
3
  import { createServer } from "node:http";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { FrameyardError } from "../errors.js";
7
+ import { validateHtmlComposition } from "../html/validate.js";
7
8
  import { loadProject } from "../project.js";
9
+ import { injectCompositionHost } from "./composition-html.js";
8
10
  import { renderPreviewHtml } from "./preview-html.js";
9
11
  const MIME_TYPES = {
10
12
  ".avif": "image/avif",
@@ -144,14 +146,76 @@ async function serveFile(request, response, base, relativePath) {
144
146
  .on("error", () => response.destroy())
145
147
  .pipe(response);
146
148
  }
149
+ function sendHtml(response, status, body, headOnly = false) {
150
+ response.writeHead(status, {
151
+ "Content-Type": "text/html; charset=utf-8",
152
+ "Content-Length": Buffer.byteLength(body),
153
+ "Cache-Control": "no-store",
154
+ });
155
+ response.end(headOnly ? undefined : body);
156
+ }
157
+ function sendJavaScript(response, status, body, headOnly = false) {
158
+ response.writeHead(status, {
159
+ "Content-Type": "text/javascript; charset=utf-8",
160
+ "Content-Length": Buffer.byteLength(body),
161
+ "Cache-Control": "no-store",
162
+ });
163
+ response.end(headOnly ? undefined : body);
164
+ }
165
+ function escapeHtml(value) {
166
+ return value.replace(/[&<>"']/g, (character) => {
167
+ switch (character) {
168
+ case "&":
169
+ return "&amp;";
170
+ case "<":
171
+ return "&lt;";
172
+ case ">":
173
+ return "&gt;";
174
+ case '"':
175
+ return "&quot;";
176
+ default:
177
+ return "&#39;";
178
+ }
179
+ });
180
+ }
181
+ function renderCompositionError(entry, diagnostics) {
182
+ const items = diagnostics
183
+ .map((diagnostic) => {
184
+ const location = diagnostic.details?.line ? ` at line ${diagnostic.details.line}` : "";
185
+ return `<li><code>${escapeHtml(diagnostic.code)}</code> ${escapeHtml(diagnostic.message)}${escapeHtml(location)}</li>`;
186
+ })
187
+ .join("");
188
+ return `<!doctype html><html><head><meta charset="utf-8"><title>Frameyard composition error</title></head><body><h1>Composition error</h1><p>${escapeHtml(entry)}</p><ul>${items}</ul></body></html>`;
189
+ }
190
+ function publicController(controller) {
191
+ if (!controller) {
192
+ return null;
193
+ }
194
+ return controller.kind === "inline" ? { kind: "inline" } : { kind: "external", src: controller.src ?? "" };
195
+ }
196
+ async function buildProjectJson(project) {
197
+ const compositions = [];
198
+ for (const composition of project.compositions.values()) {
199
+ const result = await validateHtmlComposition({ projectRoot: project.root, entry: composition.entry });
200
+ compositions.push({
201
+ id: composition.id,
202
+ entry: composition.entry,
203
+ kind: "html",
204
+ metadata: result.info?.metadata ?? null,
205
+ controller: publicController(result.info?.controller ?? null),
206
+ diagnostics: result.diagnostics,
207
+ });
208
+ }
209
+ return { compositions };
210
+ }
211
+ function findCompositionForPath(project, pathname) {
212
+ return [...project.compositions.values()].find((composition) => `/${composition.entry}` === pathname);
213
+ }
147
214
  export async function startPreviewServer(options) {
148
215
  const project = await loadProject(options.projectRoot);
149
216
  const frameworkDir = options.frameworkDir ?? path.join(fileURLToPath(new URL("../../", import.meta.url)), "dist");
150
217
  const host = options.host ?? "127.0.0.1";
151
218
  const html = renderPreviewHtml();
152
- const projectJson = {
153
- compositions: Array.from(project.compositions.values(), ({ id, entry }) => ({ id, entry })),
154
- };
155
219
  const server = createServer((request, response) => {
156
220
  void (async () => {
157
221
  if (request.method !== "GET" && request.method !== "HEAD") {
@@ -159,9 +223,11 @@ export async function startPreviewServer(options) {
159
223
  response.end();
160
224
  return;
161
225
  }
226
+ let url;
162
227
  let pathname;
163
228
  try {
164
- pathname = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
229
+ url = new URL(request.url ?? "/", "http://localhost");
230
+ pathname = decodeURIComponent(url.pathname);
165
231
  }
166
232
  catch {
167
233
  sendJson(response, 400, { error: "Invalid URL" });
@@ -187,13 +253,35 @@ export async function startPreviewServer(options) {
187
253
  return;
188
254
  }
189
255
  if (pathname === "/__frameyard/project.json") {
190
- sendJson(response, 200, projectJson);
256
+ sendJson(response, 200, await buildProjectJson(project));
191
257
  return;
192
258
  }
193
259
  if (pathname.startsWith("/__frameyard/framework/")) {
194
260
  await serveFile(request, response, frameworkDir, pathname.slice("/__frameyard/framework/".length));
195
261
  return;
196
262
  }
263
+ const composition = findCompositionForPath(project, pathname);
264
+ if (composition) {
265
+ const result = await validateHtmlComposition({ projectRoot: project.root, entry: composition.entry });
266
+ if (url.searchParams.has("__fy-controller")) {
267
+ const controller = result.info?.controller;
268
+ if (controller?.kind === "inline" && controller.source !== undefined) {
269
+ sendJavaScript(response, 200, controller.source, request.method === "HEAD");
270
+ }
271
+ else {
272
+ sendJson(response, 404, { error: "Composition has no inline controller." });
273
+ }
274
+ return;
275
+ }
276
+ const errors = result.diagnostics.filter((diagnostic) => diagnostic.severity === "error");
277
+ if (errors.length > 0) {
278
+ sendHtml(response, 500, renderCompositionError(composition.entry, result.diagnostics), request.method === "HEAD");
279
+ return;
280
+ }
281
+ const source = await readFile(composition.absoluteEntry, "utf8");
282
+ sendHtml(response, 200, injectCompositionHost({ html: source }), request.method === "HEAD");
283
+ return;
284
+ }
197
285
  await serveFile(request, response, project.root, pathname.slice(1));
198
286
  })().catch((error) => {
199
287
  if (!response.headersSent) {