@hyperframes/lint 0.8.5 → 0.8.7

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/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 TIMELINE_REGISTRY_OBJECT_BODY_PATTERN = /window\.__timelines\s*=\s*\{([\s\S]*?)\}/i;
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 objectBody = TIMELINE_REGISTRY_OBJECT_BODY_PATTERN.exec(source)?.[1];
139
- if (objectBody) {
140
- const entryPattern = new RegExp(
141
- TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.source,
142
- TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.flags
143
- );
144
- while ((match = entryPattern.exec(objectBody)) !== null) {
145
- const key = match[1] ?? match[2];
146
- if (key) keys.add(key);
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 [...keys];
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;
@@ -392,54 +428,8 @@ function describeStudioElement(tag) {
392
428
  parts.push(">");
393
429
  return parts.join("");
394
430
  }
395
- var HEAD_BLOCKS_TO_IGNORE_PATTERN = /<(?:style|script|template|title|noscript)\b[^>]*>[\s\S]*?<\/(?:style|script|template|title|noscript)(?:\s[^>]*)?>/gi;
396
- var HTML_TAG_PATTERN = /<[^>]+>/g;
397
- var HEAD_CONTENT_PATTERN = /<head\b[^>]*>([\s\S]*?)(?:<\/head>|<body\b|$)/gi;
398
- var AFTER_HEAD_BEFORE_BODY_PATTERN = /<\/head(?:\s[^>]*)?>([\s\S]*?)(?=<body\b|$)/gi;
399
- var STRAY_HEAD_CLOSE_PATTERN = /<\/(?:style|script)(?:\s[^>]*)?>/i;
400
- var MARKDOWN_CODE_FENCE_PATTERN = /```[^\r\n`]*(?:\r?\n|$)[\s\S]*?```/i;
401
- var ORPHAN_CSS_AT_RULE_PATTERN = /(?:^|\s)@(?:container|font-face|keyframes|layer|media|page|property|scope|supports)[^{<]*\{[\s\S]*?:[\s\S]*?\}/i;
402
- var ORPHAN_CSS_RULE_PATTERN = /(?:^|\s)(?:\/\*[\s\S]*?\*\/\s*)?(?:@[a-z-]+[^{}<]*|[.#][\w-]+[^{}<]*|[a-z][\w-]*(?:\s+[.#:[\w-][^{}<]*)?)\s*\{[^{}]*:[^{}]*\}/i;
403
431
  var VISIBLE_MARKUP_COMMENT_PATTERN = /\/\*[\s\S]*?\*\//g;
404
432
  var VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN = /<(style|script|template|title|noscript|pre|code|textarea|text)\b[^>]*>[\s\S]*?<\/\1(?:\s[^>]*)?>/gi;
405
- function findCodeFenceLeak(headWithoutValidBlocks) {
406
- return MARKDOWN_CODE_FENCE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
407
- }
408
- function findOrphanCssLeak(headContent) {
409
- const residualText = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ").replace(HTML_TAG_PATTERN, " ");
410
- return ORPHAN_CSS_AT_RULE_PATTERN.exec(residualText)?.[0] ?? ORPHAN_CSS_RULE_PATTERN.exec(residualText)?.[0] ?? null;
411
- }
412
- function findStrayCloseLeak(headWithoutValidBlocks) {
413
- return STRAY_HEAD_CLOSE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
414
- }
415
- function findLeakedTextInHeadContent(headContent) {
416
- const withoutValidBlocks = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ");
417
- return findCodeFenceLeak(withoutValidBlocks) ?? findOrphanCssLeak(headContent) ?? findStrayCloseLeak(withoutValidBlocks);
418
- }
419
- function findLeakedTextInHead(rawSource) {
420
- const headMatches = [...rawSource.matchAll(HEAD_CONTENT_PATTERN)];
421
- for (const match of headMatches) {
422
- const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
423
- if (leakedText) return leakedText;
424
- }
425
- return null;
426
- }
427
- function findLeakedTextBetweenHeadAndBody(rawSource) {
428
- const boundaryMatches = [...rawSource.matchAll(AFTER_HEAD_BEFORE_BODY_PATTERN)];
429
- for (const match of boundaryMatches) {
430
- const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
431
- if (leakedText) return leakedText;
432
- }
433
- return null;
434
- }
435
- function findLeakedTextBeforeCompositionRoot(source, rootTag) {
436
- if (!rootTag || rootTag.name === "body") return null;
437
- const bodyOpenMatch = /<body\b[^>]*>/i.exec(source);
438
- const prefixStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0;
439
- const prefixEnd = rootTag.index;
440
- if (prefixEnd <= prefixStart) return null;
441
- return findLeakedTextInHeadContent(source.slice(prefixStart, prefixEnd));
442
- }
443
433
  function findProtectedVisibleMarkupRanges(source) {
444
434
  const ranges = [];
445
435
  for (const match of source.matchAll(VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN)) {
@@ -499,6 +489,7 @@ var coreRules = [
499
489
  return findings;
500
490
  },
501
491
  // root_missing_composition_id + root_missing_dimensions
492
+ // fallow-ignore-next-line complexity
502
493
  ({ rootTag }) => {
503
494
  const findings = [];
504
495
  if (!rootTag || !readDecodedAttr(rootTag.raw, "data-composition-id")) {
@@ -523,20 +514,6 @@ var coreRules = [
523
514
  }
524
515
  return findings;
525
516
  },
526
- // head_leaked_text
527
- ({ source, rootTag }) => {
528
- const snippet = findLeakedTextInHead(source) ?? findLeakedTextBetweenHeadAndBody(source) ?? findLeakedTextBeforeCompositionRoot(source, rootTag);
529
- if (!snippet) return [];
530
- return [
531
- {
532
- code: "head_leaked_text",
533
- severity: "error",
534
- message: "Detected leaked code or CSS text around the document `<head>` or before the composition root. Browsers render this as visible text in the video.",
535
- fixHint: "Move CSS into a single `<style>...</style>` block and remove stray close tags, markdown fences, or code text from `<head>`, the `</head>`/`<body>` boundary, or the pre-root body prefix.",
536
- snippet: truncateSnippet(snippet)
537
- }
538
- ];
539
- },
540
517
  // visible_markup_comment
541
518
  ({ source }) => {
542
519
  const snippet = findVisibleMarkupCommentLeak(source);
@@ -552,6 +529,7 @@ var coreRules = [
552
529
  ];
553
530
  },
554
531
  // missing_timeline_registry + timeline_registry_missing_init
532
+ // fallow-ignore-next-line complexity
555
533
  ({ source, rawSource, rootTag, options }) => {
556
534
  if (options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template")) {
557
535
  return [];
@@ -730,7 +708,10 @@ var coreRules = [
730
708
  hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time."
731
709
  },
732
710
  {
733
- pattern: /new\s+Date\s*\(/,
711
+ // Zero-arg only. `new Date(<fixed timestamp>)` is fully deterministic and is how
712
+ // a composition labels a fixed date on an axis or card; the hint ("remove
713
+ // time-dependent code") cannot be applied to it without deleting the label.
714
+ pattern: /new\s+Date\s*\(\s*\)/,
734
715
  label: "new Date()",
735
716
  hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time."
736
717
  },
@@ -751,15 +732,20 @@ var coreRules = [
751
732
  },
752
733
  {
753
734
  // GSAP string form: "random(...)" / "+=random(...)" — re-rolls at tween init.
735
+ // `scansStrings` because here the string IS the executed value: GSAP parses it.
736
+ // Every other pattern above matches executable code, so a match inside a string
737
+ // literal is inert text and must not be reported.
754
738
  pattern: /["'`](?:[+-]=)?random\(\s*[-\d[]/,
739
+ scansStrings: true,
755
740
  label: '"random(...)" tween value',
756
741
  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."
757
742
  }
758
743
  ];
