mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(lint): add external_script_dependency rule for CDN scripts in sub-compositions
Added info-level lint rule that flags compositions loading external CDN libraries via <script src>. The bundler auto-hoists these into the parent document and the runtime re-injects them in unbundled mode, but the rule surfaces the dependency so developers know it exists if using a custom pipeline. Also added tests for the htmlBundler fix (external CDN scripts from sub-compositions are preserved and deduped in the bundle output).
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { bundleToSingleHtml } from "./htmlBundler";
|
||||
|
||||
function makeTempProject(files: Record<string, string>): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-bundler-test-"));
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = join(dir, rel);
|
||||
mkdirSync(join(full, ".."), { recursive: true });
|
||||
writeFileSync(full, content, "utf-8");
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("bundleToSingleHtml", () => {
|
||||
it("hoists external CDN scripts from sub-compositions into the bundle", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
</head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="rockets-host"
|
||||
data-composition-id="rockets"
|
||||
data-composition-src="compositions/rockets.html"
|
||||
data-start="0" data-duration="2"></div>
|
||||
</div>
|
||||
<script>window.__timelines={}; const tl=gsap.timeline({paused:true}); window.__timelines["main"]=tl;</script>
|
||||
</body></html>`,
|
||||
"compositions/rockets.html": `<template id="rockets-template">
|
||||
<div data-composition-id="rockets" data-width="1920" data-height="1080">
|
||||
<div id="rocket-container"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const anim = lottie.loadAnimation({ container: document.querySelector("#rocket-container"), path: "rocket.json" });
|
||||
window.__timelines["rockets"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</div>
|
||||
</template>`,
|
||||
});
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
|
||||
// Lottie CDN script from sub-composition must be present in the bundle
|
||||
expect(bundled).toContain(
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js",
|
||||
);
|
||||
|
||||
// Should only appear once (deduped)
|
||||
const occurrences = (bundled.match(/cdnjs\.cloudflare\.com\/ajax\/libs\/lottie-web/g) ?? [])
|
||||
.length;
|
||||
expect(occurrences).toBe(1);
|
||||
|
||||
// GSAP CDN from main doc should still be present
|
||||
expect(bundled).toContain("cdn.jsdelivr.net/npm/gsap");
|
||||
|
||||
// data-composition-src should be stripped (composition was inlined)
|
||||
expect(bundled).not.toContain("data-composition-src");
|
||||
});
|
||||
|
||||
it("does not duplicate CDN scripts already present in the main document", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
</head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="child-host"
|
||||
data-composition-id="child"
|
||||
data-composition-src="compositions/child.html"
|
||||
data-start="0" data-duration="5"></div>
|
||||
</div>
|
||||
<script>window.__timelines={}; const tl=gsap.timeline({paused:true}); window.__timelines["main"]=tl;</script>
|
||||
</body></html>`,
|
||||
"compositions/child.html": `<template id="child-template">
|
||||
<div data-composition-id="child" data-width="1920" data-height="1080">
|
||||
<div id="stage"></div>
|
||||
<!-- Same GSAP CDN as parent — should not be duplicated -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["child"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</div>
|
||||
</template>`,
|
||||
});
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
|
||||
// GSAP CDN should appear exactly once (deduped)
|
||||
const gsapOccurrences = (
|
||||
bundled.match(/cdn\.jsdelivr\.net\/npm\/gsap@3\.14\.2\/dist\/gsap\.min\.js/g) ?? []
|
||||
).length;
|
||||
expect(gsapOccurrences).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -111,6 +111,42 @@ describe("lintHyperframeHtml", () => {
|
||||
expect(codes.length).toBe(uniqueCodes.length);
|
||||
});
|
||||
|
||||
it("reports info for composition with external CDN script dependency", () => {
|
||||
const html = `<template id="rockets-template">
|
||||
<div data-composition-id="rockets" data-width="1920" data-height="1080">
|
||||
<div id="rocket-container"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["rockets"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</div>
|
||||
</template>`;
|
||||
const result = lintHyperframeHtml(html, { filePath: "compositions/rockets.html" });
|
||||
const finding = result.findings.find((f) => f.code === "external_script_dependency");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("info");
|
||||
expect(finding?.message).toContain("cdnjs.cloudflare.com");
|
||||
// info findings do not count as errors — ok should still be true
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.errorCount).toBe(0);
|
||||
});
|
||||
|
||||
it("does not report external_script_dependency for inline scripts", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<script>
|
||||
window.__timelines = {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "external_script_dependency")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips <template> wrapper before linting composition files", () => {
|
||||
const html = `<template id="my-comp-template">
|
||||
<div data-composition-id="my-comp" data-width="1920" data-height="1080"
|
||||
|
||||
@@ -675,6 +675,32 @@ export function lintHyperframeHtml(
|
||||
}
|
||||
}
|
||||
|
||||
// ── External CDN script dependency check ────────────────────────────────
|
||||
// Compositions that load CDN libraries via <script src="https://..."> work
|
||||
// correctly in bundled mode (bundleToSingleHtml auto-hoists them to the parent
|
||||
// document) and in runtime mode (loadExternalCompositions re-injects them).
|
||||
// But when a composition is used in a custom pipeline that bypasses both, the
|
||||
// scripts won't be available. Flag this as an info-level finding so developers
|
||||
// know the dependency exists.
|
||||
{
|
||||
const externalScriptRe = /<script\b[^>]*\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
const seen = new Set<string>();
|
||||
while ((match = externalScriptRe.exec(source)) !== null) {
|
||||
const src = match[1] ?? "";
|
||||
if (seen.has(src)) continue;
|
||||
seen.add(src);
|
||||
pushFinding({
|
||||
code: "external_script_dependency",
|
||||
severity: "info",
|
||||
message: `This composition loads an external script from \`${src}\`. The HyperFrames bundler automatically hoists CDN scripts from sub-compositions into the parent document. In unbundled runtime mode, \`loadExternalCompositions\` re-injects them. If you're using a custom pipeline that bypasses both, you'll need to include this script manually.`,
|
||||
fixHint:
|
||||
"No action needed when using `hyperframes dev` or `hyperframes render`. If using a custom pipeline, add this script tag to your root composition or HTML page.",
|
||||
snippet: truncateSnippet(match[0] ?? ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const errorCount = findings.filter((finding) => finding.severity === "error").length;
|
||||
const warningCount = findings.length - errorCount;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user