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:
Miguel Ángel
2026-06-27 13:51:21 -04:00
committed by GitHub
parent 7e0a4cd02e
commit 6aaab32ccb
35 changed files with 541 additions and 340 deletions
+7 -39
View File
@@ -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";