759
744
  for (const script of scripts) {
760
- const stripped = stripJsComments(script.content);
761
- for (const { pattern, label, hint } of patterns) {
762
- if (pattern.test(stripped)) {
745
+ const withoutComments = stripJsComments(script.content);
746
+ const executable = stripStringLiterals(withoutComments);
747
+ for (const { pattern, label, hint, scansStrings } of patterns) {
748
+ if (pattern.test(scansStrings ? withoutComments : executable)) {
763
749
  findings.push({
764
750
  code: "non_deterministic_code",
765
751
  severity: "error",
@@ -1318,7 +1304,9 @@ var mediaRules = [
1318
1304
  // audio_volume_double_automation
1319
1305
  findVolumeDoubleAutomationFindings,
1320
1306
  // audio_volume_tween_overrides_gain
1321
- findVolumeTweenOverridesGainFindings
1307
+ findVolumeTweenOverridesGainFindings,
1308
+ // audio_carve_ungrouped_sources
1309
+ findCarveUngroupedSourcesFindings
1322
1310
  ];
1323
1311
  function findVolumeTweenOverridesGainFindings(ctx) {
1324
1312
  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));
@@ -1358,6 +1346,40 @@ function findVolumeDoubleAutomationFindings(ctx) {
1358
1346
  }
1359
1347
  return findings;
1360
1348
  }
1349
+ function findCarveUngroupedSourcesFindings(ctx) {
1350
+ const groupIds = new Set(
1351
+ ctx.tags.filter((tag) => tag.name === "hf-audio-group").map((tag) => readAttr(tag.raw, "id"))
1352
+ );
1353
+ const findings = [];
1354
+ for (const tag of ctx.tags) {
1355
+ const raw = readDecodedAttr(tag.raw, "data-fx-carve");
1356
+ if (raw === null) continue;
1357
+ const trimmed = raw.trim();
1358
+ if (!trimmed.startsWith("{")) continue;
1359
+ let parsed;
1360
+ try {
1361
+ parsed = JSON.parse(trimmed);
1362
+ } catch {
1363
+ continue;
1364
+ }
1365
+ const sources = parsed.sources;
1366
+ if (!Array.isArray(sources)) continue;
1367
+ const clipIds = sources.filter(
1368
+ (id) => typeof id === "string" && !groupIds.has(id)
1369
+ );
1370
+ if (clipIds.length < 2) continue;
1371
+ const elementId = readAttr(tag.raw, "id") || void 0;
1372
+ findings.push({
1373
+ code: "audio_carve_ungrouped_sources",
1374
+ severity: "warning",
1375
+ message: `${elementId ? `#${elementId}'s` : "This"} carve names ${clipIds.length} voice clips directly (${clipIds.join(", ")}) instead of a group.`,
1376
+ elementId,
1377
+ fixHint: "Group the voice clips and carve against the group \u2014 a hand-rolled clip list silently rots when a clip is added.",
1378
+ snippet: truncateSnippet(tag.raw)
1379
+ });
1380
+ }
1381
+ return findings;
1382
+ }
1361
1383
 
1362
1384
  // src/rules/gsap.ts
1363
1385
  async function loadParseGsapScript() {
@@ -2160,9 +2182,9 @@ ${right.raw}`)
2160
2182
  (candidate) => targetedSelectorTokens(firstVisible.targetSelector).has(candidate)
2161
2183
  ) || selectors[0] || tag.name;
2162
2184
  const laterHidden = visibilityWindows.some(
2163
- (win) => win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues)
2185
+ (win) => win !== firstVisible && win.method !== "from" && win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues)
2164
2186
  );
2165
- if (firstVisible.method !== "from" && !laterHidden) continue;
2187
+ if (!laterHidden) continue;
2166
2188
  reportedVisibleOverlayKeys.add(overlayKey);
2167
2189
  findings.push({
2168
2190
  code: "gsap_fullscreen_overlay_starts_visible",
@@ -3111,7 +3133,12 @@ var captionRules = [
3111
3133
  severity: "warning",
3112
3134
  selector: (selector ?? "").trim(),
3113
3135
  message: `Caption selector "${(selector ?? "").trim()}" has white-space: nowrap but no max-width. Long phrases will clip off-screen.`,
3114
- fixHint: "Add max-width: 1600px (landscape) or max-width: 900px (portrait) and overflow: hidden."
3136
+ fixHint: (
3137
+ // Deliberately does NOT say `overflow: hidden`: caption words are scaled
3138
+ // above 1.0x, and clipping them is exactly what caption_overflow_clips_scaled_words
3139
+ // errors on. Recommending it here made this warning's own fix produce an error.
3140
+ "Add max-width: 1600px (landscape) or max-width: 900px (portrait). Keep overflow visible so scaled emphasis words are not clipped."
3141
+ )
3115
3142
  });
3116
3143
  }
3117
3144
  }
@@ -3266,7 +3293,6 @@ var HEAVY_OVERLAY_EXEMPT_TAGS = /* @__PURE__ */ new Set([
3266
3293
  ]);
3267
3294
  var HEAVY_OVERLAY_CSS_PATTERN = /(?:filter\s*:[^;}]*\bblur\s*\()|(?:clip-path\s*:(?!\s*(?:none|inherit|initial|unset)\b)\s*[^;}]+)|(?:radial-gradient\s*\()/i;
3268
3295
  var INLINE_STYLE_DISPLAY_NONE_PATTERN = /(?:^|;)\s*display\s*:\s*none\b/i;
3269
- var OVERLAP_EPSILON_SECONDS = 1e-6;
3270
3296
  function readTagTiming(rawTag) {
3271
3297
  return readClipTiming({ getAttribute: (name) => readAttr(rawTag, name) });
3272
3298
  }
@@ -3631,64 +3657,6 @@ var compositionRules = [
3631
3657
  }
3632
3658
  return findings;
3633
3659
  },
3634
- // overlapping_clips_same_track
3635
- // fallow-ignore-next-line complexity
3636
- ({ tags }) => {
3637
- const findings = [];
3638
- const trackMap = /* @__PURE__ */ new Map();
3639
- for (const tag of tags) {
3640
- const trackStr = readAttr(tag.raw, COMPOSITION_ATTRIBUTES.trackIndex);
3641
- if (!trackStr) continue;
3642
- const timing = readTagTiming(tag.raw);
3643
- const { start, duration } = timing;
3644
- const track = trackStr;
3645
- if (start == null || duration == null) continue;
3646
- const clips = trackMap.get(track) || [];
3647
- clips.push({
3648
- start,
3649
- end: start + duration,
3650
- elementId: readAttr(tag.raw, "id") || void 0,
3651
- snippet: truncateSnippet(tag.raw) || ""
3652
- });
3653
- trackMap.set(track, clips);
3654
- }
3655
- for (const [track, clips] of trackMap) {
3656
- clips.sort((a, b) => a.start - b.start);
3657
- for (let i = 0; i < clips.length - 1; i++) {
3658
- const current = clips[i];
3659
- const next = clips[i + 1];
3660
- if (!current || !next) continue;
3661
- if (current.end - next.start > OVERLAP_EPSILON_SECONDS) {
3662
- findings.push({
3663
- code: "overlapping_clips_same_track",
3664
- severity: "error",
3665
- 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.`,
3666
- 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."
3667
- });
3668
- }
3669
- }
3670
- }
3671
- return findings;
3672
- },
3673
- // root_composition_missing_data_start
3674
- ({ rootTag, options }) => {
3675
- const findings = [];
3676
- if (options.isSubComposition) return findings;
3677
- if (!rootTag) return findings;
3678
- const compId = readDecodedAttr(rootTag.raw, "data-composition-id");
3679
- if (!compId) return findings;
3680
- const hasStart = readAttr(rootTag.raw, "data-start") !== null;
3681
- if (!hasStart) {
3682
- findings.push({
3683
- code: "root_composition_missing_data_start",
3684
- severity: "error",
3685
- message: `Root composition "${compId}" is missing data-start. The runtime needs data-start="0" on the root element to begin playback.`,
3686
- fixHint: 'Add data-start="0" to the root composition element.',
3687
- snippet: truncateSnippet(rootTag.raw)
3688
- });
3689
- }
3690
- return findings;
3691
- },
3692
3660
  // standalone_composition_wrapped_in_template
