frameyard 0.2.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 (112) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/GLOSSARY.md +58 -2
  3. package/README.md +185 -95
  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 +37 -14
  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 +12 -3
  35. package/dist/preview/main.d.ts.map +1 -1
  36. package/dist/preview/main.js +208 -85
  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/audio.d.ts +5 -1
  43. package/dist/renderer/audio.d.ts.map +1 -1
  44. package/dist/renderer/audio.js +4 -0
  45. package/dist/renderer/audio.js.map +1 -1
  46. package/dist/renderer/browser.d.ts +12 -1
  47. package/dist/renderer/browser.d.ts.map +1 -1
  48. package/dist/renderer/browser.js +69 -11
  49. package/dist/renderer/browser.js.map +1 -1
  50. package/dist/renderer/index.d.ts +30 -5
  51. package/dist/renderer/index.d.ts.map +1 -1
  52. package/dist/renderer/index.js +204 -26
  53. package/dist/renderer/index.js.map +1 -1
  54. package/dist/runtime/bindings.d.ts +11 -0
  55. package/dist/runtime/bindings.d.ts.map +1 -0
  56. package/dist/runtime/bindings.js +164 -0
  57. package/dist/runtime/bindings.js.map +1 -0
  58. package/dist/runtime/diagnostics.d.ts +4 -0
  59. package/dist/runtime/diagnostics.d.ts.map +1 -0
  60. package/dist/runtime/diagnostics.js +107 -0
  61. package/dist/runtime/diagnostics.js.map +1 -0
  62. package/dist/runtime/host.d.ts +12 -0
  63. package/dist/runtime/host.d.ts.map +1 -0
  64. package/dist/runtime/host.js +701 -0
  65. package/dist/runtime/host.js.map +1 -0
  66. package/dist/runtime/index.d.ts +0 -26
  67. package/dist/runtime/index.d.ts.map +1 -1
  68. package/dist/runtime/index.js +0 -286
  69. package/dist/runtime/index.js.map +1 -1
  70. package/dist/runtime/media.d.ts +16 -0
  71. package/dist/runtime/media.d.ts.map +1 -0
  72. package/dist/runtime/media.js +307 -0
  73. package/dist/runtime/media.js.map +1 -0
  74. package/dist/scaffold.d.ts.map +1 -1
  75. package/dist/scaffold.js +169 -78
  76. package/dist/scaffold.js.map +1 -1
  77. package/dist/server/composition-html.d.ts +20 -0
  78. package/dist/server/composition-html.d.ts.map +1 -0
  79. package/dist/server/composition-html.js +45 -0
  80. package/dist/server/composition-html.js.map +1 -0
  81. package/dist/server/preview-html.d.ts.map +1 -1
  82. package/dist/server/preview-html.js +19 -14
  83. package/dist/server/preview-html.js.map +1 -1
  84. package/dist/server/server.d.ts.map +1 -1
  85. package/dist/server/server.js +94 -6
  86. package/dist/server/server.js.map +1 -1
  87. package/dist/version.d.ts +1 -1
  88. package/dist/version.js +1 -1
  89. package/docs/adr/0001-keep-composition-authoring-web-native.md +2 -0
  90. package/docs/adr/0003-render-browser-frames-offline.md +2 -0
  91. package/docs/adr/0005-keep-parallel-rendering-opt-in-and-frame-ordered.md +3 -0
  92. package/docs/adr/0006-seek-embedded-media-from-the-frame-clock.md +11 -0
  93. package/docs/adr/0007-make-html-documents-the-composition-unit.md +31 -0
  94. package/docs/migration-0.4.md +279 -0
  95. package/docs/v0.1-design.md +82 -57
  96. package/examples/demo/README.md +7 -3
  97. package/examples/demo/compositions/demo.controller.js +120 -0
  98. package/examples/demo/compositions/demo.html +253 -0
  99. package/examples/demo/video.config.js +1 -1
  100. package/package.json +9 -3
  101. package/skills/frameyard/README.md +4 -0
  102. package/skills/frameyard/SKILL.md +41 -11
  103. package/skills/frameyard/references/audio-assets.md +25 -14
  104. package/skills/frameyard/references/bindings.md +73 -0
  105. package/skills/frameyard/references/common-mistakes.md +59 -0
  106. package/skills/frameyard/references/controllers.md +126 -0
  107. package/skills/frameyard/references/deterministic-authoring.md +160 -41
  108. package/skills/frameyard/references/diagnostics.md +90 -0
  109. package/skills/frameyard/references/failure-recovery.md +38 -12
  110. package/skills/frameyard/references/json-results.md +43 -8
  111. package/skills/frameyard/references/subtitles-karaoke.md +10 -18
  112. package/examples/demo/compositions/demo.js +0 -206
