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 = /]*>/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: truncateSnippet(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 { - const urls = extractScriptUrls(html); - if (urls.length === 0) return []; - - const timeout = options.timeoutMs ?? 8000; - const findings: HyperframeLintFinding[] = []; - - const seen = new Set(); - 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: `