3693
3661
  ({ rawSource, options }) => {
3694
3662
  const findings = [];
@@ -4996,6 +4964,7 @@ async function lintProject(projectDir, entryFile) {
4996
4964
  ...lintMissingLocalAsset(projectDir, allHtmlSources),
4997
4965
  ...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
4998
4966
  ...!entryFile ? lintMultipleRootCompositions(projectDir) : [],
4967
+ ...!entryFile ? lintBlankRootWithStandaloneComposition(rootHtml, allHtmlSources) : [],
4999
4968
  ...lintDuplicateAudioTracks(allHtmlSources),
5000
4969
  ...lintMissingOrEmptySubComposition(projectDir, rootHtml),
5001
4970
  ...await lintHevcPreviewCodec(collectLocalVideoCandidates(projectDir, allHtmlSources))
@@ -5018,6 +4987,36 @@ async function lintProject(projectDir, entryFile) {
5018
4987
  }
5019
4988
  return { results, totalErrors, totalWarnings, totalInfos };
5020
4989
  }
4990
+ function lintBlankRootWithStandaloneComposition(rootHtml, htmlSources) {
4991
+ const { document: rootDocument } = parseHTML(rootHtml);
4992
+ const root = rootDocument.querySelector("body [data-composition-id]");
4993
+ if (!root || root.querySelector("*:not(script):not(style):not(link):not(meta):not(template)")) {
4994
+ return [];
4995
+ }
4996
+ const standaloneCandidates = [];
4997
+ for (const source of htmlSources) {
4998
+ if (!source.compSrcPath) continue;
4999
+ const { document } = parseHTML(source.html);
5000
+ const composition = document.querySelector("body [data-composition-id]");
5001
+ if (!composition) continue;
5002
+ const authoredTimedContent = Array.from(
5003
+ composition.querySelectorAll(
5004
+ ".clip, [data-start], [data-end], video, audio, img, svg, canvas"
5005
+ )
5006
+ ).some((element) => !element.hasAttribute("data-composition-src"));
5007
+ if (authoredTimedContent) standaloneCandidates.push(source.compSrcPath);
5008
+ }
5009
+ if (standaloneCandidates.length === 0) return [];
5010
+ return [
5011
+ {
5012
+ code: "blank_root_with_standalone_composition",
5013
+ severity: "error",
5014
+ 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.`,
5015
+ 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]}.`,
5016
+ suggestedComposition: standaloneCandidates[0]
5017
+ }
5018
+ ];
5019
+ }
5021
5020
  function lintProjectAudioFiles(projectDir, htmlSources) {
5022
5021
  const findings = [];
5023
5022
  let audioFiles;