frameyard 0.3.0 → 0.5.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 (168) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/GLOSSARY.md +46 -2
  3. package/README.md +174 -106
  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 +32 -21
  9. package/dist/cli.js.map +1 -1
  10. package/dist/composition/adapter.d.ts +30 -0
  11. package/dist/composition/adapter.d.ts.map +1 -0
  12. package/dist/composition/adapter.js +2 -0
  13. package/dist/composition/adapter.js.map +1 -0
  14. package/dist/composition/animation.d.ts +19 -0
  15. package/dist/composition/animation.d.ts.map +1 -0
  16. package/dist/composition/animation.js +55 -0
  17. package/dist/composition/animation.js.map +1 -0
  18. package/dist/composition/attributes.d.ts +7 -0
  19. package/dist/composition/attributes.d.ts.map +1 -0
  20. package/dist/composition/attributes.js +24 -0
  21. package/dist/composition/attributes.js.map +1 -0
  22. package/dist/composition/descriptor.d.ts +30 -0
  23. package/dist/composition/descriptor.d.ts.map +1 -0
  24. package/dist/composition/descriptor.js +188 -0
  25. package/dist/composition/descriptor.js.map +1 -0
  26. package/dist/composition/diagnostics.d.ts +12 -0
  27. package/dist/composition/diagnostics.d.ts.map +1 -0
  28. package/dist/composition/diagnostics.js +38 -0
  29. package/dist/composition/diagnostics.js.map +1 -0
  30. package/dist/composition/index.d.ts +12 -0
  31. package/dist/composition/index.d.ts.map +1 -0
  32. package/dist/composition/index.js +7 -0
  33. package/dist/composition/index.js.map +1 -0
  34. package/dist/composition/media.d.ts +27 -0
  35. package/dist/composition/media.d.ts.map +1 -0
  36. package/dist/composition/media.js +162 -0
  37. package/dist/composition/media.js.map +1 -0
  38. package/dist/composition/paths.d.ts +5 -0
  39. package/dist/composition/paths.d.ts.map +1 -0
  40. package/dist/composition/paths.js +16 -0
  41. package/dist/composition/paths.js.map +1 -0
  42. package/dist/composition/transcripts.d.ts +19 -0
  43. package/dist/composition/transcripts.d.ts.map +1 -0
  44. package/dist/composition/transcripts.js +57 -0
  45. package/dist/composition/transcripts.js.map +1 -0
  46. package/dist/composition/walk.d.ts +10 -0
  47. package/dist/composition/walk.d.ts.map +1 -0
  48. package/dist/composition/walk.js +32 -0
  49. package/dist/composition/walk.js.map +1 -0
  50. package/dist/contracts.d.ts +75 -47
  51. package/dist/contracts.d.ts.map +1 -1
  52. package/dist/contracts.js +14 -159
  53. package/dist/contracts.js.map +1 -1
  54. package/dist/errors.d.ts +15 -4
  55. package/dist/errors.d.ts.map +1 -1
  56. package/dist/errors.js +8 -0
  57. package/dist/errors.js.map +1 -1
  58. package/dist/html/document-adapter.d.ts +5 -0
  59. package/dist/html/document-adapter.d.ts.map +1 -0
  60. package/dist/html/document-adapter.js +17 -0
  61. package/dist/html/document-adapter.js.map +1 -0
  62. package/dist/html/index.d.ts +5 -0
  63. package/dist/html/index.d.ts.map +1 -0
  64. package/dist/html/index.js +3 -0
  65. package/dist/html/index.js.map +1 -0
  66. package/dist/html/parse.d.ts +26 -0
  67. package/dist/html/parse.d.ts.map +1 -0
  68. package/dist/html/parse.js +80 -0
  69. package/dist/html/parse.js.map +1 -0
  70. package/dist/html/validate.d.ts +14 -0
  71. package/dist/html/validate.d.ts.map +1 -0
  72. package/dist/html/validate.js +648 -0
  73. package/dist/html/validate.js.map +1 -0
  74. package/dist/index.d.ts +2 -2
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +1 -1
  77. package/dist/index.js.map +1 -1
  78. package/dist/preview/main.d.ts +10 -3
  79. package/dist/preview/main.d.ts.map +1 -1
  80. package/dist/preview/main.js +189 -89
  81. package/dist/preview/main.js.map +1 -1
  82. package/dist/project.d.ts +2 -0
  83. package/dist/project.d.ts.map +1 -1
  84. package/dist/project.js +4 -0
  85. package/dist/project.js.map +1 -1
  86. package/dist/renderer/browser.d.ts +11 -1
  87. package/dist/renderer/browser.d.ts.map +1 -1
  88. package/dist/renderer/browser.js +93 -14
  89. package/dist/renderer/browser.js.map +1 -1
  90. package/dist/renderer/index.d.ts +25 -5
  91. package/dist/renderer/index.d.ts.map +1 -1
  92. package/dist/renderer/index.js +82 -16
  93. package/dist/renderer/index.js.map +1 -1
  94. package/dist/runtime/bindings.d.ts +11 -0
  95. package/dist/runtime/bindings.d.ts.map +1 -0
  96. package/dist/runtime/bindings.js +164 -0
  97. package/dist/runtime/bindings.js.map +1 -0
  98. package/dist/runtime/diagnostics.d.ts +4 -0
  99. package/dist/runtime/diagnostics.d.ts.map +1 -0
  100. package/dist/runtime/diagnostics.js +107 -0
  101. package/dist/runtime/diagnostics.js.map +1 -0
  102. package/dist/runtime/document-adapter.d.ts +4 -0
  103. package/dist/runtime/document-adapter.d.ts.map +1 -0
  104. package/dist/runtime/document-adapter.js +19 -0
  105. package/dist/runtime/document-adapter.js.map +1 -0
  106. package/dist/runtime/host.d.ts +14 -0
  107. package/dist/runtime/host.d.ts.map +1 -0
  108. package/dist/runtime/host.js +596 -0
  109. package/dist/runtime/host.js.map +1 -0
  110. package/dist/runtime/index.d.ts +0 -29
  111. package/dist/runtime/index.d.ts.map +1 -1
  112. package/dist/runtime/index.js +0 -360
  113. package/dist/runtime/index.js.map +1 -1
  114. package/dist/runtime/media.d.ts.map +1 -1
  115. package/dist/runtime/media.js +20 -51
  116. package/dist/runtime/media.js.map +1 -1
  117. package/dist/scaffold.d.ts.map +1 -1
  118. package/dist/scaffold.js +177 -84
  119. package/dist/scaffold.js.map +1 -1
  120. package/dist/server/composition-html.d.ts +20 -0
  121. package/dist/server/composition-html.d.ts.map +1 -0
  122. package/dist/server/composition-html.js +45 -0
  123. package/dist/server/composition-html.js.map +1 -0
  124. package/dist/server/preview-html.d.ts.map +1 -1
  125. package/dist/server/preview-html.js +19 -14
  126. package/dist/server/preview-html.js.map +1 -1
  127. package/dist/server/server.d.ts.map +1 -1
  128. package/dist/server/server.js +94 -6
  129. package/dist/server/server.js.map +1 -1
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/docs/adr/0001-keep-composition-authoring-web-native.md +2 -0
  133. package/docs/adr/0006-seek-embedded-media-from-the-frame-clock.md +7 -1
  134. package/docs/adr/0007-make-html-documents-the-composition-unit.md +31 -0
  135. package/docs/adr/0008-share-the-composition-document-grammar.md +18 -0
  136. package/docs/adr/0009-composite-production-frames-in-software.md +37 -0
  137. package/docs/migration-0.4.md +279 -0
  138. package/docs/v0.1-design.md +78 -56
  139. package/examples/demo/README.md +8 -4
  140. package/examples/demo/compositions/demo.controller.js +120 -0
  141. package/examples/demo/compositions/demo.html +253 -0
  142. package/examples/demo/video.config.js +1 -1
  143. package/examples/video/README.md +61 -0
  144. package/examples/video/assets/clip.mp4 +0 -0
  145. package/examples/video/assets/clip.webm +0 -0
  146. package/examples/video/compositions/video-mp4.html +362 -0
  147. package/examples/video/compositions/video.html +362 -0
  148. package/examples/video/props/video.json +8 -0
  149. package/examples/video/scripts/make-clip.sh +109 -0
  150. package/examples/video/video.config.js +6 -0
  151. package/examples/webgl/README.md +30 -0
  152. package/examples/webgl/compositions/webgl.controller.js +200 -0
  153. package/examples/webgl/compositions/webgl.html +215 -0
  154. package/examples/webgl/props/webgl.json +6 -0
  155. package/examples/webgl/video.config.js +5 -0
  156. package/package.json +7 -3
  157. package/skills/frameyard/README.md +4 -0
  158. package/skills/frameyard/SKILL.md +40 -12
  159. package/skills/frameyard/references/audio-assets.md +25 -14
  160. package/skills/frameyard/references/bindings.md +73 -0
  161. package/skills/frameyard/references/common-mistakes.md +59 -0
  162. package/skills/frameyard/references/controllers.md +126 -0
  163. package/skills/frameyard/references/deterministic-authoring.md +143 -52
  164. package/skills/frameyard/references/diagnostics.md +90 -0
  165. package/skills/frameyard/references/failure-recovery.md +37 -14
  166. package/skills/frameyard/references/json-results.md +43 -9
  167. package/skills/frameyard/references/subtitles-karaoke.md +10 -18
  168. package/examples/demo/compositions/demo.js +0 -206
