mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
refactor: make @hyperframes/lint depend only on parsers (#1773)
* refactor: make @hyperframes/lint depend only on parsers, not core Relocates the leaf utilities lint pulled from core — URL/asset-path helpers, font aliases, and the slideshow manifest parser — into the standalone @hyperframes/parsers base, and drops @hyperframes/core from lint's dependencies. Core keeps back-compat re-export stubs at the old paths, so producer/studio/cli are unchanged. Why: lint was the lightweight validator from #1749, but depending on core transitively pulled studio-server (hono) and bpm-detective — irrelevant to linting. Now installing @hyperframes/lint pulls only parsers + postcss, and the core<->lint dependency cycle is gone. - parsers main entry stays browser-safe (pure utils only); the node:path asset helpers live behind the new @hyperframes/parsers/asset-paths subpath - slideshow parser exposed via @hyperframes/parsers/slideshow * feat(lint): add browser entry; harden CSS url() regex (ReDoS) @hyperframes/lint/browser — a fully client-side rule engine (lintHyperframeHtml, lintMediaUrls, shouldBlockRender) with zero node: builtins, so browser-only editors can validate compositions with no Node.js and no server round-trip. Closes the browser-validation ask on #1749. - shouldBlockRender extracted from the fs-bound project.ts into its own pure module so the browser entry stays node-free - pure composition primitives (data types, font aliases, URL helper) exposed via a new recast-free @hyperframes/parsers/composition subpath, so the browser bundle tree-shakes out the GSAP/recast machinery (verified: esbuild platform=browser bundles with 0 node builtins) - lint built with a platform:browser tsup pass — compile-time guarantee the browser entry never pulls a node builtin - harden CSS_URL_RE against polynomial ReDoS (CodeQL js/polynomial-redos); behavior-preserving, verified against existing tests + an old/new parity check - parsers/lint marked sideEffects:false
This commit is contained in:
@@ -1,39 +1,7 @@
|
||||
/**
|
||||
* Shared primitives for scanning and rewriting asset paths in HTML/CSS.
|
||||
*
|
||||
* Used by: rewriteSubCompPaths (core), collectExternalAssets (producer),
|
||||
* localizeExternalAssets (CLI publish).
|
||||
*/
|
||||
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
/** Regex matching CSS `url(...)` references — captures the quote style and the raw URL. */
|
||||
export const CSS_URL_RE = /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g;
|
||||
|
||||
/** Attributes that may contain relative asset paths. */
|
||||
export const PATH_ATTRS = ["src", "href"] as const;
|
||||
|
||||
/** Returns true for URLs/prefixes that should never be rewritten. */
|
||||
export function isNonRelativeUrl(val: string): boolean {
|
||||
return (
|
||||
!val ||
|
||||
val.startsWith("http://") ||
|
||||
val.startsWith("https://") ||
|
||||
val.startsWith("//") ||
|
||||
val.startsWith("data:") ||
|
||||
val.startsWith("#") ||
|
||||
val.startsWith("/")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-platform containment check: is `childPath` inside `parentPath`?
|
||||
* Equality counts as "inside".
|
||||
*/
|
||||
export function isPathInside(childPath: string, parentPath: string): boolean {
|
||||
const absChild = resolve(childPath);
|
||||
const absParent = resolve(parentPath);
|
||||
if (absChild === absParent) return true;
|
||||
const rel = relative(absParent, absChild);
|
||||
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
||||
}
|
||||
// Moved to @hyperframes/parsers. Re-exported here for back-compat.
|
||||
export {
|
||||
CSS_URL_RE,
|
||||
PATH_ATTRS,
|
||||
isNonRelativeUrl,
|
||||
isPathInside,
|
||||
} from "@hyperframes/parsers/asset-paths";
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
rewriteAssetPath,
|
||||
rewriteCssAssetUrls,
|
||||
rewriteInlineStyleAssetUrls,
|
||||
} from "./rewriteSubCompPaths.js";
|
||||
|
||||
describe("rewriteAssetPath", () => {
|
||||
it("rewrites `../` against the sub-composition dir", () => {
|
||||
expect(rewriteAssetPath("compositions/scene.html", "../icon.svg")).toBe("icon.svg");
|
||||
});
|
||||
|
||||
it("leaves plain relative paths untouched", () => {
|
||||
expect(rewriteAssetPath("compositions/scene.html", "assets/logo.png")).toBe("assets/logo.png");
|
||||
});
|
||||
|
||||
it("leaves absolute URLs and data URIs untouched", () => {
|
||||
expect(rewriteAssetPath("compositions/scene.html", "https://x/y")).toBe("https://x/y");
|
||||
expect(rewriteAssetPath("compositions/scene.html", "data:image/png;base64,AA")).toBe(
|
||||
"data:image/png;base64,AA",
|
||||
);
|
||||
expect(rewriteAssetPath("compositions/scene.html", "#hash")).toBe("#hash");
|
||||
});
|
||||
|
||||
// Regression guard for a Windows-only bug: the rewriter used to import
|
||||
// `path` (native) and emit `:\fonts\brand.woff2` — native `join` used
|
||||
// backslashes, and `resolve("/", x).slice(1)` chopped the `D` off a
|
||||
// `D:\…` absolute path. URLs must be POSIX regardless of host OS.
|
||||
it("never emits backslashes on any platform", () => {
|
||||
const out = rewriteAssetPath("compositions/nested/scene.html", "../../fonts/brand.woff2");
|
||||
expect(out).toBe("fonts/brand.woff2");
|
||||
expect(out).not.toMatch(/\\/);
|
||||
expect(out).not.toMatch(/^:/);
|
||||
});
|
||||
|
||||
it("CSS url(...) rewrites also stay POSIX under nesting", () => {
|
||||
const css = `@font-face { src: url("../../fonts/brand.woff2") format("woff2"); }`;
|
||||
const out = rewriteCssAssetUrls(css, "compositions/nested/scene.html");
|
||||
expect(out).toContain(`url("fonts/brand.woff2")`);
|
||||
expect(out).not.toMatch(/\\/);
|
||||
expect(out).not.toMatch(/:\\/);
|
||||
});
|
||||
|
||||
it("rewrites CSS urls inside inline style attributes", () => {
|
||||
const elements = [{ style: `background-image: url("../cover.png")` }];
|
||||
|
||||
rewriteInlineStyleAssetUrls(
|
||||
elements,
|
||||
"compositions/scene.html",
|
||||
(el) => el.style,
|
||||
(el, value) => {
|
||||
el.style = value;
|
||||
},
|
||||
);
|
||||
|
||||
expect(elements[0]?.style).toBe(`background-image: url("cover.png")`);
|
||||
});
|
||||
});
|
||||
@@ -1,115 +1,7 @@
|
||||
/**
|
||||
* Rewrite relative asset paths in sub-composition content so they resolve
|
||||
* correctly after the content is inlined into the root document.
|
||||
*
|
||||
* A sub-composition at "compositions/scene.html" referencing "../icon.svg"
|
||||
* means the project root — but after inlining into root index.html, the
|
||||
* "../" escapes the project directory and causes 404s. This function
|
||||
* resolves each relative path against the sub-composition's directory,
|
||||
* then normalizes it to be relative to the project root.
|
||||
*
|
||||
* Used by both the core bundler (preview) and the producer compiler (render)
|
||||
* to ensure consistent behavior.
|
||||
*/
|
||||
|
||||
// URL paths in HTML output are POSIX regardless of host OS — use the `posix`
|
||||
// submodule so Windows builds don't emit backslash-separated paths (or worse,
|
||||
// drive-letter-prefixed artifacts from `resolve("/", ...)`).
|
||||
import { posix } from "path";
|
||||
const { join, resolve, dirname } = posix;
|
||||
|
||||
import { CSS_URL_RE, PATH_ATTRS, isNonRelativeUrl } from "./assetPaths.js";
|
||||
|
||||
const isAbsoluteOrSpecial = isNonRelativeUrl;
|
||||
|
||||
/**
|
||||
* Returns true only for paths that traverse up with `../`.
|
||||
* Plain relative paths like `assets/foo.svg` are already correct from the
|
||||
* root perspective — the browser resolves them against the served root, which
|
||||
* is the project root, so they don't need rewriting.
|
||||
*/
|
||||
function needsRewrite(val: string): boolean {
|
||||
return val.startsWith("../") || val === "..";
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a single relative path from a sub-composition's context to the
|
||||
* project root context.
|
||||
*
|
||||
* @param compSrcPath - The `data-composition-src` value (e.g. "compositions/scene.html")
|
||||
* @param relativePath - The asset path to rewrite (e.g. "../icon.svg")
|
||||
* @returns The rewritten path relative to project root (e.g. "icon.svg"), or
|
||||
* the original path if no rewriting is needed.
|
||||
*/
|
||||
export function rewriteAssetPath(compSrcPath: string, relativePath: string): string {
|
||||
if (isAbsoluteOrSpecial(relativePath)) return relativePath;
|
||||
if (!needsRewrite(relativePath)) return relativePath;
|
||||
const compDir = dirname(compSrcPath);
|
||||
if (!compDir || compDir === ".") return relativePath;
|
||||
const resolved = join(compDir, relativePath);
|
||||
const normalized = resolve("/", resolved).slice(1);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite all relative `src` and `href` attributes on elements within a
|
||||
* DOM tree, adjusting paths from the sub-composition's directory context
|
||||
* to the project root.
|
||||
*
|
||||
* @param elements - Iterable of DOM elements to scan (e.g. from querySelectorAll)
|
||||
* @param compSrcPath - The `data-composition-src` value
|
||||
* @param getAttr - Function to read an attribute from an element
|
||||
* @param setAttr - Function to set an attribute on an element
|
||||
*/
|
||||
export function rewriteAssetPaths<T>(
|
||||
elements: Iterable<T>,
|
||||
compSrcPath: string,
|
||||
getAttr: (el: T, attr: string) => string | null | undefined,
|
||||
setAttr: (el: T, attr: string, value: string) => void,
|
||||
): void {
|
||||
for (const el of elements) {
|
||||
for (const attr of PATH_ATTRS) {
|
||||
const val = (getAttr(el, attr) || "").trim();
|
||||
const rewritten = rewriteAssetPath(compSrcPath, val);
|
||||
if (rewritten !== val) {
|
||||
setAttr(el, attr, rewritten);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite CSS url(...) references inside inline style attributes.
|
||||
*/
|
||||
export function rewriteInlineStyleAssetUrls<T>(
|
||||
elements: Iterable<T>,
|
||||
compSrcPath: string,
|
||||
getStyle: (el: T) => string | null | undefined,
|
||||
setStyle: (el: T, value: string) => void,
|
||||
): void {
|
||||
const compDir = dirname(compSrcPath);
|
||||
if (!compDir || compDir === ".") return;
|
||||
|
||||
for (const el of elements) {
|
||||
const style = getStyle(el);
|
||||
if (!style) continue;
|
||||
const rewritten = rewriteCssAssetUrls(style, compSrcPath);
|
||||
if (rewritten !== style) {
|
||||
setStyle(el, rewritten);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite CSS url(...) references in a sub-composition's inline styles so
|
||||
* ../foo.woff2 remains valid after the CSS is hoisted into the root document.
|
||||
*/
|
||||
export function rewriteCssAssetUrls(cssText: string, compSrcPath: string): string {
|
||||
if (!cssText) return cssText;
|
||||
return cssText.replace(CSS_URL_RE, (full, quote: string, rawUrl: string) => {
|
||||
const urlValue = (rawUrl || "").trim();
|
||||
const rewritten = rewriteAssetPath(compSrcPath, urlValue);
|
||||
if (rewritten === urlValue) return full;
|
||||
return `url(${quote || ""}${rewritten}${quote || ""})`;
|
||||
});
|
||||
}
|
||||
// Moved to @hyperframes/parsers. Re-exported here for back-compat.
|
||||
export {
|
||||
rewriteAssetPath,
|
||||
rewriteAssetPaths,
|
||||
rewriteInlineStyleAssetUrls,
|
||||
rewriteCssAssetUrls,
|
||||
} from "@hyperframes/parsers/asset-paths";
|
||||
|
||||
Reference in New Issue
Block a user