@@ -0,0 +1,931 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { assertJsonSerializable, isPlainObject, mergeProps, normalizeAudioClip, normalizeTranscriptClip, } from "../contracts.js";
4
+ import { resolveBindingPath, suggestBindingPath, validateBindingPath } from "../bindings.js";
5
+ import { errorMessage, isFrameyardError } from "../errors.js";
6
+ import { parseTranscript } from "../transcript.js";
7
+ import { findElements, getAttribute, getAttributeLocation, getDocumentDoctype, getElementByTagName, getElementLocation, getNodeLocation, getTextContent, hasAttribute, isDescendantOf, isElement, isTemplate, isTextNode, parseHtml, walk, } from "./parse.js";
8
+ const SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
9
+ const VOID_ELEMENTS = new Set([
10
+ "area",
11
+ "base",
12
+ "br",
13
+ "col",
14
+ "embed",
15
+ "hr",
16
+ "img",
17
+ "input",
18
+ "link",
19
+ "meta",
20
+ "param",
21
+ "source",
22
+ "track",
23
+ "wbr",
24
+ ]);
25
+ const NON_VISUAL_ELEMENTS = new Set(["script", "style", "link", "meta", "template", "noscript", "title"]);
26
+ const NON_ANIMATED_ELEMENTS = new Set(["audio", "fy-transcript", "script", "style", "link", "meta", "template"]);
27
+ const MEDIA_ATTRIBUTES = new Set([
28
+ "data-fy-start",
29
+ "data-fy-trim",
30
+ "data-fy-duration",
31
+ "data-fy-volume",
32
+ "data-fy-audio",
33
+ ]);
34
+ const KNOWN_BINDING_ATTRIBUTES = [
35
+ "data-fy",
36
+ "data-fy-text",
37
+ "data-fy-if",
38
+ "data-fy-repeat",
39
+ "data-fy-anim-start",
40
+ "data-fy-anim-duration",
41
+ "data-fy-anim-easing",
42
+ "data-fy-start",
43
+ "data-fy-trim",
44
+ "data-fy-duration",
45
+ "data-fy-volume",
46
+ "data-fy-audio",
47
+ ];
48
+ class HtmlCompositionValidator {
49
+ diagnostics = [];
50
+ projectRoot;
51
+ entry;
52
+ compositionDir;
53
+ document;
54
+ propsOverrides;
55
+ defaultPropsValue = Object.freeze({});
56
+ props;
57
+ metadata = null;
58
+ controller = null;
59
+ documentValid = true;
60
+ defaultsValid = true;
61
+ controllerValid = true;
62
+ rootValid = false;
63
+ root = null;
64
+ audioClips = [];
65
+ embeddedAudioClips = [];
66
+ transcriptClips = [];
67
+ constructor(options, document) {
68
+ this.projectRoot = path.resolve(options.projectRoot);
69
+ this.entry = options.entry.replaceAll("\\", "/");
70
+ this.compositionDir = path.dirname(path.resolve(this.projectRoot, this.entry));
71
+ this.document = document;
72
+ this.propsOverrides = options.props;
73
+ this.props = this.defaultPropsValue;
74
+ }
75
+ async run() {
76
+ this.validateDoctype();
77
+ this.findRoot();
78
+ this.validateMetadata();
79
+ await this.validateDefaultProps();
80
+ this.validateControllerDeclaration();
81
+ await this.validateControllerFile();
82
+ this.validateScripts();
83
+ this.validateUnsupportedElements();
84
+ this.validateDocumentAttributes();
85
+ this.validateOutsideRoot();
86
+ if (this.root) {
87
+ this.validateBindings(this.root, 0);
88
+ }
89
+ await this.validateMedia();
90
+ await this.validateTranscripts();
91
+ await this.validateOrdinaryAssets();
92
+ const info = this.metadata && this.root && this.rootValid && this.documentValid && this.defaultsValid && this.controllerValid
93
+ ? {
94
+ metadata: this.metadata,
95
+ defaultProps: this.defaultPropsValue,
96
+ controller: this.controller,
97
+ audioClips: Object.freeze([...this.audioClips]),
98
+ embeddedAudioClips: Object.freeze([...this.embeddedAudioClips]),
99
+ transcriptClips: Object.freeze([...this.transcriptClips]),
100
+ }
101
+ : null;
102
+ return { info, diagnostics: this.finishDiagnostics() };
103
+ }
104
+ report(severity, code, message, details) {
105
+ const diagnostic = {
106
+ severity,
107
+ code,
108
+ message,
109
+ };
110
+ const merged = { path: this.entry, ...details };
111
+ if (Object.keys(merged).length > 0) {
112
+ diagnostic.details = merged;
113
+ }
114
+ this.diagnostics.push(diagnostic);
115
+ }
116
+ error(code, message, details) {
117
+ this.report("error", code, message, details);
118
+ }
119
+ warning(code, message, details) {
120
+ this.report("warning", code, message, details);
121
+ }
122
+ attributeDetails(element, attribute, extra = {}) {
123
+ const location = getAttributeLocation(element, attribute) ?? getElementLocation(element);
124
+ return {
125
+ element: element.tagName,
126
+ attribute,
127
+ ...this.locationDetails(location),
128
+ ...extra,
129
+ };
130
+ }
131
+ elementDetails(element, extra = {}) {
132
+ return {
133
+ element: element.tagName,
134
+ ...this.locationDetails(getElementLocation(element)),
135
+ ...extra,
136
+ };
137
+ }
138
+ nodeDetails(node, extra = {}) {
139
+ return {
140
+ ...this.locationDetails(getNodeLocation(node)),
141
+ ...extra,
142
+ };
143
+ }
144
+ locationDetails(location) {
145
+ if (!location) {
146
+ return {};
147
+ }
148
+ return {
149
+ line: location.startLine,
150
+ column: location.startCol,
151
+ endLine: location.endLine,
152
+ endColumn: location.endCol,
153
+ };
154
+ }
155
+ finishDiagnostics() {
156
+ const seen = new Set();
157
+ const unique = this.diagnostics.filter((diagnostic) => {
158
+ const key = JSON.stringify([
159
+ diagnostic.severity,
160
+ diagnostic.code,
161
+ diagnostic.message,
162
+ diagnostic.details ?? null,
163
+ ]);
164
+ if (seen.has(key)) {
165
+ return false;
166
+ }
167
+ seen.add(key);
168
+ return true;
169
+ });
170
+ return unique.sort((left, right) => {
171
+ const leftLine = left.details?.line ?? Number.MAX_SAFE_INTEGER;
172
+ const rightLine = right.details?.line ?? Number.MAX_SAFE_INTEGER;
173
+ if (leftLine !== rightLine) {
174
+ return leftLine - rightLine;
175
+ }
176
+ const leftColumn = left.details?.column ?? 0;
177
+ const rightColumn = right.details?.column ?? 0;
178
+ return leftColumn - rightColumn;
179
+ });
180
+ }
181
+ validateDoctype() {
182
+ const doctype = getDocumentDoctype(this.document);
183
+ if (!doctype) {
184
+ this.documentValid = false;
185
+ this.error("COMPOSITION_INVALID", "Composition documents must start with <!doctype html>.", {
186
+ line: 1,
187
+ column: 1,
188
+ });
189
+ return;
190
+ }
191
+ if (doctype.name.toLowerCase() !== "html") {
192
+ this.documentValid = false;
193
+ this.error("COMPOSITION_INVALID", "Composition documents must use the HTML doctype.", {
194
+ ...this.locationDetails(doctype.sourceCodeLocation ?? undefined),
195
+ });
196
+ }
197
+ }
198
+ findRoot() {
199
+ const roots = findElements(this.document, (element) => element.tagName === "fy-composition");
200
+ if (roots.length === 0) {
201
+ this.error("COMPOSITION_INVALID", "Composition document must contain one <fy-composition> root.");
202
+ return;
203
+ }
204
+ for (const extra of roots.slice(1)) {
205
+ this.error("COMPOSITION_INVALID", "Composition document must contain exactly one <fy-composition> root.", this.elementDetails(extra));
206
+ }
207
+ const root = roots[0];
208
+ this.root = root;
209
+ let valid = roots.length === 1;
210
+ const body = getElementByTagName(this.document, "body");
211
+ if (!body || !isDescendantOf(root, body)) {
212
+ valid = false;
213
+ this.error("COMPOSITION_INVALID", "The <fy-composition> root must be inside <body>.", this.elementDetails(root));
214
+ }
215
+ if (this.isInsideTemplate(root)) {
216
+ valid = false;
217
+ this.error("COMPOSITION_INVALID", "The <fy-composition> root must not be inside a <template>.", this.elementDetails(root));
218
+ }
219
+ this.rootValid = valid;
220
+ }
221
+ isInsideTemplate(node) {
222
+ let current = "parentNode" in node ? node.parentNode : null;
223
+ while (current) {
224
+ if (isElement(current) && current.tagName === "template") {
225
+ return true;
226
+ }
227
+ current = "parentNode" in current ? current.parentNode : null;
228
+ }
229
+ return false;
230
+ }
231
+ validateMetadata() {
232
+ const root = this.root;
233
+ if (!root) {
234
+ return;
235
+ }
236
+ const width = this.readPositiveInteger(root, "width");
237
+ const height = this.readPositiveInteger(root, "height");
238
+ const fps = this.readPositiveNumber(root, "fps");
239
+ const durationInFrames = this.readPositiveInteger(root, "duration-in-frames");
240
+ if (width !== null && height !== null && fps !== null && durationInFrames !== null) {
241
+ this.metadata = { width, height, fps, durationInFrames };
242
+ }
243
+ }
244
+ readPositiveInteger(element, attribute) {
245
+ const raw = getAttribute(element, attribute);
246
+ if (raw === undefined) {
247
+ this.error("COMPOSITION_INVALID", `Missing required "${attribute}" attribute on <fy-composition>.`, this.attributeDetails(element, attribute));
248
+ return null;
249
+ }
250
+ const value = Number(raw);
251
+ if (!/^\d+$/.test(raw.trim()) || !Number.isInteger(value) || value <= 0) {
252
+ this.error("COMPOSITION_INVALID", `"${attribute}" must be a positive integer; received "${raw}".`, this.attributeDetails(element, attribute, { value: raw }));
253
+ return null;
254
+ }
255
+ return value;
256
+ }
257
+ readPositiveNumber(element, attribute) {
258
+ const raw = getAttribute(element, attribute);
259
+ if (raw === undefined) {
260
+ this.error("COMPOSITION_INVALID", `Missing required "${attribute}" attribute on <fy-composition>.`, this.attributeDetails(element, attribute));
261
+ return null;
262
+ }
263
+ const value = Number(raw);
264
+ if (!Number.isFinite(value) || value <= 0) {
265
+ this.error("COMPOSITION_INVALID", `"${attribute}" must be a positive number; received "${raw}".`, this.attributeDetails(element, attribute, { value: raw }));
266
+ return null;
267
+ }
268
+ return value;
269
+ }
270
+ async validateDefaultProps() {
271
+ const blocks = findElements(this.document, (element) => element.tagName === "script" &&
272
+ getAttribute(element, "type")?.toLowerCase() === "application/json" &&
273
+ getAttribute(element, "data-fy") === "props");
274
+ if (blocks.length > 1) {
275
+ this.defaultsValid = false;
276
+ for (const extra of blocks.slice(1)) {
277
+ this.error("PROPS_INVALID", 'A composition may contain at most one data-fy="props" block.', this.elementDetails(extra));
278
+ }
279
+ }
280
+ const block = blocks[0];
281
+ let defaults = Object.freeze({});
282
+ if (block) {
283
+ if (this.isInsideTemplate(block)) {
284
+ this.defaultsValid = false;
285
+ this.error("PROPS_INVALID", 'The data-fy="props" block must not be inside a <template>.', this.elementDetails(block));
286
+ }
287
+ try {
288
+ const parsed = JSON.parse(getTextContent(block));
289
+ if (!isPlainObject(parsed)) {
290
+ throw new Error('The data-fy="props" block must contain a JSON object.');
291
+ }
292
+ assertJsonSerializable(parsed, "defaultProps");
293
+ defaults = mergeProps(parsed, undefined);
294
+ }
295
+ catch (error) {
296
+ this.defaultsValid = false;
297
+ this.error("PROPS_INVALID", `Invalid default props: ${errorMessage(error)}`, this.elementDetails(block));
298
+ }
299
+ }
300
+ try {
301
+ this.props = mergeProps(defaults, this.propsOverrides);
302
+ }
303
+ catch (error) {
304
+ this.defaultsValid = false;
305
+ this.error("PROPS_INVALID", `Invalid props: ${errorMessage(error)}`);
306
+ this.props = defaults;
307
+ }
308
+ if (this.defaultsValid) {
309
+ this.defaultPropsValue = defaults;
310
+ }
311
+ }
312
+ validateControllerDeclaration() {
313
+ const blocks = findElements(this.document, (element) => element.tagName === "script" && getAttribute(element, "type")?.toLowerCase() === "text/fy-controller");
314
+ if (blocks.length > 1) {
315
+ this.controllerValid = false;
316
+ for (const extra of blocks.slice(1)) {
317
+ this.error("CONTROLLER_INVALID", "A composition may declare at most one controller.", this.elementDetails(extra));
318
+ }
319
+ }
320
+ const block = blocks[0];
321
+ if (!block) {
322
+ return;
323
+ }
324
+ if (this.isInsideTemplate(block)) {
325
+ this.controllerValid = false;
326
+ this.error("CONTROLLER_INVALID", "The controller block must not be inside a <template>.", this.elementDetails(block));
327
+ }
328
+ const src = getAttribute(block, "src");
329
+ const source = getTextContent(block);
330
+ const hasSource = source.trim().length > 0;
331
+ if (src !== undefined && hasSource) {
332
+ this.controllerValid = false;
333
+ this.error("CONTROLLER_INVALID", "A controller must be either inline or external, not both.", this.attributeDetails(block, "src"));
334
+ return;
335
+ }
336
+ if (src === undefined && !hasSource) {
337
+ this.controllerValid = false;
338
+ this.error("CONTROLLER_INVALID", "A controller must contain inline source or reference an external module with src.", this.elementDetails(block));
339
+ return;
340
+ }
341
+ if (src !== undefined) {
342
+ this.controller = {
343
+ kind: "external",
344
+ src,
345
+ ...this.locationDetails(getAttributeLocation(block, "src")),
346
+ };
347
+ return;
348
+ }
349
+ const textNode = block.childNodes[0];
350
+ const location = textNode ? getNodeLocation(textNode) : getElementLocation(block);
351
+ this.controller = {
352
+ kind: "inline",
353
+ source,
354
+ ...this.locationDetails(location),
355
+ };
356
+ }
357
+ async validateControllerFile() {
358
+ if (!this.controller || this.controller.kind !== "external" || this.controller.src === undefined) {
359
+ return;
360
+ }
361
+ const details = {
362
+ path: this.entry,
363
+ ...(this.controller.line !== undefined ? { line: this.controller.line, column: this.controller.column } : {}),
364
+ };
365
+ const resolved = await this.resolveLocalFile(this.controller.src, this.compositionDir, "CONTROLLER_INVALID", "Controller", details);
366
+ if (!resolved) {
367
+ this.controllerValid = false;
368
+ return;
369
+ }
370
+ this.controller = { ...this.controller, src: resolved.relative };
371
+ }
372
+ validateScripts() {
373
+ for (const element of findElements(this.document, (candidate) => candidate.tagName === "script")) {
374
+ if (hasAttribute(element, "data-fy-host")) {
375
+ continue;
376
+ }
377
+ const type = getAttribute(element, "type")?.toLowerCase();
378
+ if (type === "importmap" || type === "application/json" || type === "text/fy-controller") {
379
+ continue;
380
+ }
381
+ this.error("SCRIPT_NOT_ALLOWED", "Executable <script> elements are not allowed in a composition; move code into the controller.", this.elementDetails(element, { value: getAttribute(element, "src") }));
382
+ }
383
+ }
384
+ validateUnsupportedElements() {
385
+ walk(this.document, (node) => {
386
+ if (!isElement(node)) {
387
+ return;
388
+ }
389
+ const tag = node.tagName;
390
+ if (tag === "iframe" || tag === "object" || tag === "embed") {
391
+ this.error("ELEMENT_NOT_SUPPORTED", `<${tag}> is not a supported visual source; use HTML, SVG, canvas, or a local <video>.`, this.elementDetails(node));
392
+ return;
393
+ }
394
+ if (tag === "base") {
395
+ this.error("ELEMENT_NOT_SUPPORTED", "<base> is not supported in a composition.", this.elementDetails(node));
396
+ return;
397
+ }
398
+ if (tag === "source" || tag === "track") {
399
+ this.error("ELEMENT_NOT_SUPPORTED", `<${tag}> is not supported; use a local media src and <fy-transcript> for captions.`, this.elementDetails(node));
400
+ return;
401
+ }
402
+ if (tag === "meta") {
403
+ const httpEquiv = getAttribute(node, "http-equiv")?.toLowerCase();
404
+ if (httpEquiv === "refresh" || httpEquiv === "content-security-policy") {
405
+ this.error("ELEMENT_NOT_SUPPORTED", `<meta http-equiv="${httpEquiv}"> is not supported in a composition.`, this.attributeDetails(node, "http-equiv"));
406
+ }
407
+ return;
408
+ }
409
+ if (tag === "template" && hasAttribute(node, "shadowrootmode")) {
410
+ this.error("ELEMENT_NOT_SUPPORTED", "Declarative shadow DOM is not supported in a composition.", this.elementDetails(node));
411
+ return;
412
+ }
413
+ if (tag.startsWith("fy-") && tag !== "fy-composition" && tag !== "fy-transcript") {
414
+ this.warning("ELEMENT_UNKNOWN", `Unknown Frameyard element <${tag}>.`, this.elementDetails(node));
415
+ }
416
+ });
417
+ }
418
+ validateDocumentAttributes() {
419
+ walk(this.document, (node) => {
420
+ if (!isElement(node)) {
421
+ return;
422
+ }
423
+ for (const attribute of node.attrs) {
424
+ if (/^on[a-z]/i.test(attribute.name)) {
425
+ this.error("EVENT_HANDLER_NOT_ALLOWED", `Event handler attribute "${attribute.name}" is not allowed in a composition.`, this.attributeDetails(node, attribute.name));
426
+ }
427
+ if (/^\s*javascript:/i.test(attribute.value)) {
428
+ this.error("URL_NOT_ALLOWED", `"javascript:" URLs are not allowed in a composition.`, this.attributeDetails(node, attribute.name, { value: attribute.value }));
429
+ }
430
+ }
431
+ });
432
+ }
433
+ validateOutsideRoot() {
434
+ const body = getElementByTagName(this.document, "body");
435
+ const root = this.root;
436
+ if (!body || !root) {
437
+ return;
438
+ }
439
+ const inspect = (parent) => {
440
+ for (const child of parent.childNodes) {
441
+ if (child === root) {
442
+ continue;
443
+ }
444
+ if (isElement(child)) {
445
+ if (isDescendantOf(root, child)) {
446
+ inspect(child);
447
+ continue;
448
+ }
449
+ if (NON_VISUAL_ELEMENTS.has(child.tagName)) {
450
+ continue;
451
+ }
452
+ this.warning("CONTENT_OUTSIDE_ROOT", "Visual content outside <fy-composition> is not captured; move it inside the composition root.", this.elementDetails(child));
453
+ continue;
454
+ }
455
+ if (isTextNode(child) && child.value.trim().length > 0) {
456
+ this.warning("CONTENT_OUTSIDE_ROOT", "Text outside <fy-composition> is not captured; move it inside the composition root.", this.nodeDetails(child));
457
+ }
458
+ }
459
+ };
460
+ inspect(body);
461
+ }
462
+ validateBindings(element, repeatDepth) {
463
+ const repeat = getAttribute(element, "data-fy-repeat");
464
+ const condition = getAttribute(element, "data-fy-if");
465
+ const text = getAttribute(element, "data-fy-text");
466
+ const inRepeat = repeatDepth > 0;
467
+ if (repeat !== undefined) {
468
+ if (element.tagName !== "template") {
469
+ this.error("BINDING_REPEAT_INVALID", "data-fy-repeat must be used on a <template>.", this.attributeDetails(element, "data-fy-repeat"));
470
+ }
471
+ if (condition !== undefined) {
472
+ this.error("BINDING_INVALID", "A repeat template cannot also use data-fy-if.", this.attributeDetails(element, "data-fy-if"));
473
+ }
474
+ if (text !== undefined) {
475
+ this.error("BINDING_TARGET_INVALID", "A repeat template cannot use data-fy-text.", this.attributeDetails(element, "data-fy-text"));
476
+ }
477
+ if (inRepeat) {
478
+ this.error("BINDING_REPEAT_NESTED", "Nested data-fy-repeat is not supported; use a controller for nested data.", this.attributeDetails(element, "data-fy-repeat"));
479
+ }
480
+ this.validateBindingPathValue(repeat, element, "data-fy-repeat", {
481
+ inRepeat,
482
+ requireArray: true,
483
+ });
484
+ if (isTemplate(element)) {
485
+ for (const child of element.content.childNodes) {
486
+ if (isElement(child)) {
487
+ this.validateBindings(child, repeatDepth + 1);
488
+ }
489
+ }
490
+ }
491
+ return;
492
+ }
493
+ if (condition !== undefined) {
494
+ if (element === this.root) {
495
+ this.error("BINDING_INVALID", "data-fy-if cannot be used on the composition root.", this.attributeDetails(element, "data-fy-if"));
496
+ }
497
+ else {
498
+ this.validateBindingPathValue(condition, element, "data-fy-if", { inRepeat });
499
+ }
500
+ }
501
+ if (text !== undefined) {
502
+ if (element === this.root) {
503
+ this.error("BINDING_TARGET_INVALID", "data-fy-text cannot be used on the composition root.", this.attributeDetails(element, "data-fy-text"));
504
+ }
505
+ else if (VOID_ELEMENTS.has(element.tagName)) {
506
+ this.error("BINDING_TARGET_INVALID", `data-fy-text cannot be used on <${element.tagName}>.`, this.attributeDetails(element, "data-fy-text"));
507
+ }
508
+ else {
509
+ this.validateBindingPathValue(text, element, "data-fy-text", {
510
+ inRepeat,
511
+ checkValueType: "text",
512
+ });
513
+ }
514
+ }
515
+ for (const attribute of element.attrs) {
516
+ const name = attribute.name;
517
+ if (name === "data-fy-repeat" || name === "data-fy-if" || name === "data-fy-text") {
518
+ continue;
519
+ }
520
+ if (name === "data-fy") {
521
+ if (element.tagName !== "script" || getAttribute(element, "type")?.toLowerCase() !== "application/json") {
522
+ this.error("BINDING_INVALID", "data-fy is only valid on a JSON props block.", this.attributeDetails(element, name));
523
+ }
524
+ continue;
525
+ }
526
+ if (name.startsWith("data-fy-attr-")) {
527
+ this.validateAttributeBinding(element, name, attribute.value, inRepeat);
528
+ continue;
529
+ }
530
+ if (name.startsWith("data-fy-class-")) {
531
+ this.validateClassBinding(element, name, attribute.value, inRepeat);
532
+ continue;
533
+ }
534
+ if (name === "data-fy-anim-start" || name === "data-fy-anim-duration") {
535
+ this.validateAnimationDuration(element, name, attribute.value);
536
+ continue;
537
+ }
538
+ if (name === "data-fy-anim-easing") {
539
+ if (attribute.value.trim().length === 0) {
540
+ this.error("ANIMATION_INVALID", "data-fy-anim-easing must not be empty.", this.attributeDetails(element, name));
541
+ }
542
+ continue;
543
+ }
544
+ if (MEDIA_ATTRIBUTES.has(name)) {
545
+ this.validateMediaAttribute(element, name, attribute.value);
546
+ continue;
547
+ }
548
+ if (name.startsWith("data-fy-")) {
549
+ this.error("BINDING_INVALID", `Unknown Frameyard attribute "${name}". Known attributes: ${KNOWN_BINDING_ATTRIBUTES.join(", ")}.`, this.attributeDetails(element, name, { value: attribute.value }));
550
+ }
551
+ }
552
+ const children = isTemplate(element) ? element.content.childNodes : element.childNodes;
553
+ for (const child of children) {
554
+ if (isElement(child)) {
555
+ this.validateBindings(child, repeatDepth);
556
+ }
557
+ }
558
+ }
559
+ validateBindingPathValue(path, element, attribute, options) {
560
+ const validation = validateBindingPath(path);
561
+ if (!validation.ok) {
562
+ this.error("BINDING_INVALID", `Invalid ${attribute} path "${path}": ${validation.reason}`, this.attributeDetails(element, attribute, { value: path }));
563
+ return;
564
+ }
565
+ const root = path.split(".")[0];
566
+ if (options.inRepeat && (root === "item" || root === "index")) {
567
+ return;
568
+ }
569
+ const resolved = resolveBindingPath({ props: this.props }, path);
570
+ if (!resolved.found) {
571
+ const severity = options.requireArray ? "error" : "warning";
572
+ const code = options.requireArray ? "BINDING_REPEAT_INVALID" : "BINDING_PATH_MISSING";
573
+ this.report(severity, code, `${attribute} references prop "${path}", which is not in the resolved props.`, this.attributeDetails(element, attribute, {
574
+ value: path,
575
+ suggestion: suggestBindingPath(path, this.props),
576
+ }));
577
+ return;
578
+ }
579
+ if (options.requireArray && !Array.isArray(resolved.value)) {
580
+ this.error("BINDING_REPEAT_INVALID", `${attribute} must reference an array; "${path}" is ${describeValue(resolved.value)}.`, this.attributeDetails(element, attribute, { value: path }));
581
+ return;
582
+ }
583
+ if (options.checkValueType && resolved.value !== null && typeof resolved.value === "object") {
584
+ this.warning("BINDING_VALUE_INVALID", `${attribute} references "${path}", which is ${describeValue(resolved.value)}; ${options.checkValueType} bindings require a primitive value.`, this.attributeDetails(element, attribute, { value: path }));
585
+ }
586
+ }
587
+ validateAttributeBinding(element, name, value, inRepeat) {
588
+ const target = name.slice("data-fy-attr-".length);
589
+ if (target.length === 0) {
590
+ this.error("BINDING_INVALID", "data-fy-attr-* requires an attribute name.", this.attributeDetails(element, name));
591
+ return;
592
+ }
593
+ if (/^on/i.test(target)) {
594
+ this.error("EVENT_HANDLER_NOT_ALLOWED", `data-fy-attr-${target} would set an event handler, which is not allowed.`, this.attributeDetails(element, name));
595
+ return;
596
+ }
597
+ this.validateBindingPathValue(value, element, name, {
598
+ inRepeat,
599
+ checkValueType: "attr",
600
+ });
601
+ }
602
+ validateClassBinding(element, name, value, inRepeat) {
603
+ const className = name.slice("data-fy-class-".length);
604
+ if (className.length === 0 || /\s/.test(className)) {
605
+ this.error("BINDING_INVALID", "data-fy-class-* requires one class name without whitespace.", this.attributeDetails(element, name));
606
+ return;
607
+ }
608
+ this.validateBindingPathValue(value, element, name, {
609
+ inRepeat,
610
+ checkValueType: "class",
611
+ });
612
+ }
613
+ validateAnimationDuration(element, name, value) {
614
+ const frames = Number(value);
615
+ const valid = /^\d+$/.test(value.trim()) &&
616
+ Number.isInteger(frames) &&
617
+ (name === "data-fy-anim-start" ? frames >= 0 : frames > 0);
618
+ if (!valid) {
619
+ this.error("ANIMATION_INVALID", `${name} must be a ${name === "data-fy-anim-start" ? "non-negative" : "positive"} integer frame count.`, this.attributeDetails(element, name, { value }));
620
+ }
621
+ if (NON_ANIMATED_ELEMENTS.has(element.tagName)) {
622
+ this.warning("ANIMATION_NOT_FOUND", `${name} has no visual animation target on <${element.tagName}>.`, this.attributeDetails(element, name));
623
+ }
624
+ }
625
+ validateMediaAttribute(element, name, value) {
626
+ if (element.tagName !== "audio" && element.tagName !== "video") {
627
+ this.error("MEDIA_INVALID", `${name} is only valid on <audio> or <video>.`, this.attributeDetails(element, name));
628
+ return;
629
+ }
630
+ if (name === "data-fy-audio") {
631
+ if (element.tagName !== "video" || value !== "off") {
632
+ this.error("MEDIA_INVALID", 'data-fy-audio is only valid on <video> with the value "off".', this.attributeDetails(element, name, { value }));
633
+ }
634
+ return;
635
+ }
636
+ if (name === "data-fy-start") {
637
+ const frames = Number(value);
638
+ if (!/^\d+$/.test(value.trim()) || !Number.isInteger(frames) || frames < 0) {
639
+ this.error("MEDIA_INVALID", "data-fy-start must be a non-negative integer frame.", this.attributeDetails(element, name, { value }));
640
+ }
641
+ return;
642
+ }
643
+ if (name === "data-fy-trim") {
644
+ const seconds = Number(value);
645
+ if (!Number.isFinite(seconds) || seconds < 0) {
646
+ this.error("MEDIA_INVALID", "data-fy-trim must be a non-negative number of seconds.", this.attributeDetails(element, name, { value }));
647
+ }
648
+ return;
649
+ }
650
+ if (name === "data-fy-duration") {
651
+ const seconds = Number(value);
652
+ if (!Number.isFinite(seconds) || seconds <= 0) {
653
+ this.error("MEDIA_INVALID", "data-fy-duration must be a positive number of seconds.", this.attributeDetails(element, name, { value }));
654
+ }
655
+ return;
656
+ }
657
+ const volume = Number(value);
658
+ if (!Number.isFinite(volume) || volume < 0 || volume > 1) {
659
+ this.error("MEDIA_INVALID", "data-fy-volume must be between 0 and 1.", this.attributeDetails(element, name, { value }));
660
+ }
661
+ }
662
+ async validateMedia() {
663
+ const media = findElements(this.document, (element) => element.tagName === "audio" || element.tagName === "video");
664
+ for (const element of media) {
665
+ const literal = getAttribute(element, "src");
666
+ const bound = getAttribute(element, "data-fy-attr-src");
667
+ if (literal !== undefined && bound !== undefined) {
668
+ this.error("MEDIA_INVALID", `Use either src or data-fy-attr-src on <${element.tagName}>, not both.`, this.elementDetails(element));
669
+ continue;
670
+ }
671
+ if (literal === undefined && bound === undefined) {
672
+ this.error("MEDIA_INVALID", `<${element.tagName}> requires a src attribute or data-fy-attr-src binding.`, this.elementDetails(element));
673
+ continue;
674
+ }
675
+ let source;
676
+ if (literal !== undefined) {
677
+ source = literal;
678
+ }
679
+ else if (bound !== undefined) {
680
+ const resolved = this.resolveMediaBinding(bound);
681
+ if (resolved === null) {
682
+ continue;
683
+ }
684
+ source = resolved;
685
+ }
686
+ if (source === undefined) {
687
+ continue;
688
+ }
689
+ const file = await this.resolveLocalFile(source, this.projectRoot, "MEDIA_INVALID", "Media", this.elementDetails(element));
690
+ if (!file) {
691
+ continue;
692
+ }
693
+ const startFrame = this.readMediaInteger(element, "data-fy-start", 0);
694
+ const trimStart = this.readMediaNumber(element, "data-fy-trim", 0);
695
+ const trimDuration = this.readOptionalMediaNumber(element, "data-fy-duration");
696
+ const volume = hasAttribute(element, "muted") ? 0 : this.readMediaNumber(element, "data-fy-volume", 1);
697
+ if (startFrame === undefined || trimStart === undefined || volume === undefined) {
698
+ continue;
699
+ }
700
+ try {
701
+ const clip = normalizeAudioClip({
702
+ src: file.relative,
703
+ startFrame,
704
+ trimStart,
705
+ ...(trimDuration !== undefined ? { trimDuration } : {}),
706
+ volume,
707
+ }, this.audioClips.length + this.embeddedAudioClips.length);
708
+ if (element.tagName === "audio") {
709
+ this.audioClips.push(clip);
710
+ }
711
+ else if (!hasAttribute(element, "muted") && getAttribute(element, "data-fy-audio") !== "off") {
712
+ this.embeddedAudioClips.push(clip);
713
+ }
714
+ }
715
+ catch (error) {
716
+ this.error(errorCode(error, "MEDIA_INVALID"), errorMessage(error), this.elementDetails(element));
717
+ }
718
+ }
719
+ }
720
+ resolveMediaBinding(path) {
721
+ const root = path.split(".")[0];
722
+ if (root === "item" || root === "index") {
723
+ return null;
724
+ }
725
+ const resolved = resolveBindingPath({ props: this.props }, path);
726
+ if (!resolved.found) {
727
+ return null;
728
+ }
729
+ if (typeof resolved.value !== "string" || resolved.value.length === 0) {
730
+ this.error("MEDIA_INVALID", `Media binding "${path}" must resolve to a non-empty local path.`, { value: path });
731
+ return null;
732
+ }
733
+ return resolved.value;
734
+ }
735
+ readMediaInteger(element, attribute, fallback) {
736
+ const raw = getAttribute(element, attribute);
737
+ if (raw === undefined) {
738
+ return fallback;
739
+ }
740
+ const value = Number(raw);
741
+ return /^\d+$/.test(raw.trim()) && Number.isInteger(value) && value >= 0 ? value : undefined;
742
+ }
743
+ readMediaNumber(element, attribute, fallback) {
744
+ const raw = getAttribute(element, attribute);
745
+ if (raw === undefined) {
746
+ return fallback;
747
+ }
748
+ const value = Number(raw);
749
+ return Number.isFinite(value) && value >= 0 ? value : undefined;
750
+ }
751
+ readOptionalMediaNumber(element, attribute) {
752
+ const raw = getAttribute(element, attribute);
753
+ if (raw === undefined) {
754
+ return undefined;
755
+ }
756
+ const value = Number(raw);
757
+ return Number.isFinite(value) && value > 0 ? value : undefined;
758
+ }
759
+ async validateTranscripts() {
760
+ const transcripts = findElements(this.document, (element) => element.tagName === "fy-transcript");
761
+ for (const element of transcripts) {
762
+ const literal = getAttribute(element, "src");
763
+ const bound = getAttribute(element, "data-fy-attr-src");
764
+ if (literal !== undefined && bound !== undefined) {
765
+ this.error("TRANSCRIPT_INVALID", "Use either src or data-fy-attr-src on <fy-transcript>, not both.", this.elementDetails(element));
766
+ continue;
767
+ }
768
+ if (literal === undefined && bound === undefined) {
769
+ this.error("TRANSCRIPT_INVALID", "<fy-transcript> requires a src attribute or data-fy-attr-src binding.", this.elementDetails(element));
770
+ continue;
771
+ }
772
+ let source;
773
+ if (literal !== undefined) {
774
+ source = literal;
775
+ }
776
+ else if (bound !== undefined) {
777
+ const resolved = this.resolveMediaBinding(bound);
778
+ if (resolved === null) {
779
+ continue;
780
+ }
781
+ source = resolved;
782
+ }
783
+ if (source === undefined) {
784
+ continue;
785
+ }
786
+ const file = await this.resolveLocalFile(source, this.projectRoot, "TRANSCRIPT_INVALID", "Transcript", this.elementDetails(element));
787
+ if (!file) {
788
+ continue;
789
+ }
790
+ const words = hasAttribute(element, "words");
791
+ try {
792
+ const clip = normalizeTranscriptClip({ src: file.relative, words }, this.transcriptClips.length);
793
+ this.transcriptClips.push(clip);
794
+ const content = await readFile(file.absolute, "utf8");
795
+ const parsed = JSON.parse(content);
796
+ parseTranscript(parsed, { requireWords: words });
797
+ }
798
+ catch (error) {
799
+ this.error(errorCode(error, "TRANSCRIPT_INVALID"), errorMessage(error), {
800
+ path: file.relative,
801
+ ...this.elementDetails(element),
802
+ });
803
+ }
804
+ }
805
+ }
806
+ async validateOrdinaryAssets() {
807
+ const assets = [];
808
+ for (const image of findElements(this.document, (element) => element.tagName === "img")) {
809
+ const src = getAttribute(image, "src");
810
+ if (src !== undefined) {
811
+ assets.push({ element: image, attribute: "src", value: src });
812
+ }
813
+ }
814
+ for (const link of findElements(this.document, (element) => element.tagName === "link")) {
815
+ const rel = getAttribute(link, "rel")?.toLowerCase().split(/\s+/) ?? [];
816
+ if (!rel.includes("stylesheet")) {
817
+ continue;
818
+ }
819
+ const href = getAttribute(link, "href");
820
+ if (href !== undefined) {
821
+ assets.push({ element: link, attribute: "href", value: href });
822
+ }
823
+ }
824
+ for (const asset of assets) {
825
+ if (isRemote(asset.value)) {
826
+ continue;
827
+ }
828
+ await this.resolveLocalFile(asset.value, this.compositionDir, "ASSET_NOT_FOUND", "Asset", this.attributeDetails(asset.element, asset.attribute, { value: asset.value }));
829
+ }
830
+ }
831
+ async resolveLocalFile(raw, baseDir, code, label, details) {
832
+ if (!isLocalRelative(raw)) {
833
+ this.error(code, `${label} path must be a local project file, not "${raw}".`, { ...details, value: raw });
834
+ return null;
835
+ }
836
+ const absolute = path.resolve(baseDir, raw);
837
+ if (absolute !== this.projectRoot && !absolute.startsWith(this.projectRoot + path.sep)) {
838
+ this.error(code, `${label} path escapes the project root: "${raw}".`, { ...details, value: raw });
839
+ return null;
840
+ }
841
+ try {
842
+ const file = await stat(absolute);
843
+ if (!file.isFile()) {
844
+ throw new Error("not a file");
845
+ }
846
+ }
847
+ catch {
848
+ this.error(code, `${label} file does not exist: "${raw}".`, { ...details, value: raw });
849
+ return null;
850
+ }
851
+ return {
852
+ absolute,
853
+ relative: path.relative(this.projectRoot, absolute).split(path.sep).join("/"),
854
+ };
855
+ }
856
+ }
857
+ export async function validateHtmlComposition(options) {
858
+ const projectRoot = path.resolve(options.projectRoot);
859
+ const entryPath = path.resolve(projectRoot, options.entry);
860
+ if (entryPath !== projectRoot && !entryPath.startsWith(projectRoot + path.sep)) {
861
+ return {
862
+ info: null,
863
+ diagnostics: [
864
+ {
865
+ severity: "error",
866
+ code: "COMPOSITION_INVALID",
867
+ message: `Composition entry escapes the project root: "${options.entry}".`,
868
+ details: { path: options.entry.replaceAll("\\", "/") },
869
+ },
870
+ ],
871
+ };
872
+ }
873
+ let html;
874
+ try {
875
+ html = await readFile(entryPath, "utf8");
876
+ }
877
+ catch {
878
+ return {
879
+ info: null,
880
+ diagnostics: [
881
+ {
882
+ severity: "error",
883
+ code: "COMPOSITION_NOT_FOUND",
884
+ message: `Composition entry does not exist: "${options.entry}".`,
885
+ details: { path: options.entry.replaceAll("\\", "/") },
886
+ },
887
+ ],
888
+ };
889
+ }
890
+ let document;
891
+ try {
892
+ document = parseHtml(html);
893
+ }
894
+ catch (error) {
895
+ return {
896
+ info: null,
897
+ diagnostics: [
898
+ {
899
+ severity: "error",
900
+ code: "COMPOSITION_INVALID",
901
+ message: `Could not parse composition HTML: ${errorMessage(error)}`,
902
+ details: { path: options.entry.replaceAll("\\", "/") },
903
+ },
904
+ ],
905
+ };
906
+ }
907
+ const validator = new HtmlCompositionValidator({ ...options, projectRoot }, document);
908
+ return validator.run();
909
+ }
910
+ function isRemote(value) {
911
+ return SCHEME.test(value) || value.startsWith("//");
912
+ }
913
+ function isLocalRelative(value) {
914
+ return !SCHEME.test(value) && !value.startsWith("/") && !value.includes("\\") && !value.split("/").includes("..");
915
+ }
916
+ function describeValue(value) {
917
+ if (value === null) {
918
+ return "null";
919
+ }
920
+ if (Array.isArray(value)) {
921
+ return "an array";
922
+ }
923
+ if (typeof value === "object") {
924
+ return "an object";
925
+ }
926
+ return `a ${typeof value}`;
927
+ }
928
+ function errorCode(error, fallback) {
929
+ return isFrameyardError(error) ? error.code : fallback;
930
+ }
931
+ //# sourceMappingURL=validate.js.map