@@ -0,0 +1,648 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { KNOWN_DATA_FY_ATTRIBUTES, isAnimationAttribute, isLocalRelative, isMediaAttribute, isRemote, readAnimationDeclarations, readCompositionDescriptor, readMediaDeclarations, readTranscriptDeclarations, } from "../composition/index.js";
4
+ import { mergeProps, } from "../contracts.js";
5
+ import { resolveBindingPath, suggestBindingPath, validateBindingPath } from "../bindings.js";
6
+ import { errorMessage, isFrameyardError } from "../errors.js";
7
+ import { parseTranscript } from "../transcript.js";
8
+ import { createParse5Adapter } from "./document-adapter.js";
9
+ import { findElements, getAttribute, getAttributeLocation, getDocumentDoctype, getElementByTagName, getElementLocation, getNodeLocation, hasAttribute, isDescendantOf, isElement, isTemplate, isTextNode, parseHtml, walk, } from "./parse.js";
10
+ const VOID_ELEMENTS = new Set([
11
+ "area",
12
+ "base",
13
+ "br",
14
+ "col",
15
+ "embed",
16
+ "hr",
17
+ "img",
18
+ "input",
19
+ "link",
20
+ "meta",
21
+ "param",
22
+ "source",
23
+ "track",
24
+ "wbr",
25
+ ]);
26
+ const NON_VISUAL_ELEMENTS = new Set(["script", "style", "link", "meta", "template", "noscript", "title"]);
27
+ const NON_ANIMATED_ELEMENTS = new Set(["audio", "fy-transcript", "script", "style", "link", "meta", "template"]);
28
+ class HtmlCompositionValidator {
29
+ diagnostics = [];
30
+ projectRoot;
31
+ entry;
32
+ compositionDir;
33
+ document;
34
+ propsOverrides;
35
+ defaultPropsValue = Object.freeze({});
36
+ props;
37
+ metadata = null;
38
+ controller = null;
39
+ documentValid = true;
40
+ fixedFactsValid = false;
41
+ overridesValid = false;
42
+ controllerFileValid = true;
43
+ root = null;
44
+ audioClips = [];
45
+ embeddedAudioClips = [];
46
+ transcriptClips = [];
47
+ constructor(options, document) {
48
+ this.projectRoot = path.resolve(options.projectRoot);
49
+ this.entry = options.entry.replaceAll("\\", "/");
50
+ this.compositionDir = path.dirname(path.resolve(this.projectRoot, this.entry));
51
+ this.document = document;
52
+ this.propsOverrides = options.props;
53
+ this.props = this.defaultPropsValue;
54
+ }
55
+ async run() {
56
+ this.validateDoctype();
57
+ const adapter = createParse5Adapter(this.document);
58
+ const read = readCompositionDescriptor(adapter, {
59
+ path: this.entry,
60
+ resolvePath: (raw) => this.resolveControllerPath(raw),
61
+ });
62
+ this.diagnostics.push(...read.diagnostics);
63
+ this.fixedFactsValid = read.valid;
64
+ const readRoot = read.root;
65
+ this.root = readRoot !== null && isElement(readRoot) ? readRoot : null;
66
+ this.metadata = read.descriptor.metadata;
67
+ this.defaultPropsValue = read.descriptor.defaultProps;
68
+ this.controller = read.descriptor.controller;
69
+ this.mergeOverrides();
70
+ await this.validateControllerFile();
71
+ this.validateScripts();
72
+ this.validateUnsupportedElements();
73
+ this.validateDocumentAttributes();
74
+ this.validateOutsideRoot();
75
+ if (this.root) {
76
+ this.validateBindings(this.root, 0);
77
+ }
78
+ const animation = readAnimationDeclarations(adapter, {
79
+ path: this.entry,
80
+ includeTemplateContent: true,
81
+ });
82
+ this.diagnostics.push(...animation.diagnostics);
83
+ this.validateAnimationTargets(animation.entries);
84
+ const media = readMediaDeclarations(adapter, {
85
+ path: this.entry,
86
+ includeTemplateContent: true,
87
+ props: this.props,
88
+ });
89
+ this.diagnostics.push(...media.diagnostics);
90
+ await this.validateMediaFiles(media.audio, media.embedded);
91
+ const transcripts = readTranscriptDeclarations(adapter, {
92
+ path: this.entry,
93
+ includeTemplateContent: true,
94
+ props: this.props,
95
+ });
96
+ this.diagnostics.push(...transcripts.diagnostics);
97
+ await this.validateTranscriptFiles(transcripts.declarations);
98
+ await this.validateOrdinaryAssets();
99
+ const metadata = this.metadata;
100
+ const root = this.root;
101
+ const info = metadata && root && this.fixedFactsValid && this.overridesValid && this.controllerFileValid && this.documentValid
102
+ ? {
103
+ metadata,
104
+ defaultProps: this.defaultPropsValue,
105
+ controller: this.controller,
106
+ audioClips: Object.freeze([...this.audioClips]),
107
+ embeddedAudioClips: Object.freeze([...this.embeddedAudioClips]),
108
+ transcriptClips: Object.freeze([...this.transcriptClips]),
109
+ }
110
+ : null;
111
+ return { info, diagnostics: this.finishDiagnostics() };
112
+ }
113
+ report(severity, code, message, details) {
114
+ const diagnostic = {
115
+ severity,
116
+ code,
117
+ message,
118
+ };
119
+ const merged = { path: this.entry, ...details };
120
+ if (Object.keys(merged).length > 0) {
121
+ diagnostic.details = merged;
122
+ }
123
+ this.diagnostics.push(diagnostic);
124
+ }
125
+ error(code, message, details) {
126
+ this.report("error", code, message, details);
127
+ }
128
+ warning(code, message, details) {
129
+ this.report("warning", code, message, details);
130
+ }
131
+ attributeDetails(element, attribute, extra = {}) {
132
+ const location = getAttributeLocation(element, attribute) ?? getElementLocation(element);
133
+ return {
134
+ element: element.tagName,
135
+ attribute,
136
+ ...this.locationDetails(location),
137
+ ...extra,
138
+ };
139
+ }
140
+ elementDetails(element, extra = {}) {
141
+ return {
142
+ element: element.tagName,
143
+ ...this.locationDetails(getElementLocation(element)),
144
+ ...extra,
145
+ };
146
+ }
147
+ nodeDetails(node, extra = {}) {
148
+ return {
149
+ ...this.locationDetails(getNodeLocation(node)),
150
+ ...extra,
151
+ };
152
+ }
153
+ locationDetails(location) {
154
+ if (!location) {
155
+ return {};
156
+ }
157
+ return {
158
+ line: location.startLine,
159
+ column: location.startCol,
160
+ endLine: location.endLine,
161
+ endColumn: location.endCol,
162
+ };
163
+ }
164
+ finishDiagnostics() {
165
+ const seen = new Set();
166
+ const unique = this.diagnostics.filter((diagnostic) => {
167
+ const key = JSON.stringify([
168
+ diagnostic.severity,
169
+ diagnostic.code,
170
+ diagnostic.message,
171
+ diagnostic.details ?? null,
172
+ ]);
173
+ if (seen.has(key)) {
174
+ return false;
175
+ }
176
+ seen.add(key);
177
+ return true;
178
+ });
179
+ return unique.sort((left, right) => {
180
+ const leftLine = left.details?.line ?? Number.MAX_SAFE_INTEGER;
181
+ const rightLine = right.details?.line ?? Number.MAX_SAFE_INTEGER;
182
+ if (leftLine !== rightLine) {
183
+ return leftLine - rightLine;
184
+ }
185
+ const leftColumn = left.details?.column ?? 0;
186
+ const rightColumn = right.details?.column ?? 0;
187
+ return leftColumn - rightColumn;
188
+ });
189
+ }
190
+ validateDoctype() {
191
+ const doctype = getDocumentDoctype(this.document);
192
+ if (!doctype) {
193
+ this.documentValid = false;
194
+ this.error("COMPOSITION_INVALID", "Composition documents must start with <!doctype html>.", {
195
+ line: 1,
196
+ column: 1,
197
+ });
198
+ return;
199
+ }
200
+ if (doctype.name.toLowerCase() !== "html") {
201
+ this.documentValid = false;
202
+ this.error("COMPOSITION_INVALID", "Composition documents must use the HTML doctype.", {
203
+ ...this.locationDetails(doctype.sourceCodeLocation ?? undefined),
204
+ });
205
+ }
206
+ }
207
+ mergeOverrides() {
208
+ try {
209
+ this.props = mergeProps(this.defaultPropsValue, this.propsOverrides);
210
+ this.overridesValid = true;
211
+ }
212
+ catch (error) {
213
+ this.overridesValid = false;
214
+ this.error("PROPS_INVALID", `Invalid props: ${errorMessage(error)}`);
215
+ this.props = this.defaultPropsValue;
216
+ }
217
+ }
218
+ async validateControllerFile() {
219
+ const controller = this.controller;
220
+ if (!controller ||
221
+ controller.kind !== "external" ||
222
+ controller.rawSrc === undefined ||
223
+ controller.src === undefined) {
224
+ return;
225
+ }
226
+ const details = {
227
+ path: this.entry,
228
+ ...(controller.line !== undefined ? { line: controller.line, column: controller.column } : {}),
229
+ };
230
+ const resolved = await this.resolveLocalFile(controller.rawSrc, this.compositionDir, "CONTROLLER_INVALID", "Controller", details);
231
+ if (!resolved) {
232
+ this.controllerFileValid = false;
233
+ return;
234
+ }
235
+ this.controller = { ...controller, src: resolved.relative };
236
+ }
237
+ resolveControllerPath(raw) {
238
+ return path.relative(this.projectRoot, path.resolve(this.compositionDir, raw)).split(path.sep).join("/");
239
+ }
240
+ validateScripts() {
241
+ for (const element of findElements(this.document, (candidate) => candidate.tagName === "script")) {
242
+ if (hasAttribute(element, "data-fy-host")) {
243
+ continue;
244
+ }
245
+ const type = getAttribute(element, "type")?.toLowerCase();
246
+ if (type === "importmap" || type === "application/json" || type === "text/fy-controller") {
247
+ continue;
248
+ }
249
+ 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") }));
250
+ }
251
+ }
252
+ validateUnsupportedElements() {
253
+ walk(this.document, (node) => {
254
+ if (!isElement(node)) {
255
+ return;
256
+ }
257
+ const tag = node.tagName;
258
+ if (tag === "iframe" || tag === "object" || tag === "embed") {
259
+ this.error("ELEMENT_NOT_SUPPORTED", `<${tag}> is not a supported visual source; use HTML, SVG, canvas, or a local <video>.`, this.elementDetails(node));
260
+ return;
261
+ }
262
+ if (tag === "base") {
263
+ this.error("ELEMENT_NOT_SUPPORTED", "<base> is not supported in a composition.", this.elementDetails(node));
264
+ return;
265
+ }
266
+ if (tag === "source" || tag === "track") {
267
+ this.error("ELEMENT_NOT_SUPPORTED", `<${tag}> is not supported; use a local media src and <fy-transcript> for captions.`, this.elementDetails(node));
268
+ return;
269
+ }
270
+ if (tag === "meta") {
271
+ const httpEquiv = getAttribute(node, "http-equiv")?.toLowerCase();
272
+ if (httpEquiv === "refresh" || httpEquiv === "content-security-policy") {
273
+ this.error("ELEMENT_NOT_SUPPORTED", `<meta http-equiv="${httpEquiv}"> is not supported in a composition.`, this.attributeDetails(node, "http-equiv"));
274
+ }
275
+ return;
276
+ }
277
+ if (tag === "template" && hasAttribute(node, "shadowrootmode")) {
278
+ this.error("ELEMENT_NOT_SUPPORTED", "Declarative shadow DOM is not supported in a composition.", this.elementDetails(node));
279
+ return;
280
+ }
281
+ if (tag.startsWith("fy-") && tag !== "fy-composition" && tag !== "fy-transcript") {
282
+ this.warning("ELEMENT_UNKNOWN", `Unknown Frameyard element <${tag}>.`, this.elementDetails(node));
283
+ }
284
+ });
285
+ }
286
+ validateDocumentAttributes() {
287
+ walk(this.document, (node) => {
288
+ if (!isElement(node)) {
289
+ return;
290
+ }
291
+ for (const attribute of node.attrs) {
292
+ if (/^on[a-z]/i.test(attribute.name)) {
293
+ this.error("EVENT_HANDLER_NOT_ALLOWED", `Event handler attribute "${attribute.name}" is not allowed in a composition.`, this.attributeDetails(node, attribute.name));
294
+ }
295
+ if (/^\s*javascript:/i.test(attribute.value)) {
296
+ this.error("URL_NOT_ALLOWED", `"javascript:" URLs are not allowed in a composition.`, this.attributeDetails(node, attribute.name, { value: attribute.value }));
297
+ }
298
+ }
299
+ });
300
+ }
301
+ validateOutsideRoot() {
302
+ const body = getElementByTagName(this.document, "body");
303
+ const root = this.root;
304
+ if (!body || !root) {
305
+ return;
306
+ }
307
+ const inspect = (parent) => {
308
+ for (const child of parent.childNodes) {
309
+ if (child === root) {
310
+ continue;
311
+ }
312
+ if (isElement(child)) {
313
+ if (isDescendantOf(root, child)) {
314
+ inspect(child);
315
+ continue;
316
+ }
317
+ if (NON_VISUAL_ELEMENTS.has(child.tagName)) {
318
+ continue;
319
+ }
320
+ this.warning("CONTENT_OUTSIDE_ROOT", "Visual content outside <fy-composition> is not captured; move it inside the composition root.", this.elementDetails(child));
321
+ continue;
322
+ }
323
+ if (isTextNode(child) && child.value.trim().length > 0) {
324
+ this.warning("CONTENT_OUTSIDE_ROOT", "Text outside <fy-composition> is not captured; move it inside the composition root.", this.nodeDetails(child));
325
+ }
326
+ }
327
+ };
328
+ inspect(body);
329
+ }
330
+ validateBindings(element, repeatDepth) {
331
+ const repeat = getAttribute(element, "data-fy-repeat");
332
+ const condition = getAttribute(element, "data-fy-if");
333
+ const text = getAttribute(element, "data-fy-text");
334
+ const inRepeat = repeatDepth > 0;
335
+ if (repeat !== undefined) {
336
+ if (element.tagName !== "template") {
337
+ this.error("BINDING_REPEAT_INVALID", "data-fy-repeat must be used on a <template>.", this.attributeDetails(element, "data-fy-repeat"));
338
+ }
339
+ if (condition !== undefined) {
340
+ this.error("BINDING_INVALID", "A repeat template cannot also use data-fy-if.", this.attributeDetails(element, "data-fy-if"));
341
+ }
342
+ if (text !== undefined) {
343
+ this.error("BINDING_TARGET_INVALID", "A repeat template cannot use data-fy-text.", this.attributeDetails(element, "data-fy-text"));
344
+ }
345
+ if (inRepeat) {
346
+ this.error("BINDING_REPEAT_NESTED", "Nested data-fy-repeat is not supported; use a controller for nested data.", this.attributeDetails(element, "data-fy-repeat"));
347
+ }
348
+ this.validateBindingPathValue(repeat, element, "data-fy-repeat", {
349
+ inRepeat,
350
+ requireArray: true,
351
+ });
352
+ if (isTemplate(element)) {
353
+ for (const child of element.content.childNodes) {
354
+ if (isElement(child)) {
355
+ this.validateBindings(child, repeatDepth + 1);
356
+ }
357
+ }
358
+ }
359
+ return;
360
+ }
361
+ if (condition !== undefined) {
362
+ if (element === this.root) {
363
+ this.error("BINDING_INVALID", "data-fy-if cannot be used on the composition root.", this.attributeDetails(element, "data-fy-if"));
364
+ }
365
+ else {
366
+ this.validateBindingPathValue(condition, element, "data-fy-if", { inRepeat });
367
+ }
368
+ }
369
+ if (text !== undefined) {
370
+ if (element === this.root) {
371
+ this.error("BINDING_TARGET_INVALID", "data-fy-text cannot be used on the composition root.", this.attributeDetails(element, "data-fy-text"));
372
+ }
373
+ else if (VOID_ELEMENTS.has(element.tagName)) {
374
+ this.error("BINDING_TARGET_INVALID", `data-fy-text cannot be used on <${element.tagName}>.`, this.attributeDetails(element, "data-fy-text"));
375
+ }
376
+ else {
377
+ this.validateBindingPathValue(text, element, "data-fy-text", {
378
+ inRepeat,
379
+ checkValueType: "text",
380
+ });
381
+ }
382
+ }
383
+ for (const attribute of element.attrs) {
384
+ const name = attribute.name;
385
+ if (name === "data-fy-repeat" || name === "data-fy-if" || name === "data-fy-text") {
386
+ continue;
387
+ }
388
+ if (name === "data-fy") {
389
+ if (element.tagName !== "script" || getAttribute(element, "type")?.toLowerCase() !== "application/json") {
390
+ this.error("BINDING_INVALID", "data-fy is only valid on a JSON props block.", this.attributeDetails(element, name));
391
+ }
392
+ continue;
393
+ }
394
+ if (name.startsWith("data-fy-attr-")) {
395
+ this.validateAttributeBinding(element, name, attribute.value, inRepeat);
396
+ continue;
397
+ }
398
+ if (name.startsWith("data-fy-class-")) {
399
+ this.validateClassBinding(element, name, attribute.value, inRepeat);
400
+ continue;
401
+ }
402
+ if (isAnimationAttribute(name) || isMediaAttribute(name)) {
403
+ continue;
404
+ }
405
+ if (name.startsWith("data-fy-")) {
406
+ this.error("BINDING_INVALID", `Unknown Frameyard attribute "${name}". Known attributes: ${KNOWN_DATA_FY_ATTRIBUTES.join(", ")}.`, this.attributeDetails(element, name, { value: attribute.value }));
407
+ }
408
+ }
409
+ const children = isTemplate(element) ? element.content.childNodes : element.childNodes;
410
+ for (const child of children) {
411
+ if (isElement(child)) {
412
+ this.validateBindings(child, repeatDepth);
413
+ }
414
+ }
415
+ }
416
+ validateBindingPathValue(path, element, attribute, options) {
417
+ const validation = validateBindingPath(path);
418
+ if (!validation.ok) {
419
+ this.error("BINDING_INVALID", `Invalid ${attribute} path "${path}": ${validation.reason}`, this.attributeDetails(element, attribute, { value: path }));
420
+ return;
421
+ }
422
+ const root = path.split(".")[0];
423
+ if (options.inRepeat && (root === "item" || root === "index")) {
424
+ return;
425
+ }
426
+ const resolved = resolveBindingPath({ props: this.props }, path);
427
+ if (!resolved.found) {
428
+ const severity = options.requireArray ? "error" : "warning";
429
+ const code = options.requireArray ? "BINDING_REPEAT_INVALID" : "BINDING_PATH_MISSING";
430
+ this.report(severity, code, `${attribute} references prop "${path}", which is not in the resolved props.`, this.attributeDetails(element, attribute, {
431
+ value: path,
432
+ suggestion: suggestBindingPath(path, this.props),
433
+ }));
434
+ return;
435
+ }
436
+ if (options.requireArray && !Array.isArray(resolved.value)) {
437
+ this.error("BINDING_REPEAT_INVALID", `${attribute} must reference an array; "${path}" is ${describeValue(resolved.value)}.`, this.attributeDetails(element, attribute, { value: path }));
438
+ return;
439
+ }
440
+ if (options.checkValueType && resolved.value !== null && typeof resolved.value === "object") {
441
+ 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 }));
442
+ }
443
+ }
444
+ validateAttributeBinding(element, name, value, inRepeat) {
445
+ const target = name.slice("data-fy-attr-".length);
446
+ if (target.length === 0) {
447
+ this.error("BINDING_INVALID", "data-fy-attr-* requires an attribute name.", this.attributeDetails(element, name));
448
+ return;
449
+ }
450
+ if (/^on/i.test(target)) {
451
+ this.error("EVENT_HANDLER_NOT_ALLOWED", `data-fy-attr-${target} would set an event handler, which is not allowed.`, this.attributeDetails(element, name));
452
+ return;
453
+ }
454
+ this.validateBindingPathValue(value, element, name, {
455
+ inRepeat,
456
+ checkValueType: "attr",
457
+ });
458
+ }
459
+ validateClassBinding(element, name, value, inRepeat) {
460
+ const className = name.slice("data-fy-class-".length);
461
+ if (className.length === 0 || /\s/.test(className)) {
462
+ this.error("BINDING_INVALID", "data-fy-class-* requires one class name without whitespace.", this.attributeDetails(element, name));
463
+ return;
464
+ }
465
+ this.validateBindingPathValue(value, element, name, {
466
+ inRepeat,
467
+ checkValueType: "class",
468
+ });
469
+ }
470
+ validateAnimationTargets(entries) {
471
+ for (const entry of entries) {
472
+ if (!isElement(entry.element)) {
473
+ continue;
474
+ }
475
+ const tag = entry.element.tagName;
476
+ if (!NON_ANIMATED_ELEMENTS.has(tag)) {
477
+ continue;
478
+ }
479
+ for (const attribute of entry.attributes) {
480
+ if (attribute !== "data-fy-anim-start" && attribute !== "data-fy-anim-duration") {
481
+ continue;
482
+ }
483
+ this.warning("ANIMATION_NOT_FOUND", `${attribute} has no visual animation target on <${tag}>.`, this.attributeDetails(entry.element, attribute));
484
+ }
485
+ }
486
+ }
487
+ async validateMediaFiles(audio, embedded) {
488
+ for (const declaration of audio) {
489
+ if (await this.mediaFileExists(declaration)) {
490
+ this.audioClips.push(declaration.clip);
491
+ }
492
+ }
493
+ for (const declaration of embedded) {
494
+ if (await this.mediaFileExists(declaration)) {
495
+ this.embeddedAudioClips.push(declaration.clip);
496
+ }
497
+ }
498
+ }
499
+ async mediaFileExists(declaration) {
500
+ if (!isElement(declaration.element)) {
501
+ return false;
502
+ }
503
+ const file = await this.resolveLocalFile(declaration.clip.src, this.projectRoot, "MEDIA_INVALID", "Media", this.elementDetails(declaration.element));
504
+ return file !== null;
505
+ }
506
+ async validateTranscriptFiles(declarations) {
507
+ for (const declaration of declarations) {
508
+ if (!isElement(declaration.element)) {
509
+ continue;
510
+ }
511
+ const file = await this.resolveLocalFile(declaration.clip.src, this.projectRoot, "TRANSCRIPT_INVALID", "Transcript", this.elementDetails(declaration.element));
512
+ if (!file) {
513
+ continue;
514
+ }
515
+ this.transcriptClips.push(declaration.clip);
516
+ try {
517
+ const content = await readFile(file.absolute, "utf8");
518
+ const parsed = JSON.parse(content);
519
+ parseTranscript(parsed, { requireWords: declaration.clip.words });
520
+ }
521
+ catch (error) {
522
+ this.error(errorCode(error, "TRANSCRIPT_INVALID"), errorMessage(error), {
523
+ path: file.relative,
524
+ ...this.elementDetails(declaration.element),
525
+ });
526
+ }
527
+ }
528
+ }
529
+ async validateOrdinaryAssets() {
530
+ const assets = [];
531
+ for (const image of findElements(this.document, (element) => element.tagName === "img")) {
532
+ const src = getAttribute(image, "src");
533
+ if (src !== undefined) {
534
+ assets.push({ element: image, attribute: "src", value: src });
535
+ }
536
+ }
537
+ for (const link of findElements(this.document, (element) => element.tagName === "link")) {
538
+ const rel = getAttribute(link, "rel")?.toLowerCase().split(/\s+/) ?? [];
539
+ if (!rel.includes("stylesheet")) {
540
+ continue;
541
+ }
542
+ const href = getAttribute(link, "href");
543
+ if (href !== undefined) {
544
+ assets.push({ element: link, attribute: "href", value: href });
545
+ }
546
+ }
547
+ for (const asset of assets) {
548
+ if (isRemote(asset.value)) {
549
+ continue;
550
+ }
551
+ await this.resolveLocalFile(asset.value, this.compositionDir, "ASSET_NOT_FOUND", "Asset", this.attributeDetails(asset.element, asset.attribute, { value: asset.value }));
552
+ }
553
+ }
554
+ async resolveLocalFile(raw, baseDir, code, label, details) {
555
+ if (!isLocalRelative(raw)) {
556
+ this.error(code, `${label} path must be a local project file, not "${raw}".`, { ...details, value: raw });
557
+ return null;
558
+ }
559
+ const absolute = path.resolve(baseDir, raw);
560
+ if (absolute !== this.projectRoot && !absolute.startsWith(this.projectRoot + path.sep)) {
561
+ this.error(code, `${label} path escapes the project root: "${raw}".`, { ...details, value: raw });
562
+ return null;
563
+ }
564
+ try {
565
+ const file = await stat(absolute);
566
+ if (!file.isFile()) {
567
+ throw new Error("not a file");
568
+ }
569
+ }
570
+ catch {
571
+ this.error(code, `${label} file does not exist: "${raw}".`, { ...details, value: raw });
572
+ return null;
573
+ }
574
+ return {
575
+ absolute,
576
+ relative: path.relative(this.projectRoot, absolute).split(path.sep).join("/"),
577
+ };
578
+ }
579
+ }
580
+ export async function validateHtmlComposition(options) {
581
+ const projectRoot = path.resolve(options.projectRoot);
582
+ const entryPath = path.resolve(projectRoot, options.entry);
583
+ if (entryPath !== projectRoot && !entryPath.startsWith(projectRoot + path.sep)) {
584
+ return {
585
+ info: null,
586
+ diagnostics: [
587
+ {
588
+ severity: "error",
589
+ code: "COMPOSITION_INVALID",
590
+ message: `Composition entry escapes the project root: "${options.entry}".`,
591
+ details: { path: options.entry.replaceAll("\\", "/") },
592
+ },
593
+ ],
594
+ };
595
+ }
596
+ let html;
597
+ try {
598
+ html = await readFile(entryPath, "utf8");
599
+ }
600
+ catch {
601
+ return {
602
+ info: null,
603
+ diagnostics: [
604
+ {
605
+ severity: "error",
606
+ code: "COMPOSITION_NOT_FOUND",
607
+ message: `Composition entry does not exist: "${options.entry}".`,
608
+ details: { path: options.entry.replaceAll("\\", "/") },
609
+ },
610
+ ],
611
+ };
612
+ }
613
+ let document;
614
+ try {
615
+ document = parseHtml(html);
616
+ }
617
+ catch (error) {
618
+ return {
619
+ info: null,
620
+ diagnostics: [
621
+ {
622
+ severity: "error",
623
+ code: "COMPOSITION_INVALID",
624
+ message: `Could not parse composition HTML: ${errorMessage(error)}`,
625
+ details: { path: options.entry.replaceAll("\\", "/") },
626
+ },
627
+ ],
628
+ };
629
+ }
630
+ const validator = new HtmlCompositionValidator({ ...options, projectRoot }, document);
631
+ return validator.run();
632
+ }
633
+ function describeValue(value) {
634
+ if (value === null) {
635
+ return "null";
636
+ }
637
+ if (Array.isArray(value)) {
638
+ return "an array";
639
+ }
640
+ if (typeof value === "object") {
641
+ return "an object";
642
+ }
643
+ return `a ${typeof value}`;
644
+ }
645
+ function errorCode(error, fallback) {
646
+ return isFrameyardError(error) ? error.code : fallback;
647
+ }
648
+ //# sourceMappingURL=validate.js.map