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.
- package/CHANGELOG.md +117 -0
- package/GLOSSARY.md +46 -2
- package/README.md +174 -106
- package/dist/bindings.d.ts +42 -0
- package/dist/bindings.d.ts.map +1 -0
- package/dist/bindings.js +144 -0
- package/dist/bindings.js.map +1 -0
- package/dist/cli.js +32 -21
- package/dist/cli.js.map +1 -1
- package/dist/composition/adapter.d.ts +30 -0
- package/dist/composition/adapter.d.ts.map +1 -0
- package/dist/composition/adapter.js +2 -0
- package/dist/composition/adapter.js.map +1 -0
- package/dist/composition/animation.d.ts +19 -0
- package/dist/composition/animation.d.ts.map +1 -0
- package/dist/composition/animation.js +55 -0
- package/dist/composition/animation.js.map +1 -0
- package/dist/composition/attributes.d.ts +7 -0
- package/dist/composition/attributes.d.ts.map +1 -0
- package/dist/composition/attributes.js +24 -0
- package/dist/composition/attributes.js.map +1 -0
- package/dist/composition/descriptor.d.ts +30 -0
- package/dist/composition/descriptor.d.ts.map +1 -0
- package/dist/composition/descriptor.js +188 -0
- package/dist/composition/descriptor.js.map +1 -0
- package/dist/composition/diagnostics.d.ts +12 -0
- package/dist/composition/diagnostics.d.ts.map +1 -0
- package/dist/composition/diagnostics.js +38 -0
- package/dist/composition/diagnostics.js.map +1 -0
- package/dist/composition/index.d.ts +12 -0
- package/dist/composition/index.d.ts.map +1 -0
- package/dist/composition/index.js +7 -0
- package/dist/composition/index.js.map +1 -0
- package/dist/composition/media.d.ts +27 -0
- package/dist/composition/media.d.ts.map +1 -0
- package/dist/composition/media.js +162 -0
- package/dist/composition/media.js.map +1 -0
- package/dist/composition/paths.d.ts +5 -0
- package/dist/composition/paths.d.ts.map +1 -0
- package/dist/composition/paths.js +16 -0
- package/dist/composition/paths.js.map +1 -0
- package/dist/composition/transcripts.d.ts +19 -0
- package/dist/composition/transcripts.d.ts.map +1 -0
- package/dist/composition/transcripts.js +57 -0
- package/dist/composition/transcripts.js.map +1 -0
- package/dist/composition/walk.d.ts +10 -0
- package/dist/composition/walk.d.ts.map +1 -0
- package/dist/composition/walk.js +32 -0
- package/dist/composition/walk.js.map +1 -0
- package/dist/contracts.d.ts +75 -47
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +14 -159
- package/dist/contracts.js.map +1 -1
- package/dist/errors.d.ts +15 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +8 -0
- package/dist/errors.js.map +1 -1
- package/dist/html/document-adapter.d.ts +5 -0
- package/dist/html/document-adapter.d.ts.map +1 -0
- package/dist/html/document-adapter.js +17 -0
- package/dist/html/document-adapter.js.map +1 -0
- package/dist/html/index.d.ts +5 -0
- package/dist/html/index.d.ts.map +1 -0
- package/dist/html/index.js +3 -0
- package/dist/html/index.js.map +1 -0
- package/dist/html/parse.d.ts +26 -0
- package/dist/html/parse.d.ts.map +1 -0
- package/dist/html/parse.js +80 -0
- package/dist/html/parse.js.map +1 -0
- package/dist/html/validate.d.ts +14 -0
- package/dist/html/validate.d.ts.map +1 -0
- package/dist/html/validate.js +648 -0
- package/dist/html/validate.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/preview/main.d.ts +10 -3
- package/dist/preview/main.d.ts.map +1 -1
- package/dist/preview/main.js +189 -89
- package/dist/preview/main.js.map +1 -1
- package/dist/project.d.ts +2 -0
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +4 -0
- package/dist/project.js.map +1 -1
- package/dist/renderer/browser.d.ts +11 -1
- package/dist/renderer/browser.d.ts.map +1 -1
- package/dist/renderer/browser.js +93 -14
- package/dist/renderer/browser.js.map +1 -1
- package/dist/renderer/index.d.ts +25 -5
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +82 -16
- package/dist/renderer/index.js.map +1 -1
- package/dist/runtime/bindings.d.ts +11 -0
- package/dist/runtime/bindings.d.ts.map +1 -0
- package/dist/runtime/bindings.js +164 -0
- package/dist/runtime/bindings.js.map +1 -0
- package/dist/runtime/diagnostics.d.ts +4 -0
- package/dist/runtime/diagnostics.d.ts.map +1 -0
- package/dist/runtime/diagnostics.js +107 -0
- package/dist/runtime/diagnostics.js.map +1 -0
- package/dist/runtime/document-adapter.d.ts +4 -0
- package/dist/runtime/document-adapter.d.ts.map +1 -0
- package/dist/runtime/document-adapter.js +19 -0
- package/dist/runtime/document-adapter.js.map +1 -0
- package/dist/runtime/host.d.ts +14 -0
- package/dist/runtime/host.d.ts.map +1 -0
- package/dist/runtime/host.js +596 -0
- package/dist/runtime/host.js.map +1 -0
- package/dist/runtime/index.d.ts +0 -29
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +0 -360
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/media.d.ts.map +1 -1
- package/dist/runtime/media.js +20 -51
- package/dist/runtime/media.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +177 -84
- package/dist/scaffold.js.map +1 -1
- package/dist/server/composition-html.d.ts +20 -0
- package/dist/server/composition-html.d.ts.map +1 -0
- package/dist/server/composition-html.js +45 -0
- package/dist/server/composition-html.js.map +1 -0
- package/dist/server/preview-html.d.ts.map +1 -1
- package/dist/server/preview-html.js +19 -14
- package/dist/server/preview-html.js.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +94 -6
- package/dist/server/server.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/adr/0001-keep-composition-authoring-web-native.md +2 -0
- package/docs/adr/0006-seek-embedded-media-from-the-frame-clock.md +7 -1
- package/docs/adr/0007-make-html-documents-the-composition-unit.md +31 -0
- package/docs/adr/0008-share-the-composition-document-grammar.md +18 -0
- package/docs/adr/0009-composite-production-frames-in-software.md +37 -0
- package/docs/migration-0.4.md +279 -0
- package/docs/v0.1-design.md +78 -56
- package/examples/demo/README.md +8 -4
- package/examples/demo/compositions/demo.controller.js +120 -0
- package/examples/demo/compositions/demo.html +253 -0
- package/examples/demo/video.config.js +1 -1
- package/examples/video/README.md +61 -0
- package/examples/video/assets/clip.mp4 +0 -0
- package/examples/video/assets/clip.webm +0 -0
- package/examples/video/compositions/video-mp4.html +362 -0
- package/examples/video/compositions/video.html +362 -0
- package/examples/video/props/video.json +8 -0
- package/examples/video/scripts/make-clip.sh +109 -0
- package/examples/video/video.config.js +6 -0
- package/examples/webgl/README.md +30 -0
- package/examples/webgl/compositions/webgl.controller.js +200 -0
- package/examples/webgl/compositions/webgl.html +215 -0
- package/examples/webgl/props/webgl.json +6 -0
- package/examples/webgl/video.config.js +5 -0
- package/package.json +7 -3
- package/skills/frameyard/README.md +4 -0
- package/skills/frameyard/SKILL.md +40 -12
- package/skills/frameyard/references/audio-assets.md +25 -14
- package/skills/frameyard/references/bindings.md +73 -0
- package/skills/frameyard/references/common-mistakes.md +59 -0
- package/skills/frameyard/references/controllers.md +126 -0
- package/skills/frameyard/references/deterministic-authoring.md +143 -52
- package/skills/frameyard/references/diagnostics.md +90 -0
- package/skills/frameyard/references/failure-recovery.md +37 -14
- package/skills/frameyard/references/json-results.md +43 -9
- package/skills/frameyard/references/subtitles-karaoke.md +10 -18
- 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
|