@hyperframes/lint 0.8.6 → 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/browser.d.ts +2 -0
- package/dist/browser.js +106 -78
- package/dist/browser.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +137 -78
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
|
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;
|
|
@@ -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
|
-
|
|
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
|
|
701
|
-
|
|
702
|
-
|
|
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 (
|
|
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:
|
|
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
|
}
|
|
@@ -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
|
}
|
|
@@ -3571,64 +3657,6 @@ var compositionRules = [
|
|
|
3571
3657
|
}
|
|
3572
3658
|
return findings;
|
|
3573
3659
|
},
|
|
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
3660
|
// standalone_composition_wrapped_in_template
|
|
3633
3661
|
({ rawSource, options }) => {
|
|
3634
3662
|
const findings = [];
|