mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 04:18:01 +00:00
fix(core): lint rule for timeline ID mismatches (#93)
## What Added three new lint rules to the Hyperframe HTML linter to catch common runtime errors and invalid script references. ## Why These lint rules prevent silent failures and runtime errors that can break Hyperframe compositions: 1. Timeline assignments without initialization guards cause silent failures when `window.__timelines` is undefined 2. Mismatched timeline IDs between `data-composition-id` attributes and `window.__timelines` keys prevent proper auto-nesting 3. Hallucinated script sources referencing non-existent `@hyperframe/` packages result in 404 errors ## How Implemented three new lint rules with corresponding error codes: - `timeline_registry_missing_init`: Detects timeline assignments without proper initialization guard using regex pattern matching - `timeline_id_mismatch`: Cross-references composition IDs from HTML attributes against timeline registry keys to identify mismatches - `hallucinated_script_src`: Checks script `src` attributes against known bad patterns for non-existent CDN packages Each rule provides specific error messages and fix hints to guide developers toward correct implementations. ## Test plan - [x] Unit tests added/updated - [x] Manual testing performed - [ ] Documentation updated (if applicable) Added comprehensive test coverage for all three new lint rules, including both positive and negative test cases to ensure proper detection and avoid false positives.
This commit is contained in:
@@ -102,6 +102,47 @@ export function lintHyperframeHtml(
|
||||
});
|
||||
}
|
||||
|
||||
// Timeline assignment without initialization guard — causes silent failure
|
||||
// when the runtime script hasn't loaded yet (window.__timelines is undefined).
|
||||
if (
|
||||
TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) &&
|
||||
!TIMELINE_REGISTRY_INIT_PATTERN.test(source)
|
||||
) {
|
||||
pushFinding({
|
||||
code: "timeline_registry_missing_init",
|
||||
severity: "error",
|
||||
message:
|
||||
"`window.__timelines[…] = …` is used without initializing `window.__timelines` first.",
|
||||
fixHint:
|
||||
"Add `window.__timelines = window.__timelines || {};` before any timeline assignment.",
|
||||
});
|
||||
}
|
||||
|
||||
// Check for timeline ID mismatches: data-composition-id vs window.__timelines["X"] keys.
|
||||
{
|
||||
const htmlCompIds = new Set<string>();
|
||||
const timelineRegKeys = new Set<string>();
|
||||
const compIdRe = /data-composition-id\s*=\s*["']([^"']+)["']/gi;
|
||||
const tlKeyRe = /window\.__timelines\[\s*["']([^"']+)["']\s*\]/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = compIdRe.exec(source)) !== null) {
|
||||
if (m[1]) htmlCompIds.add(m[1]);
|
||||
}
|
||||
while ((m = tlKeyRe.exec(source)) !== null) {
|
||||
if (m[1]) timelineRegKeys.add(m[1]);
|
||||
}
|
||||
for (const key of timelineRegKeys) {
|
||||
if (!htmlCompIds.has(key)) {
|
||||
pushFinding({
|
||||
code: "timeline_id_mismatch",
|
||||
severity: "error",
|
||||
message: `Timeline registered as "${key}" but no element has data-composition-id="${key}". The runtime cannot auto-nest this timeline.`,
|
||||
fixHint: `Change window.__timelines["${key}"] to match the data-composition-id attribute, or vice versa.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (INVALID_SCRIPT_CLOSE_PATTERN.test(source)) {
|
||||
pushFinding({
|
||||
code: "invalid_inline_script_syntax",
|
||||
@@ -878,3 +919,86 @@ export async function lintMediaUrls(
|
||||
await Promise.all(checks);
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all external script URLs from the HTML.
|
||||
*/
|
||||
function extractScriptUrls(html: string): Array<{ url: string; snippet: string }> {
|
||||
const results: Array<{ url: string; snippet: string }> = [];
|
||||
const scriptRe = /<script\b[^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = scriptRe.exec(html)) !== null) {
|
||||
const raw = match[0];
|
||||
const src = readAttr(raw, "src");
|
||||
if (!src) continue;
|
||||
if (/^https?:\/\//i.test(src)) {
|
||||
results.push({
|
||||
url: src,
|
||||
snippet: raw.length > 120 ? raw.slice(0, 117) + "..." : raw,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async lint pass: HEAD-checks every external script URL in the HTML.
|
||||
* Returns findings for URLs that are unreachable (non-2xx status or network error).
|
||||
*
|
||||
* Call this after `lintHyperframeHtml()` and merge the findings.
|
||||
*
|
||||
* @param timeoutMs - per-request timeout (default 8000ms)
|
||||
*/
|
||||
export async function lintScriptUrls(
|
||||
html: string,
|
||||
options: { timeoutMs?: number } = {},
|
||||
): Promise<HyperframeLintFinding[]> {
|
||||
const urls = extractScriptUrls(html);
|
||||
if (urls.length === 0) return [];
|
||||
|
||||
const timeout = options.timeoutMs ?? 8000;
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const unique = urls.filter((u) => {
|
||||
if (seen.has(u.url)) return false;
|
||||
seen.add(u.url);
|
||||
return true;
|
||||
});
|
||||
|
||||
const checks = unique.map(async ({ url, snippet }) => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
const resp = await fetch(url, {
|
||||
method: "HEAD",
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!resp.ok) {
|
||||
findings.push({
|
||||
code: "inaccessible_script_url",
|
||||
severity: "error",
|
||||
message: `<script> references a URL that returned HTTP ${resp.status}: ${url.slice(0, 120)}`,
|
||||
fixHint:
|
||||
"This script URL is not accessible. Remove it or replace with a valid URL. The HyperFrames runtime is injected automatically — do not load it manually.",
|
||||
snippet,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.name : "unknown";
|
||||
findings.push({
|
||||
code: "inaccessible_script_url",
|
||||
severity: "error",
|
||||
message: `<script> references an unreachable URL (${reason}): ${url.slice(0, 120)}`,
|
||||
fixHint: "This script URL is not accessible. Remove it or replace with a valid URL.",
|
||||
snippet,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(checks);
|
||||
return findings;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user