@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 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/browser.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;
@@ -453,6 +489,7 @@ var coreRules = [
453
489
  return findings;
454
490
  },
455
491
  // root_missing_composition_id + root_missing_dimensions
492
+ // fallow-ignore-next-line complexity
456
493
  ({ rootTag }) => {
457
494
  const findings = [];
458
495
  if (!rootTag || !readDecodedAttr(rootTag.raw, "data-composition-id")) {
@@ -492,6 +529,7 @@ var coreRules = [
492
529
  ];
493
530
  },
494
531
  // missing_timeline_registry + timeline_registry_missing_init
532
+ // fallow-ignore-next-line complexity
495
533
  ({ source, rawSource, rootTag, options }) => {
496
534
  if (options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template")) {
497
535
  return [];
@@ -670,7 +708,10 @@ var coreRules = [
670
708
  hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time."
671
709
  },
672
710
  {
673
- 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*\)/,
674
715
  label: "new Date()",
675
716
  hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time."
676
717
  },
@@ -691,15 +732,20 @@ var coreRules = [
691
732
  },
692
733
  {
693
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.
694
738
  pattern: /["'`](?:[+-]=)?random\(\s*[-\d[]/,
739
+ scansStrings: true,
695
740
  label: '"random(...)" tween value',
696
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."
697
742
  }
698
743
  ];
699
744
  for (const script of scripts) {
700
- const stripped = stripJsComments(script.content);
701
- for (const { pattern, label, hint } of patterns) {
702
- 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)) {
703
749
  findings.push({
704
750
  code: "non_deterministic_code",
705
751
  severity: "error",
@@ -1258,7 +1304,9 @@ var mediaRules = [
1258
1304
  // audio_volume_double_automation
1259
1305
  findVolumeDoubleAutomationFindings,
1260
1306
  // audio_volume_tween_overrides_gain
1261
- findVolumeTweenOverridesGainFindings
1307
+ findVolumeTweenOverridesGainFindings,
1308
+ // audio_carve_ungrouped_sources
1309
+ findCarveUngroupedSourcesFindings
1262
1310
  ];
1263
1311
  function findVolumeTweenOverridesGainFindings(ctx) {
1264
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));
@@ -1298,6 +1346,40 @@ function findVolumeDoubleAutomationFindings(ctx) {
1298
1346
  }
1299
1347
  return findings;
1300
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
+ }
1301
1383
 
1302
1384
  // src/rules/gsap.ts
1303
1385
  async function loadParseGsapScript() {
@@ -2100,9 +2182,9 @@ ${right.raw}`)
2100
2182
  (candidate) => targetedSelectorTokens(firstVisible.targetSelector).has(candidate)
2101
2183
  ) || selectors[0] || tag.name;
2102
2184
  const laterHidden = visibilityWindows.some(
2103
- (win) => win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues)
2185
+ (win) => win !== firstVisible && win.method !== "from" && win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues)
2104
2186
  );
2105
- if (firstVisible.method !== "from" && !laterHidden) continue;
2187
+ if (!laterHidden) continue;
2106
2188
  reportedVisibleOverlayKeys.add(overlayKey);
2107
2189
  findings.push({
2108
2190
  code: "gsap_fullscreen_overlay_starts_visible",
@@ -3051,7 +3133,12 @@ var captionRules = [
3051
3133
  severity: "warning",
3052
3134
  selector: (selector ?? "").trim(),
3053
3135
  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."
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
+ )
3055
3142
  });
3056
3143
  }
3057
3144
  }
@@ -3179,7 +3266,7 @@ var captionRules = [
3179
3266
  ];
3180
3267
 
3181
3268
  // src/rules/composition.ts
3182
- import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers/composition";
3269
+ import { COMPOSITION_VARIABLE_TYPES, isSafeMediaUrl } from "@hyperframes/parsers/composition";
3183
3270
  import { COMPOSITION_ATTRIBUTES, readClipTiming } from "@hyperframes/parsers/composition-contract";
3184
3271
  var MAX_COMPOSITION_LINES = 300;
3185
3272
  var MAX_TIMED_ELEMENTS_PER_TRACK = 3;
@@ -3206,7 +3293,6 @@ var HEAVY_OVERLAY_EXEMPT_TAGS = /* @__PURE__ */ new Set([
3206
3293
  ]);
3207
3294
  var HEAVY_OVERLAY_CSS_PATTERN = /(?:filter\s*:[^;}]*\bblur\s*\()|(?:clip-path\s*:(?!\s*(?:none|inherit|initial|unset)\b)\s*[^;}]+)|(?:radial-gradient\s*\()/i;
3208
3295
  var INLINE_STYLE_DISPLAY_NONE_PATTERN = /(?:^|;)\s*display\s*:\s*none\b/i;
3209
- var OVERLAP_EPSILON_SECONDS = 1e-6;
3210
3296
  function readTagTiming(rawTag) {
3211
3297
  return readClipTiming({ getAttribute: (name) => readAttr(rawTag, name) });
3212
3298
  }
@@ -3348,6 +3434,7 @@ var compositionRules = [
3348
3434
  const tagsByCompositionId = /* @__PURE__ */ new Map();
3349
3435
  for (const tag of tags) {
3350
3436
  if (isInsideInertTemplate(tag, tags)) continue;
3437
+ if (readAttr(tag.raw, "data-composition-src")) continue;
3351
3438
  const compositionId = readDecodedAttr(tag.raw, "data-composition-id");
3352
3439
  if (!compositionId || compositionId.trim().length === 0) continue;
3353
3440
  const matchingTags = tagsByCompositionId.get(compositionId) ?? [];
@@ -3571,64 +3658,6 @@ var compositionRules = [
3571
3658
  }
3572
3659
  return findings;
3573
3660
  },
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
3661
  // standalone_composition_wrapped_in_template
3633
3662
  ({ rawSource, options }) => {
3634
3663
  const findings = [];
@@ -3808,6 +3837,11 @@ var compositionRules = [
3808
3837
  }
3809
3838
  const findings = [];
3810
3839
  const knownTypes = new Set(COMPOSITION_VARIABLE_TYPES);
3840
+ const varSrcIds = /* @__PURE__ */ new Set();
3841
+ for (const tag of tags) {
3842
+ const bound = readAttr(tag.raw, "data-var-src");
3843
+ if (bound) varSrcIds.add(bound);
3844
+ }
3811
3845
  for (let i = 0; i < parsed.length; i += 1) {
3812
3846
  const entry = parsed[i];
3813
3847
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
@@ -3832,6 +3866,17 @@ var compositionRules = [
3832
3866
  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
3867
  snippet: truncateSnippet(htmlTag.raw)
3834
3868
  });
3869
+ continue;
3870
+ }
3871
+ const id = String(e.id);
3872
+ if ((e.type === "image" || varSrcIds.has(id)) && typeof e.default === "string" && e.default.length > 0 && !isSafeMediaUrl(e.default)) {
3873
+ findings.push({
3874
+ code: "unloadable_media_variable_default",
3875
+ severity: "error",
3876
+ 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.`,
3877
+ 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.`,
3878
+ snippet: truncateSnippet(htmlTag.raw)
3879
+ });
3835
3880
  }
3836
3881
  }
3837
3882
  return findings;