diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts
index 580fba595c..739ac392d3 100644
--- a/packages/core/src/compiler/htmlBundler.ts
+++ b/packages/core/src/compiler/htmlBundler.ts
@@ -1,5 +1,6 @@
import { readFileSync, existsSync } from "fs";
import { join, resolve, relative, dirname, isAbsolute, sep } from "path";
+import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js";
import { transformSync } from "esbuild";
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
import {
@@ -72,14 +73,7 @@ function injectInterceptor(html: string, runtimeMode: "inline" | "placeholder" =
}
function isRelativeUrl(url: string): boolean {
- if (!url) return false;
- return (
- !url.startsWith("http://") &&
- !url.startsWith("https://") &&
- !url.startsWith("//") &&
- !url.startsWith("data:") &&
- !isAbsolute(url)
- );
+ return !isNonRelativeUrl(url) && !isAbsolute(url);
}
function safeReadFile(filePath: string): string | null {
@@ -94,8 +88,6 @@ function safeReadFile(filePath: string): string | null {
const CSS_IMPORT_RE =
/@import\s+(?:url\(\s*(["']?)([^)"']+)\1\s*\)|(["'])([^"']+)\3)\s*([^;]*);\s*/g;
-const REBASE_URL_RE = /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g;
-
const CSS_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
function withCommentsStripped(
@@ -123,7 +115,7 @@ function rebaseCssUrls(css: string, cssFileDir: string, projectDir: string): str
const resolvedRoot = resolve(projectDir);
const resolvedDir = resolve(cssFileDir);
if (resolvedDir === resolvedRoot) return css;
- return css.replace(REBASE_URL_RE, (full, quote: string, urlValue: string) => {
+ return css.replace(CSS_URL_RE, (full, quote: string, urlValue: string) => {
if (!urlValue || !isRelativeUrl(urlValue)) return full;
const { basePath, suffix } = splitUrlSuffix(urlValue.trim());
if (!basePath) return full;
@@ -205,29 +197,24 @@ function appendSuffixToUrl(baseUrl: string, suffix: string): string {
return baseUrl;
}
-function guessMimeType(filePath: string): string {
- const l = filePath.toLowerCase();
- if (l.endsWith(".svg")) return "image/svg+xml";
- if (l.endsWith(".json")) return "application/json";
- if (l.endsWith(".txt")) return "text/plain";
- if (l.endsWith(".xml")) return "application/xml";
- return "application/octet-stream";
-}
-
-function shouldInlineAsDataUrl(filePath: string): boolean {
- const l = filePath.toLowerCase();
- return l.endsWith(".svg") || l.endsWith(".json") || l.endsWith(".txt") || l.endsWith(".xml");
-}
+const INLINE_MIME: Record = {
+ ".svg": "image/svg+xml",
+ ".json": "application/json",
+ ".txt": "text/plain",
+ ".xml": "application/xml",
+};
function maybeInlineRelativeAssetUrl(urlValue: string, projectDir: string): string | null {
if (!urlValue || !isRelativeUrl(urlValue)) return null;
const { basePath, suffix } = splitUrlSuffix(urlValue.trim());
if (!basePath) return null;
const filePath = resolveWithinProject(projectDir, basePath);
- if (!filePath || !shouldInlineAsDataUrl(filePath)) return null;
+ if (!filePath) return null;
+ const ext = filePath.toLowerCase().match(/\.[^.]+$/)?.[0] ?? "";
+ const mimeType = INLINE_MIME[ext];
+ if (!mimeType) return null;
const content = safeReadFileBuffer(filePath);
if (content == null) return null;
- const mimeType = guessMimeType(filePath);
const dataUrl = `data:${mimeType};base64,${content.toString("base64")}`;
return appendSuffixToUrl(dataUrl, suffix);
}
@@ -479,14 +466,13 @@ function autoHealMissingCompositionIds(document: Document): void {
function coalesceHeadStylesAndBodyScripts(document: Document): void {
const headStyleEls = [...document.querySelectorAll("head style")];
if (headStyleEls.length > 1) {
- const importRe = /@import\s+url\([^)]*\)\s*;|@import\s+["'][^"']+["']\s*;/gi;
const imports: string[] = [];
const cssParts: string[] = [];
const seenImports = new Set();
for (const el of headStyleEls) {
const raw = (el.textContent || "").trim();
if (!raw) continue;
- const nonImportCss = raw.replace(importRe, (match) => {
+ const nonImportCss = raw.replace(CSS_IMPORT_RE, (match) => {
const cleaned = match.trim();
if (!seenImports.has(cleaned)) {
seenImports.add(cleaned);
@@ -607,6 +593,78 @@ export interface BundleOptions {
* - Inlines sub-composition HTML fragments (data-composition-src)
* - Inlines small textual assets as data URLs
*/
+
+function ensureExternalScriptTag(doc: Document, src: string): void {
+ if (doc.querySelector(`script[src="${src}"]`)) return;
+ const el = doc.createElement("script");
+ el.setAttribute("src", src);
+ doc.body.appendChild(el);
+}
+
+function hoistExternalScript(
+ src: string,
+ projectDir: string,
+ doc: Document,
+ seenSrcs: Set,
+ chunks: string[],
+): void {
+ if (seenSrcs.has(src)) return;
+ seenSrcs.add(src);
+ if (!isNonRelativeUrl(src) && !isAbsolute(src)) {
+ const jsPath = resolveWithinProject(projectDir, src);
+ const js = jsPath ? safeReadFile(jsPath) : null;
+ if (js != null) {
+ chunks.push(js);
+ return;
+ }
+ }
+ ensureExternalScriptTag(doc, src);
+}
+
+function hoistCompositionScripts(
+ container: { querySelectorAll: (sel: string) => NodeListOf },
+ opts: {
+ projectDir: string;
+ document: Document;
+ compId: string | null;
+ runtimeScope: string | undefined;
+ runtimeCompId: string | undefined;
+ authoredRootId: string | undefined;
+ seenCompScriptSrcs: Set;
+ compScriptChunks: string[];
+ },
+): void {
+ for (const scriptEl of [...container.querySelectorAll("script")]) {
+ const externalSrc = (scriptEl.getAttribute("src") || "").trim();
+ if (externalSrc) {
+ hoistExternalScript(
+ externalSrc,
+ opts.projectDir,
+ opts.document,
+ opts.seenCompScriptSrcs,
+ opts.compScriptChunks,
+ );
+ } else {
+ opts.compScriptChunks.push(
+ opts.compId
+ ? wrapScopedCompositionScript(
+ scriptEl.textContent || "",
+ opts.compId,
+ "[HyperFrames] composition script error:",
+ opts.runtimeScope,
+ opts.runtimeCompId || opts.compId,
+ opts.authoredRootId,
+ )
+ : wrapInlineScriptWithErrorBoundary(
+ scriptEl.textContent || "",
+ "[HyperFrames] composition script error:",
+ ),
+ );
+ }
+ scriptEl.remove();
+ }
+}
+
export async function bundleToSingleHtml(
projectDir: string,
options?: BundleOptions,
@@ -789,47 +847,16 @@ export async function bundleToSingleHtml(
);
styleEl.remove();
}
- // Hoist scripts into the collected script chunks
- for (const scriptEl of [...innerRoot.querySelectorAll("script")]) {
- const externalSrc = (scriptEl.getAttribute("src") || "").trim();
- if (externalSrc) {
- if (!seenCompScriptSrcs.has(externalSrc)) {
- seenCompScriptSrcs.add(externalSrc);
- if (isRelativeUrl(externalSrc)) {
- const jsPath = resolveWithinProject(projectDir, externalSrc);
- const js = jsPath ? safeReadFile(jsPath) : null;
- if (js != null) {
- compScriptChunks.push(js);
- } else if (!document.querySelector(`script[src="${externalSrc}"]`)) {
- const extScript = document.createElement("script");
- extScript.setAttribute("src", externalSrc);
- document.body.appendChild(extScript);
- }
- } else if (!document.querySelector(`script[src="${externalSrc}"]`)) {
- const extScript = document.createElement("script");
- extScript.setAttribute("src", externalSrc);
- document.body.appendChild(extScript);
- }
- }
- } else {
- compScriptChunks.push(
- compId
- ? wrapScopedCompositionScript(
- scriptEl.textContent || "",
- compId,
- "[HyperFrames] composition script error:",
- runtimeScope,
- runtimeCompId || compId,
- authoredRootId,
- )
- : wrapInlineScriptWithErrorBoundary(
- scriptEl.textContent || "",
- "[HyperFrames] composition script error:",
- ),
- );
- }
- scriptEl.remove();
- }
+ hoistCompositionScripts(innerRoot, {
+ projectDir,
+ document,
+ compId,
+ runtimeScope,
+ runtimeCompId,
+ authoredRootId: authoredRootId ?? undefined,
+ seenCompScriptSrcs,
+ compScriptChunks,
+ });
// Copy dimension attributes from inner root to host if not already set
const innerW = innerRoot.getAttribute("data-width");
@@ -845,45 +872,16 @@ export async function bundleToSingleHtml(
compStyleChunks.push(compId ? scopeCssToComposition(css, compId, runtimeScope) : css);
styleEl.remove();
}
- for (const scriptEl of [...innerDoc.querySelectorAll("script")]) {
- const externalSrc = (scriptEl.getAttribute("src") || "").trim();
- if (externalSrc) {
- if (!seenCompScriptSrcs.has(externalSrc)) {
- seenCompScriptSrcs.add(externalSrc);
- if (isRelativeUrl(externalSrc)) {
- const jsPath = resolveWithinProject(projectDir, externalSrc);
- const js = jsPath ? safeReadFile(jsPath) : null;
- if (js != null) {
- compScriptChunks.push(js);
- } else if (!document.querySelector(`script[src="${externalSrc}"]`)) {
- const extScript = document.createElement("script");
- extScript.setAttribute("src", externalSrc);
- document.body.appendChild(extScript);
- }
- } else if (!document.querySelector(`script[src="${externalSrc}"]`)) {
- const extScript = document.createElement("script");
- extScript.setAttribute("src", externalSrc);
- document.body.appendChild(extScript);
- }
- }
- } else {
- compScriptChunks.push(
- compId
- ? wrapScopedCompositionScript(
- scriptEl.textContent || "",
- compId,
- "[HyperFrames] composition script error:",
- runtimeScope,
- runtimeCompId || compId,
- )
- : wrapInlineScriptWithErrorBoundary(
- scriptEl.textContent || "",
- "[HyperFrames] composition script error:",
- ),
- );
- }
- scriptEl.remove();
- }
+ hoistCompositionScripts(innerDoc, {
+ projectDir,
+ document,
+ compId,
+ runtimeScope,
+ runtimeCompId,
+ authoredRootId: undefined,
+ seenCompScriptSrcs,
+ compScriptChunks,
+ });
host.innerHTML = innerDoc.body.innerHTML || "";
}
diff --git a/packages/core/src/compiler/rewriteSubCompPaths.ts b/packages/core/src/compiler/rewriteSubCompPaths.ts
index ebc677547f..d0c7eb0cb1 100644
--- a/packages/core/src/compiler/rewriteSubCompPaths.ts
+++ b/packages/core/src/compiler/rewriteSubCompPaths.ts
@@ -67,18 +67,12 @@ export function rewriteAssetPaths(
getAttr: (el: T, attr: string) => string | null | undefined,
setAttr: (el: T, attr: string, value: string) => void,
): void {
- const compDir = dirname(compSrcPath);
- if (!compDir || compDir === ".") return;
-
for (const el of elements) {
for (const attr of PATH_ATTRS) {
const val = (getAttr(el, attr) || "").trim();
- if (isAbsoluteOrSpecial(val)) continue;
- if (!needsRewrite(val)) continue;
- const rewritten = join(compDir, val);
- const normalized = resolve("/", rewritten).slice(1);
- if (normalized !== val) {
- setAttr(el, attr, normalized);
+ const rewritten = rewriteAssetPath(compSrcPath, val);
+ if (rewritten !== val) {
+ setAttr(el, attr, rewritten);
}
}
}
diff --git a/packages/core/src/core.types.ts b/packages/core/src/core.types.ts
index 7d85dbd7ac..14ad5eb28f 100644
--- a/packages/core/src/core.types.ts
+++ b/packages/core/src/core.types.ts
@@ -330,26 +330,6 @@ export interface CompositionSpec {
variables: CompositionVariable[];
}
-export function isStringVariable(v: CompositionVariable): v is StringVariable {
- return v.type === "string";
-}
-
-export function isNumberVariable(v: CompositionVariable): v is NumberVariable {
- return v.type === "number";
-}
-
-export function isColorVariable(v: CompositionVariable): v is ColorVariable {
- return v.type === "color";
-}
-
-export function isBooleanVariable(v: CompositionVariable): v is BooleanVariable {
- return v.type === "boolean";
-}
-
-export function isEnumVariable(v: CompositionVariable): v is EnumVariable {
- return v.type === "enum";
-}
-
export type TimelineElement =
| TimelineMediaElement
| TimelineTextElement
diff --git a/packages/core/src/fonts/systemFontLocator.ts b/packages/core/src/fonts/systemFontLocator.ts
index 341f08c8c1..86fbd9cb16 100644
--- a/packages/core/src/fonts/systemFontLocator.ts
+++ b/packages/core/src/fonts/systemFontLocator.ts
@@ -4,6 +4,8 @@ import { homedir, platform } from "node:os";
import { join, resolve } from "node:path";
export const SYSTEM_FONT_SIZE_LIMIT = 5 * 1024 * 1024;
+const PROFILER_TIMEOUT_MS = 5000;
+const FC_MATCH_TIMEOUT_MS = 3000;
export type FontFileFormat = "ttf" | "otf" | "woff2" | "woff" | "ttc";
@@ -238,7 +240,7 @@ function getSystemProfilerIndex(): Map {
const raw = execFileSync("system_profiler", ["SPFontsDataType", "-json"], {
encoding: "utf8",
maxBuffer: 12 * 1024 * 1024,
- timeout: 5000,
+ timeout: PROFILER_TIMEOUT_MS,
});
const parsed = JSON.parse(raw);
if (!parsed?.SPFontsDataType || !Array.isArray(parsed.SPFontsDataType)) return profilerCache;
@@ -289,7 +291,7 @@ function locateViaFcMatch(targetFamily: string): LocatedFont | null {
try {
const result = execFileSync("fc-match", [targetFamily, "--format=%{file}"], {
encoding: "utf8",
- timeout: 3000,
+ timeout: FC_MATCH_TIMEOUT_MS,
}).trim();
if (!result || !isRegularFile(result) || !isPathBounded(result)) return null;
const fileName = result.split("/").pop() ?? "";
@@ -403,6 +405,11 @@ function dedupeVariants(variants: LocatedFontVariant[]): LocatedFontVariant[] {
return Array.from(seen.values());
}
+export function getSystemProfilerFamilies(): string[] {
+ const index = getSystemProfilerIndex();
+ return Array.from(index.keys());
+}
+
export function clearSystemFontCache(): void {
cache.clear();
profilerCache = null;
diff --git a/packages/core/src/generators/hyperframes.ts b/packages/core/src/generators/hyperframes.ts
index 9028013c16..e05c32ce60 100644
--- a/packages/core/src/generators/hyperframes.ts
+++ b/packages/core/src/generators/hyperframes.ts
@@ -320,11 +320,26 @@ export function generateHyperframesHtml(
? ` data-zoom-keyframes='${JSON.stringify(stageZoomKeyframes).replace(/'/g, "'")}'`
: "";
- const { coreCss, customCss, googleFontsLink } = generateHyperframesStyles(
- sortedElements,
- resolution,
- customStyles,
- );
+ let styleTags = "";
+ let googleFontsLink = "";
+ if (includeStyles) {
+ const styles = generateHyperframesStyles(sortedElements, resolution, customStyles);
+ googleFontsLink = styles.googleFontsLink;
+ styleTags = [
+ styles.coreCss
+ ? ` `
+ : "",
+ styles.customCss
+ ? ` `
+ : "",
+ ]
+ .filter(Boolean)
+ .join("\n");
+ }
const gsapScript = includeScripts
? generateGsapTimelineScript(sortedElements, totalDuration, {
@@ -344,23 +359,6 @@ ${gsapScript}
`
: "";
- const styleTags = includeStyles
- ? [
- coreCss
- ? ` `
- : "",
- customCss
- ? ` `
- : "",
- ]
- .filter(Boolean)
- .join("\n")
- : "";
-
const customStylesAttr = customStyles
? ` data-custom-styles='${JSON.stringify(customStyles).replace(/'/g, "'")}'`
: "";
@@ -372,7 +370,7 @@ ${gsapScript}
- ${includeStyles ? googleFontsLink : ""}
+ ${googleFontsLink}
${gsapCdnTag}
${styleTags ? ` ${styleTags}` : ""}
diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts
index 55ab677e09..884c5b6df3 100644
--- a/packages/core/src/index.test.ts
+++ b/packages/core/src/index.test.ts
@@ -67,14 +67,6 @@ describe("@hyperframes/core public API exports", () => {
expect(zoom.focusX).toBe(960);
expect(zoom.focusY).toBe(540);
});
-
- it("exports composition variable type guards", () => {
- expect(typeof core.isStringVariable).toBe("function");
- expect(typeof core.isNumberVariable).toBe("function");
- expect(typeof core.isColorVariable).toBe("function");
- expect(typeof core.isBooleanVariable).toBe("function");
- expect(typeof core.isEnumVariable).toBe("function");
- });
});
describe("template exports", () => {
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index eb586f0bbd..5cdd54805a 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -53,11 +53,6 @@ export {
isMediaElement,
isCompositionElement,
getDefaultStageZoom,
- isStringVariable,
- isNumberVariable,
- isColorVariable,
- isBooleanVariable,
- isEnumVariable,
} from "./core.types";
// Templates
diff --git a/packages/core/src/lint/hyperframeLinter.test.ts b/packages/core/src/lint/hyperframeLinter.test.ts
index 4d11a64e05..5203c0ef30 100644
--- a/packages/core/src/lint/hyperframeLinter.test.ts
+++ b/packages/core/src/lint/hyperframeLinter.test.ts
@@ -1,5 +1,5 @@
-import { describe, it, expect, vi } from "vitest";
-import { lintHyperframeHtml, lintScriptUrls } from "./hyperframeLinter.js";
+import { describe, it, expect } from "vitest";
+import { lintHyperframeHtml } from "./hyperframeLinter.js";
describe("lintHyperframeHtml — orchestrator", () => {
const validComposition = `
@@ -64,66 +64,3 @@ describe("lintHyperframeHtml — orchestrator", () => {
expect(missing).toHaveLength(0);
});
});
-
-describe("lintScriptUrls", () => {
- it("reports error for script URL returning non-2xx", async () => {
- const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
- vi.stubGlobal("fetch", mockFetch);
-
- const html = `
-
-
-`;
- const findings = await lintScriptUrls(html);
- const finding = findings.find((f) => f.code === "inaccessible_script_url");
- expect(finding).toBeDefined();
- expect(finding?.severity).toBe("error");
- expect(finding?.message).toContain("404");
-
- vi.unstubAllGlobals();
- });
-
- it("reports error for unreachable script URL", async () => {
- const mockFetch = vi.fn().mockRejectedValue(new Error("AbortError"));
- vi.stubGlobal("fetch", mockFetch);
-
- const html = `
-
-
-`;
- const findings = await lintScriptUrls(html);
- const finding = findings.find((f) => f.code === "inaccessible_script_url");
- expect(finding).toBeDefined();
-
- vi.unstubAllGlobals();
- });
-
- it("does not flag accessible script URLs", async () => {
- const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
- vi.stubGlobal("fetch", mockFetch);
-
- const html = `
-
-
-`;
- const findings = await lintScriptUrls(html);
- expect(findings.length).toBe(0);
-
- vi.unstubAllGlobals();
- });
-
- it("skips inline scripts without src", async () => {
- const mockFetch = vi.fn();
- vi.stubGlobal("fetch", mockFetch);
-
- const html = `
-
-
-`;
- const findings = await lintScriptUrls(html);
- expect(findings.length).toBe(0);
- expect(mockFetch).not.toHaveBeenCalled();
-
- vi.unstubAllGlobals();
- });
-});
diff --git a/packages/core/src/lint/hyperframeLinter.ts b/packages/core/src/lint/hyperframeLinter.ts
index fcab47fcc7..9467c3bea9 100644
--- a/packages/core/src/lint/hyperframeLinter.ts
+++ b/packages/core/src/lint/hyperframeLinter.ts
@@ -151,82 +151,3 @@ export async function lintMediaUrls(
await Promise.all(checks);
return findings;
}
-
-function extractScriptUrls(html: string): Array<{ url: string; snippet: string }> {
- const results: Array<{ url: string; snippet: string }> = [];
- const scriptRe = /