@hyperframes/lint 0.8.6 → 0.8.8
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/dist/browser.d.ts +2 -0
- package/dist/browser.js +124 -79
- package/dist/browser.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +163 -84
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ type HyperframeLintFinding = {
|
|
|
8
8
|
elementId?: string;
|
|
9
9
|
fixHint?: string;
|
|
10
10
|
snippet?: string;
|
|
11
|
+
/** Optional standalone entry that command-specific guidance can act on. */
|
|
12
|
+
suggestedComposition?: string;
|
|
11
13
|
};
|
|
12
14
|
/**
|
|
13
15
|
* Where a single lint pass spent its time. Attributed per rule-source module
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ var TIMELINE_REGISTRY_ASSIGN_PATTERN = /window\.__timelines(?:\[[^\]]+\]|\.[A-Za
|
|
|
7
7
|
var WINDOW_TIMELINE_ASSIGN_PATTERN = /window\.__timelines(?:\[\s*(?:["']([^"']+)["']|[A-Za-z_$][\w$.]*)\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=\s*([A-Za-z_$][\w$]*)/i;
|
|
8
8
|
var INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*script(?!>)/i;
|
|
9
9
|
var TIMELINE_REGISTRY_KEY_PATTERN = /window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=/g;
|
|
10
|
-
var
|
|
10
|
+
var TIMELINE_REGISTRY_OBJECT_OPEN_PATTERN = /window\.__timelines\s*=\s*\{/i;
|
|
11
11
|
var TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN = /(?:["']([^"']+)["']|([A-Za-z_$][\w$]*))\s*:\s*[A-Za-z_$][\w$]*/g;
|
|
12
12
|
function parseHtmlStructure(source) {
|
|
13
13
|
const tags = [];
|
|
@@ -135,18 +135,48 @@ function extractTimelineRegistryKeys(source) {
|
|
|
135
135
|
const key = match[1] ?? match[2];
|
|
136
136
|
if (key) keys.add(key);
|
|
137
137
|
}
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
138
|
+
for (const entry of readTimelineRegistryTopLevelKeys(source)) keys.add(entry);
|
|
139
|
+
return [...keys];
|
|
140
|
+
}
|
|
141
|
+
function findMatchingBrace(source, bodyStart) {
|
|
142
|
+
let depth = 1;
|
|
143
|
+
for (let i = bodyStart; i < source.length; i += 1) {
|
|
144
|
+
if (source[i] === "{") depth += 1;
|
|
145
|
+
else if (source[i] === "}" && (depth -= 1) === 0) return i;
|
|
146
|
+
}
|
|
147
|
+
return source.length;
|
|
148
|
+
}
|
|
149
|
+
function blankNestedBraceGroups(body) {
|
|
150
|
+
let out = "";
|
|
151
|
+
let depth = 0;
|
|
152
|
+
for (const ch of body) {
|
|
153
|
+
if (ch === "{") depth += 1;
|
|
154
|
+
else if (ch === "}") depth = Math.max(0, depth - 1);
|
|
155
|
+
else if (depth === 0) {
|
|
156
|
+
out += ch;
|
|
157
|
+
continue;
|
|
147
158
|
}
|
|
159
|
+
out += " ";
|
|
148
160
|
}
|
|
149
|
-
return
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
function readTimelineRegistryTopLevelKeys(source) {
|
|
164
|
+
const open = TIMELINE_REGISTRY_OBJECT_OPEN_PATTERN.exec(source);
|
|
165
|
+
if (!open) return [];
|
|
166
|
+
const bodyStart = open.index + open[0].length;
|
|
167
|
+
const body = source.slice(bodyStart, findMatchingBrace(source, bodyStart));
|
|
168
|
+
const flattened = blankNestedBraceGroups(body);
|
|
169
|
+
const keys = [];
|
|
170
|
+
const entryPattern = new RegExp(
|
|
171
|
+
TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.source,
|
|
172
|
+
TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.flags
|
|
173
|
+
);
|
|
174
|
+
let entry;
|
|
175
|
+
while ((entry = entryPattern.exec(flattened)) !== null) {
|
|
176
|
+
const key = entry[1] ?? entry[2];
|
|
177
|
+
if (key) keys.push(key);
|
|
178
|
+
}
|
|
179
|
+
return keys;
|
|
150
180
|
}
|
|
151
181
|
function getInlineScriptSyntaxError(source) {
|
|
152
182
|
if (!source.trim()) return null;
|
|
@@ -158,6 +188,12 @@ function getInlineScriptSyntaxError(source) {
|
|
|
158
188
|
return String(error);
|
|
159
189
|
}
|
|
160
190
|
}
|
|
191
|
+
function stripStringLiterals(source) {
|
|
192
|
+
return source.replace(
|
|
193
|
+
/(['"])(?:\\.|(?!\1)[^\\\n])*\1?/g,
|
|
194
|
+
(literal) => literal[0] + " ".repeat(Math.max(0, literal.length - 1))
|
|
195
|
+
);
|
|
196
|
+
}
|
|
161
197
|
function stripJsComments(source) {
|
|
162
198
|
let out = "";
|
|
163
199
|
let i = 0;
|
|
@@ -251,6 +287,9 @@ function truncateSnippet(value, maxLength = 220) {
|
|
|
251
287
|
if (normalized.length <= maxLength) return normalized;
|
|
252
288
|
return `${normalized.slice(0, maxLength - 3)}...`;
|
|
253
289
|
}
|
|
290
|
+
function mediaSrcTagRe(tagAlternation) {
|
|
291
|
+
return new RegExp(`<(${tagAlternation})\\b[^>]*\\ssrc\\s*=\\s*["']([^"']+)["'][^>]*>`, "gi");
|
|
292
|
+
}
|
|
254
293
|
|
|
255
294
|
// src/context.ts
|
|
256
295
|
function buildLintContext(html, options = {}) {
|
|
@@ -453,6 +492,7 @@ var coreRules = [
|
|
|
453
492
|
return findings;
|
|
454
493
|
},
|
|
455
494
|
// root_missing_composition_id + root_missing_dimensions
|
|
495
|
+
// fallow-ignore-next-line complexity
|
|
456
496
|
({ rootTag }) => {
|
|
457
497
|
const findings = [];
|
|
458
498
|
if (!rootTag || !readDecodedAttr(rootTag.raw, "data-composition-id")) {
|
|
@@ -492,6 +532,7 @@ var coreRules = [
|
|
|
492
532
|
];
|
|
493
533
|
},
|
|
494
534
|
// missing_timeline_registry + timeline_registry_missing_init
|
|
535
|
+
// fallow-ignore-next-line complexity
|
|
495
536
|
({ source, rawSource, rootTag, options }) => {
|
|
496
537
|
if (options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template")) {
|
|
497
538
|
return [];
|
|
@@ -670,7 +711,10 @@ var coreRules = [
|
|
|
670
711
|
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time."
|
|
671
712
|
},
|
|
672
713
|
{
|
|
673
|
-
|
|
714
|
+
// Zero-arg only. `new Date(<fixed timestamp>)` is fully deterministic and is how
|
|
715
|
+
// a composition labels a fixed date on an axis or card; the hint ("remove
|
|
716
|
+
// time-dependent code") cannot be applied to it without deleting the label.
|
|
717
|
+
pattern: /new\s+Date\s*\(\s*\)/,
|
|
674
718
|
label: "new Date()",
|
|
675
719
|
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time."
|
|
676
720
|
},
|
|
@@ -691,15 +735,20 @@ var coreRules = [
|
|
|
691
735
|
},
|
|
692
736
|
{
|
|
693
737
|
// GSAP string form: "random(...)" / "+=random(...)" — re-rolls at tween init.
|
|
738
|
+
// `scansStrings` because here the string IS the executed value: GSAP parses it.
|
|
739
|
+
// Every other pattern above matches executable code, so a match inside a string
|
|
740
|
+
// literal is inert text and must not be reported.
|
|
694
741
|
pattern: /["'`](?:[+-]=)?random\(\s*[-\d[]/,
|
|
742
|
+
scansStrings: true,
|
|
695
743
|
label: '"random(...)" tween value',
|
|
696
744
|
hint: "GSAP random string values re-roll at tween init and each render worker initializes independently. Use fixed values or precompute with a seeded PRNG."
|
|
697
745
|
}
|
|
698
746
|
];
|
|
699
747
|
for (const script of scripts) {
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
748
|
+
const withoutComments = stripJsComments(script.content);
|
|
749
|
+
const executable = stripStringLiterals(withoutComments);
|
|
750
|
+
for (const { pattern, label, hint, scansStrings } of patterns) {
|
|
751
|
+
if (pattern.test(scansStrings ? withoutComments : executable)) {
|
|
703
752
|
findings.push({
|
|
704
753
|
code: "non_deterministic_code",
|
|
705
754
|
severity: "error",
|
|
@@ -1258,7 +1307,9 @@ var mediaRules = [
|
|
|
1258
1307
|
// audio_volume_double_automation
|
|
1259
1308
|
findVolumeDoubleAutomationFindings,
|
|
1260
1309
|
// audio_volume_tween_overrides_gain
|
|
1261
|
-
findVolumeTweenOverridesGainFindings
|
|
1310
|
+
findVolumeTweenOverridesGainFindings,
|
|
1311
|
+
// audio_carve_ungrouped_sources
|
|
1312
|
+
findCarveUngroupedSourcesFindings
|
|
1262
1313
|
];
|
|
1263
1314
|
function findVolumeTweenOverridesGainFindings(ctx) {
|
|
1264
1315
|
const boosted = ctx.tags.filter((tag) => isMediaTag(tag.name)).map((tag) => ({ tag, volume: Number(readAttr(tag.raw, "data-volume") ?? "1") })).filter((entry) => Number.isFinite(entry.volume) && entry.volume !== 1).filter((entry) => !readDecodedAttr(entry.tag.raw, "data-automation")).map((entry) => ({ ...entry, id: readAttr(entry.tag.raw, "id") })).filter((entry) => Boolean(entry.id));
|
|
@@ -1298,6 +1349,40 @@ function findVolumeDoubleAutomationFindings(ctx) {
|
|
|
1298
1349
|
}
|
|
1299
1350
|
return findings;
|
|
1300
1351
|
}
|
|
1352
|
+
function findCarveUngroupedSourcesFindings(ctx) {
|
|
1353
|
+
const groupIds = new Set(
|
|
1354
|
+
ctx.tags.filter((tag) => tag.name === "hf-audio-group").map((tag) => readAttr(tag.raw, "id"))
|
|
1355
|
+
);
|
|
1356
|
+
const findings = [];
|
|
1357
|
+
for (const tag of ctx.tags) {
|
|
1358
|
+
const raw = readDecodedAttr(tag.raw, "data-fx-carve");
|
|
1359
|
+
if (raw === null) continue;
|
|
1360
|
+
const trimmed = raw.trim();
|
|
1361
|
+
if (!trimmed.startsWith("{")) continue;
|
|
1362
|
+
let parsed;
|
|
1363
|
+
try {
|
|
1364
|
+
parsed = JSON.parse(trimmed);
|
|
1365
|
+
} catch {
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
const sources = parsed.sources;
|
|
1369
|
+
if (!Array.isArray(sources)) continue;
|
|
1370
|
+
const clipIds = sources.filter(
|
|
1371
|
+
(id) => typeof id === "string" && !groupIds.has(id)
|
|
1372
|
+
);
|
|
1373
|
+
if (clipIds.length < 2) continue;
|
|
1374
|
+
const elementId = readAttr(tag.raw, "id") || void 0;
|
|
1375
|
+
findings.push({
|
|
1376
|
+
code: "audio_carve_ungrouped_sources",
|
|
1377
|
+
severity: "warning",
|
|
1378
|
+
message: `${elementId ? `#${elementId}'s` : "This"} carve names ${clipIds.length} voice clips directly (${clipIds.join(", ")}) instead of a group.`,
|
|
1379
|
+
elementId,
|
|
1380
|
+
fixHint: "Group the voice clips and carve against the group \u2014 a hand-rolled clip list silently rots when a clip is added.",
|
|
1381
|
+
snippet: truncateSnippet(tag.raw)
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
return findings;
|
|
1385
|
+
}
|
|
1301
1386
|
|
|
1302
1387
|
// src/rules/gsap.ts
|
|
1303
1388
|
async function loadParseGsapScript() {
|
|
@@ -2100,9 +2185,9 @@ ${right.raw}`)
|
|
|
2100
2185
|
(candidate) => targetedSelectorTokens(firstVisible.targetSelector).has(candidate)
|
|
2101
2186
|
) || selectors[0] || tag.name;
|
|
2102
2187
|
const laterHidden = visibilityWindows.some(
|
|
2103
|
-
(win) => win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues)
|
|
2188
|
+
(win) => win !== firstVisible && win.method !== "from" && win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues)
|
|
2104
2189
|
);
|
|
2105
|
-
if (
|
|
2190
|
+
if (!laterHidden) continue;
|
|
2106
2191
|
reportedVisibleOverlayKeys.add(overlayKey);
|
|
2107
2192
|
findings.push({
|
|
2108
2193
|
code: "gsap_fullscreen_overlay_starts_visible",
|
|
@@ -3051,7 +3136,12 @@ var captionRules = [
|
|
|
3051
3136
|
severity: "warning",
|
|
3052
3137
|
selector: (selector ?? "").trim(),
|
|
3053
3138
|
message: `Caption selector "${(selector ?? "").trim()}" has white-space: nowrap but no max-width. Long phrases will clip off-screen.`,
|
|
3054
|
-
fixHint:
|
|
3139
|
+
fixHint: (
|
|
3140
|
+
// Deliberately does NOT say `overflow: hidden`: caption words are scaled
|
|
3141
|
+
// above 1.0x, and clipping them is exactly what caption_overflow_clips_scaled_words
|
|
3142
|
+
// errors on. Recommending it here made this warning's own fix produce an error.
|
|
3143
|
+
"Add max-width: 1600px (landscape) or max-width: 900px (portrait). Keep overflow visible so scaled emphasis words are not clipped."
|
|
3144
|
+
)
|
|
3055
3145
|
});
|
|
3056
3146
|
}
|
|
3057
3147
|
}
|
|
@@ -3179,7 +3269,7 @@ var captionRules = [
|
|
|
3179
3269
|
];
|
|
3180
3270
|
|
|
3181
3271
|
// src/rules/composition.ts
|
|
3182
|
-
import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers/composition";
|
|
3272
|
+
import { COMPOSITION_VARIABLE_TYPES, isSafeMediaUrl } from "@hyperframes/parsers/composition";
|
|
3183
3273
|
import { COMPOSITION_ATTRIBUTES, readClipTiming } from "@hyperframes/parsers/composition-contract";
|
|
3184
3274
|
var MAX_COMPOSITION_LINES = 300;
|
|
3185
3275
|
var MAX_TIMED_ELEMENTS_PER_TRACK = 3;
|
|
@@ -3206,7 +3296,6 @@ var HEAVY_OVERLAY_EXEMPT_TAGS = /* @__PURE__ */ new Set([
|
|
|
3206
3296
|
]);
|
|
3207
3297
|
var HEAVY_OVERLAY_CSS_PATTERN = /(?:filter\s*:[^;}]*\bblur\s*\()|(?:clip-path\s*:(?!\s*(?:none|inherit|initial|unset)\b)\s*[^;}]+)|(?:radial-gradient\s*\()/i;
|
|
3208
3298
|
var INLINE_STYLE_DISPLAY_NONE_PATTERN = /(?:^|;)\s*display\s*:\s*none\b/i;
|
|
3209
|
-
var OVERLAP_EPSILON_SECONDS = 1e-6;
|
|
3210
3299
|
function readTagTiming(rawTag) {
|
|
3211
3300
|
return readClipTiming({ getAttribute: (name) => readAttr(rawTag, name) });
|
|
3212
3301
|
}
|
|
@@ -3348,6 +3437,7 @@ var compositionRules = [
|
|
|
3348
3437
|
const tagsByCompositionId = /* @__PURE__ */ new Map();
|
|
3349
3438
|
for (const tag of tags) {
|
|
3350
3439
|
if (isInsideInertTemplate(tag, tags)) continue;
|
|
3440
|
+
if (readAttr(tag.raw, "data-composition-src")) continue;
|
|
3351
3441
|
const compositionId = readDecodedAttr(tag.raw, "data-composition-id");
|
|
3352
3442
|
if (!compositionId || compositionId.trim().length === 0) continue;
|
|
3353
3443
|
const matchingTags = tagsByCompositionId.get(compositionId) ?? [];
|
|
@@ -3571,64 +3661,6 @@ var compositionRules = [
|
|
|
3571
3661
|
}
|
|
3572
3662
|
return findings;
|
|
3573
3663
|
},
|
|
3574
|
-
// overlapping_clips_same_track
|
|
3575
|
-
// fallow-ignore-next-line complexity
|
|
3576
|
-
({ tags }) => {
|
|
3577
|
-
const findings = [];
|
|
3578
|
-
const trackMap = /* @__PURE__ */ new Map();
|
|
3579
|
-
for (const tag of tags) {
|
|
3580
|
-
const trackStr = readAttr(tag.raw, COMPOSITION_ATTRIBUTES.trackIndex);
|
|
3581
|
-
if (!trackStr) continue;
|
|
3582
|
-
const timing = readTagTiming(tag.raw);
|
|
3583
|
-
const { start, duration } = timing;
|
|
3584
|
-
const track = trackStr;
|
|
3585
|
-
if (start == null || duration == null) continue;
|
|
3586
|
-
const clips = trackMap.get(track) || [];
|
|
3587
|
-
clips.push({
|
|
3588
|
-
start,
|
|
3589
|
-
end: start + duration,
|
|
3590
|
-
elementId: readAttr(tag.raw, "id") || void 0,
|
|
3591
|
-
snippet: truncateSnippet(tag.raw) || ""
|
|
3592
|
-
});
|
|
3593
|
-
trackMap.set(track, clips);
|
|
3594
|
-
}
|
|
3595
|
-
for (const [track, clips] of trackMap) {
|
|
3596
|
-
clips.sort((a, b) => a.start - b.start);
|
|
3597
|
-
for (let i = 0; i < clips.length - 1; i++) {
|
|
3598
|
-
const current = clips[i];
|
|
3599
|
-
const next = clips[i + 1];
|
|
3600
|
-
if (!current || !next) continue;
|
|
3601
|
-
if (current.end - next.start > OVERLAP_EPSILON_SECONDS) {
|
|
3602
|
-
findings.push({
|
|
3603
|
-
code: "overlapping_clips_same_track",
|
|
3604
|
-
severity: "error",
|
|
3605
|
-
message: `Track ${track}: clip ending at ${current.end}s overlaps with clip starting at ${next.start}s. Overlapping clips on the same track cause rendering conflicts.`,
|
|
3606
|
-
fixHint: "Adjust data-start or data-duration so clips on the same track do not overlap, or move one clip to a different data-track-index."
|
|
3607
|
-
});
|
|
3608
|
-
}
|
|
3609
|
-
}
|
|
3610
|
-
}
|
|
3611
|
-
return findings;
|
|
3612
|
-
},
|
|
3613
|
-
// root_composition_missing_data_start
|
|
3614
|
-
({ rootTag, options }) => {
|
|
3615
|
-
const findings = [];
|
|
3616
|
-
if (options.isSubComposition) return findings;
|
|
3617
|
-
if (!rootTag) return findings;
|
|
3618
|
-
const compId = readDecodedAttr(rootTag.raw, "data-composition-id");
|
|
3619
|
-
if (!compId) return findings;
|
|
3620
|
-
const hasStart = readAttr(rootTag.raw, "data-start") !== null;
|
|
3621
|
-
if (!hasStart) {
|
|
3622
|
-
findings.push({
|
|
3623
|
-
code: "root_composition_missing_data_start",
|
|
3624
|
-
severity: "error",
|
|
3625
|
-
message: `Root composition "${compId}" is missing data-start. The runtime needs data-start="0" on the root element to begin playback.`,
|
|
3626
|
-
fixHint: 'Add data-start="0" to the root composition element.',
|
|
3627
|
-
snippet: truncateSnippet(rootTag.raw)
|
|
3628
|
-
});
|
|
3629
|
-
}
|
|
3630
|
-
return findings;
|
|
3631
|
-
},
|
|
3632
3664
|
// standalone_composition_wrapped_in_template
|
|
3633
3665
|
({ rawSource, options }) => {
|
|
3634
3666
|
const findings = [];
|
|
@@ -3808,6 +3840,11 @@ var compositionRules = [
|
|
|
3808
3840
|
}
|
|
3809
3841
|
const findings = [];
|
|
3810
3842
|
const knownTypes = new Set(COMPOSITION_VARIABLE_TYPES);
|
|
3843
|
+
const varSrcIds = /* @__PURE__ */ new Set();
|
|
3844
|
+
for (const tag of tags) {
|
|
3845
|
+
const bound = readAttr(tag.raw, "data-var-src");
|
|
3846
|
+
if (bound) varSrcIds.add(bound);
|
|
3847
|
+
}
|
|
3811
3848
|
for (let i = 0; i < parsed.length; i += 1) {
|
|
3812
3849
|
const entry = parsed[i];
|
|
3813
3850
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
@@ -3832,6 +3869,17 @@ var compositionRules = [
|
|
|
3832
3869
|
message: `data-composition-variables entry [${i}] is missing or has invalid: ${missing.join(", ")}. Type must be one of string, number, color, boolean, enum, font, image.`,
|
|
3833
3870
|
snippet: truncateSnippet(htmlTag.raw)
|
|
3834
3871
|
});
|
|
3872
|
+
continue;
|
|
3873
|
+
}
|
|
3874
|
+
const id = String(e.id);
|
|
3875
|
+
if ((e.type === "image" || varSrcIds.has(id)) && typeof e.default === "string" && e.default.length > 0 && !isSafeMediaUrl(e.default)) {
|
|
3876
|
+
findings.push({
|
|
3877
|
+
code: "unloadable_media_variable_default",
|
|
3878
|
+
severity: "error",
|
|
3879
|
+
message: `Variable "${id}" defaults to a URL the runtime will refuse to load, so any element bound to it renders its authored fallback src instead and the render still exits 0.`,
|
|
3880
|
+
fixHint: `Media URLs must be relative, http(s), blob:, or a data:image/* URI. Copy the file into the project and reference it relatively (e.g. "assets/bg.png") rather than by absolute path.`,
|
|
3881
|
+
snippet: truncateSnippet(htmlTag.raw)
|
|
3882
|
+
});
|
|
3835
3883
|
}
|
|
3836
3884
|
}
|
|
3837
3885
|
return findings;
|
|
@@ -4743,13 +4791,13 @@ async function probeIsHevc(ffprobePath, filePath) {
|
|
|
4743
4791
|
}
|
|
4744
4792
|
function collectLocalVideoCandidates(projectDir, htmlSources) {
|
|
4745
4793
|
const candidates = /* @__PURE__ */ new Map();
|
|
4746
|
-
const videoSrcRe =
|
|
4794
|
+
const videoSrcRe = mediaSrcTagRe("video");
|
|
4747
4795
|
for (const { html, compSrcPath } of htmlSources) {
|
|
4748
4796
|
const scannable = maskNonScannableRanges(html);
|
|
4749
4797
|
const re = new RegExp(videoSrcRe.source, videoSrcRe.flags);
|
|
4750
4798
|
let match;
|
|
4751
4799
|
while ((match = re.exec(scannable)) !== null) {
|
|
4752
|
-
const rawSrc = match[
|
|
4800
|
+
const rawSrc = match[2] ?? "";
|
|
4753
4801
|
if (isUnresolvedAssetPlaceholder(rawSrc)) continue;
|
|
4754
4802
|
const src = cleanAssetUrl(rawSrc);
|
|
4755
4803
|
if (!src) continue;
|
|
@@ -4936,6 +4984,7 @@ async function lintProject(projectDir, entryFile) {
|
|
|
4936
4984
|
...lintMissingLocalAsset(projectDir, allHtmlSources),
|
|
4937
4985
|
...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
|
|
4938
4986
|
...!entryFile ? lintMultipleRootCompositions(projectDir) : [],
|
|
4987
|
+
...!entryFile ? lintBlankRootWithStandaloneComposition(rootHtml, allHtmlSources) : [],
|
|
4939
4988
|
...lintDuplicateAudioTracks(allHtmlSources),
|
|
4940
4989
|
...lintMissingOrEmptySubComposition(projectDir, rootHtml),
|
|
4941
4990
|
...await lintHevcPreviewCodec(collectLocalVideoCandidates(projectDir, allHtmlSources))
|
|
@@ -4958,6 +5007,36 @@ async function lintProject(projectDir, entryFile) {
|
|
|
4958
5007
|
}
|
|
4959
5008
|
return { results, totalErrors, totalWarnings, totalInfos };
|
|
4960
5009
|
}
|
|
5010
|
+
function lintBlankRootWithStandaloneComposition(rootHtml, htmlSources) {
|
|
5011
|
+
const { document: rootDocument } = parseHTML(rootHtml);
|
|
5012
|
+
const root = rootDocument.querySelector("body [data-composition-id]");
|
|
5013
|
+
if (!root || root.querySelector("*:not(script):not(style):not(link):not(meta):not(template)")) {
|
|
5014
|
+
return [];
|
|
5015
|
+
}
|
|
5016
|
+
const standaloneCandidates = [];
|
|
5017
|
+
for (const source of htmlSources) {
|
|
5018
|
+
if (!source.compSrcPath) continue;
|
|
5019
|
+
const { document } = parseHTML(source.html);
|
|
5020
|
+
const composition = document.querySelector("body [data-composition-id]");
|
|
5021
|
+
if (!composition) continue;
|
|
5022
|
+
const authoredTimedContent = Array.from(
|
|
5023
|
+
composition.querySelectorAll(
|
|
5024
|
+
".clip, [data-start], [data-end], video, audio, img, svg, canvas"
|
|
5025
|
+
)
|
|
5026
|
+
).some((element) => !element.hasAttribute("data-composition-src"));
|
|
5027
|
+
if (authoredTimedContent) standaloneCandidates.push(source.compSrcPath);
|
|
5028
|
+
}
|
|
5029
|
+
if (standaloneCandidates.length === 0) return [];
|
|
5030
|
+
return [
|
|
5031
|
+
{
|
|
5032
|
+
code: "blank_root_with_standalone_composition",
|
|
5033
|
+
severity: "error",
|
|
5034
|
+
message: `The default index.html composition has no renderable content, but ${standaloneCandidates.join(", ")} contains a standalone timed composition. Default check, snapshot, preview, render, and publish commands open index.html, so they will capture or publish only its background.`,
|
|
5035
|
+
fixHint: `Move the authored composition into index.html, or mount it from index.html with data-composition-src and the sub-composition <template> contract. If the separate file is intentional, render it explicitly with --composition ${standaloneCandidates[0]}.`,
|
|
5036
|
+
suggestedComposition: standaloneCandidates[0]
|
|
5037
|
+
}
|
|
5038
|
+
];
|
|
5039
|
+
}
|
|
4961
5040
|
function lintProjectAudioFiles(projectDir, htmlSources) {
|
|
4962
5041
|
const findings = [];
|
|
4963
5042
|
let audioFiles;
|
|
@@ -4982,12 +5061,12 @@ function lintProjectAudioFiles(projectDir, htmlSources) {
|
|
|
4982
5061
|
}
|
|
4983
5062
|
function lintAudioSrcNotFound(projectDir, htmlSources) {
|
|
4984
5063
|
const findings = [];
|
|
4985
|
-
const audioSrcRe =
|
|
5064
|
+
const audioSrcRe = mediaSrcTagRe("audio");
|
|
4986
5065
|
const missingSrcs = [];
|
|
4987
5066
|
for (const { html, compSrcPath } of htmlSources) {
|
|
4988
5067
|
let match;
|
|
4989
5068
|
while ((match = audioSrcRe.exec(html)) !== null) {
|
|
4990
|
-
const src = match[
|
|
5069
|
+
const src = match[2];
|
|
4991
5070
|
if (/^(https?:|data:|blob:)/i.test(src)) continue;
|
|
4992
5071
|
if (isUnresolvedAssetPlaceholder2(src)) continue;
|
|
4993
5072
|
const rootRelative = compSrcPath ? rewriteAssetPath2(compSrcPath, src, (path) => existsSync2(join2(projectDir, path))) : src;
|
|
@@ -5009,7 +5088,7 @@ function lintAudioSrcNotFound(projectDir, htmlSources) {
|
|
|
5009
5088
|
}
|
|
5010
5089
|
function lintMissingLocalAsset(projectDir, htmlSources) {
|
|
5011
5090
|
const findings = [];
|
|
5012
|
-
const localAssetSrcRe =
|
|
5091
|
+
const localAssetSrcRe = mediaSrcTagRe("video|img|source");
|
|
5013
5092
|
const missingByTag = /* @__PURE__ */ new Map();
|
|
5014
5093
|
for (const { html, compSrcPath } of htmlSources) {
|
|
5015
5094
|
const scannable = maskNonScannableRanges2(html);
|