@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/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;
@@ -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
- pattern: /new\s+Date\s*\(/,
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 stripped = stripJsComments(script.content);
701
- for (const { pattern, label, hint } of patterns) {
702
- if (pattern.test(stripped)) {
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 (firstVisible.method !== "from" && !laterHidden) continue;
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: "Add max-width: 1600px (landscape) or max-width: 900px (portrait) and overflow: hidden."
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 = /<video\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
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[1] ?? "";
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 = /<audio\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
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[1];
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 = /<(video|img|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
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);