From 7ed197b2942bd165ae890862758b6cfef68068dd Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 27 Jun 2026 12:54:20 -0400 Subject: [PATCH 1/2] refactor: make @hyperframes/lint depend only on parsers, not core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .fallowrc.jsonc | 8 ++ bun.lock | 27 ++-- packages/core/src/compiler/assetPaths.ts | 46 +----- .../core/src/compiler/rewriteSubCompPaths.ts | 122 +--------------- packages/core/src/fonts/aliases.ts | 136 +----------------- packages/core/src/index.ts | 4 +- packages/core/src/runtime/timeline.ts | 2 +- packages/core/src/slideshow/index.ts | 5 +- packages/core/src/utils/urlPath.ts | 13 +- packages/lint/package.json | 1 - packages/lint/src/project.ts | 3 +- packages/lint/src/rules/fonts.ts | 2 +- packages/lint/src/rules/slideshow.ts | 2 +- packages/parsers/package.json | 20 +++ packages/parsers/src/assetPaths.ts | 39 +++++ packages/parsers/src/assets.ts | 4 + packages/parsers/src/fontAliases.ts | 129 +++++++++++++++++ packages/parsers/src/index.ts | 12 ++ packages/parsers/src/rewriteSubCompPaths.ts | 115 +++++++++++++++ packages/parsers/src/slideshow/index.ts | 3 + .../src/slideshow/parseSlideshow.test.ts | 0 .../src/slideshow/parseSlideshow.ts | 0 .../src/slideshow/sceneId.ts | 0 .../src/slideshow/slideshow.types.ts | 0 packages/parsers/src/utils/urlPath.ts | 11 ++ packages/parsers/tsup.config.ts | 2 + 26 files changed, 388 insertions(+), 318 deletions(-) create mode 100644 packages/parsers/src/assetPaths.ts create mode 100644 packages/parsers/src/assets.ts create mode 100644 packages/parsers/src/fontAliases.ts create mode 100644 packages/parsers/src/rewriteSubCompPaths.ts create mode 100644 packages/parsers/src/slideshow/index.ts rename packages/{core => parsers}/src/slideshow/parseSlideshow.test.ts (100%) rename packages/{core => parsers}/src/slideshow/parseSlideshow.ts (100%) rename packages/{core => parsers}/src/slideshow/sceneId.ts (100%) rename packages/{core => parsers}/src/slideshow/slideshow.types.ts (100%) create mode 100644 packages/parsers/src/utils/urlPath.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 49ab642315..a312d7bd74 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -333,6 +333,14 @@ // complexity pre-dates the computed-timeline work. Exempted at file level // rather than refactored as scope creep. "ignore": [ + // timeline.ts: collectRuntimeTimelinePayload (CRITICAL) pre-dates this PR; + // only an import line changed here (slideshow/sceneId → slideshow/index), + // but the line-shift fingerprint makes fallow re-flag inherited complexity. + "packages/core/src/runtime/timeline.ts", + // sceneId.ts: trivial 5-cyclomatic guard, moved verbatim from + // packages/core/src/slideshow/ into parsers; flagged only because the move + // makes fallow treat it as a fresh finding. + "packages/parsers/src/slideshow/sceneId.ts", // gsapParser.ts: the recast/babel GSAP writer is a 2500-line legacy parser; // moved from packages/core/src/parsers/ — same complexity rationale. "packages/parsers/src/gsapParser.ts", diff --git a/bun.lock b/bun.lock index 41f53da468..a4e2e1eed2 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.7.14", + "version": "0.7.16", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.7.14", + "version": "0.7.16", "bin": { "hyperframes": "./dist/cli.js", }, @@ -103,7 +103,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.7.14", + "version": "0.7.16", "dependencies": { "@chenglou/pretext": "^0.0.5", "@hyperframes/lint": "workspace:*", @@ -128,7 +128,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.7.14", + "version": "0.7.16", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -146,7 +146,7 @@ }, "packages/gcp-cloud-run": { "name": "@hyperframes/gcp-cloud-run", - "version": "0.7.14", + "version": "0.7.16", "dependencies": { "@google-cloud/storage": "^7.14.0", "@google-cloud/workflows": "^4.2.0", @@ -166,9 +166,8 @@ }, "packages/lint": { "name": "@hyperframes/lint", - "version": "0.7.11", + "version": "0.7.16", "dependencies": { - "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", "postcss": "^8.5.8", }, @@ -182,7 +181,7 @@ }, "packages/parsers": { "name": "@hyperframes/parsers", - "version": "0.7.11", + "version": "0.7.16", "dependencies": { "@babel/parser": "^7.27.0", "acorn": "^8.17.0", @@ -202,7 +201,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.7.14", + "version": "0.7.16", "dependencies": { "@hyperframes/core": "workspace:*", }, @@ -217,7 +216,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.7.14", + "version": "0.7.16", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -260,7 +259,7 @@ }, "packages/sdk": { "name": "@hyperframes/sdk", - "version": "0.7.14", + "version": "0.7.16", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", @@ -286,7 +285,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.7.14", + "version": "0.7.16", "dependencies": { "html2canvas": "^1.4.1", }, @@ -298,7 +297,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.7.14", + "version": "0.7.16", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -345,7 +344,7 @@ }, "packages/studio-server": { "name": "@hyperframes/studio-server", - "version": "0.7.11", + "version": "0.7.16", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", diff --git a/packages/core/src/compiler/assetPaths.ts b/packages/core/src/compiler/assetPaths.ts index c1f75bf944..5accdaf569 100644 --- a/packages/core/src/compiler/assetPaths.ts +++ b/packages/core/src/compiler/assetPaths.ts @@ -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"; diff --git a/packages/core/src/compiler/rewriteSubCompPaths.ts b/packages/core/src/compiler/rewriteSubCompPaths.ts index d0c7eb0cb1..9f9b8f3f8e 100644 --- a/packages/core/src/compiler/rewriteSubCompPaths.ts +++ b/packages/core/src/compiler/rewriteSubCompPaths.ts @@ -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( - elements: Iterable, - 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( - elements: Iterable, - 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"; diff --git a/packages/core/src/fonts/aliases.ts b/packages/core/src/fonts/aliases.ts index 9328f0dc5a..14c7346b83 100644 --- a/packages/core/src/fonts/aliases.ts +++ b/packages/core/src/fonts/aliases.ts @@ -1,129 +1,7 @@ -/** - * Single source of truth for the deterministic font alias map. Both the - * producer's @font-face injector and the core lint rules import from here, - * eliminating manual drift between the two. - * - * Keys are lowercase font family names. Values are canonical font slugs - * matching CANONICAL_FONTS keys in the producer's deterministicFonts module. - */ -export const FONT_ALIAS_MAP = { - // ── Canonical bundled fonts (self-referencing) ──────────────────────── - inter: "inter", - montserrat: "montserrat", - outfit: "outfit", - nunito: "nunito", - oswald: "oswald", - "league gothic": "league-gothic", - "archivo black": "archivo-black", - "space mono": "space-mono", - "ibm plex mono": "ibm-plex-mono", - "jetbrains mono": "jetbrains-mono", - "eb garamond": "eb-garamond", - "playfair display": "playfair-display", - "source code pro": "source-code-pro", - "noto sans jp": "noto-sans-jp", - roboto: "roboto", - "open sans": "open-sans", - lato: "lato", - poppins: "poppins", - - // ── Common aliases → nearest canonical ──────────────────────────────── - "helvetica neue": "inter", - helvetica: "inter", - arial: "inter", - "helvetica bold": "inter", - futura: "montserrat", - "din alternate": "montserrat", - "arial black": "montserrat", - "bebas neue": "league-gothic", - "courier new": "jetbrains-mono", - courier: "jetbrains-mono", - garamond: "eb-garamond", - "noto sans japanese": "noto-sans-jp", - "segoe ui": "roboto", - - // ── macOS sans-serif system fonts → inter ───────────────────────────── - "sf pro": "inter", - "sf pro display": "inter", - "sf pro text": "inter", - "sf pro rounded": "inter", - avenir: "inter", - "avenir next": "inter", - "lucida grande": "inter", - geneva: "inter", - optima: "inter", - - // ── Windows sans-serif system fonts → inter ─────────────────────────── - verdana: "inter", - tahoma: "inter", - "trebuchet ms": "inter", - calibri: "inter", - candara: "inter", - corbel: "inter", - "lucida sans": "inter", - "lucida sans unicode": "inter", - - // ── Linux sans-serif system fonts → inter ───────────────────────────── - "noto sans": "inter", - "dejavu sans": "inter", - "liberation sans": "inter", - - // ── Monospace system fonts → jetbrains-mono ─────────────────────────── - "sf mono": "jetbrains-mono", - menlo: "jetbrains-mono", - monaco: "jetbrains-mono", - consolas: "jetbrains-mono", - "lucida console": "jetbrains-mono", - "lucida sans typewriter": "jetbrains-mono", - "andale mono": "jetbrains-mono", - "dejavu sans mono": "jetbrains-mono", - "liberation mono": "jetbrains-mono", - - // ── Serif system fonts → eb-garamond ────────────────────────────────── - georgia: "eb-garamond", - palatino: "eb-garamond", - "palatino linotype": "eb-garamond", - "book antiqua": "eb-garamond", - cambria: "eb-garamond", - times: "eb-garamond", - "times new roman": "eb-garamond", - "dejavu serif": "eb-garamond", - "liberation serif": "eb-garamond", -} satisfies Readonly>; - -export const FONT_ALIAS_KEYS: ReadonlySet = new Set(Object.keys(FONT_ALIAS_MAP)); - -/** - * Human-readable display names for canonical font slugs. Used by the lint - * rule to tell authors what their aliased font will render as. - */ -export const CANONICAL_FONT_DISPLAY_NAMES: Readonly> = { - inter: "Inter", - montserrat: "Montserrat", - outfit: "Outfit", - nunito: "Nunito", - oswald: "Oswald", - "league-gothic": "League Gothic", - "archivo-black": "Archivo Black", - "space-mono": "Space Mono", - "ibm-plex-mono": "IBM Plex Mono", - "jetbrains-mono": "JetBrains Mono", - "eb-garamond": "EB Garamond", - "playfair-display": "Playfair Display", - "source-code-pro": "Source Code Pro", - "noto-sans-jp": "Noto Sans JP", - roboto: "Roboto", - "open-sans": "Open Sans", - lato: "Lato", - poppins: "Poppins", -}; - -/** - * Resolve a font alias to its canonical display name, or undefined if the - * alias is not in the map. - */ -export function resolveAliasDisplayName(alias: string): string | undefined { - const slug = (FONT_ALIAS_MAP as Record)[alias.toLowerCase()]; - if (!slug) return undefined; - return CANONICAL_FONT_DISPLAY_NAMES[slug]; -} +// Moved to @hyperframes/parsers. Re-exported here for back-compat. +export { + FONT_ALIAS_MAP, + FONT_ALIAS_KEYS, + CANONICAL_FONT_DISPLAY_NAMES, + resolveAliasDisplayName, +} from "@hyperframes/parsers"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 42ca299622..fe31c13540 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -45,9 +45,9 @@ export type { ResolvedSlide, ResolvedSlideSequence, ResolvedSlideshow, -} from "./slideshow/slideshow.types"; +} from "./slideshow/index.js"; -export { parseSlideshowManifest, resolveSlideshow } from "./slideshow/parseSlideshow"; +export { parseSlideshowManifest, resolveSlideshow } from "./slideshow/index.js"; export { CANVAS_DIMENSIONS, diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index b3b8e3dfa1..550690c554 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -8,7 +8,7 @@ import { stableClipId } from "./clipTree"; import { swallow } from "./diagnostics"; import { readElementPlaybackRate } from "./media"; import { createRuntimeStartTimeResolver } from "./startResolver"; -import { isSceneLikeCompositionId } from "../slideshow/sceneId"; +import { isSceneLikeCompositionId } from "../slideshow/index.js"; const AUTHORED_DURATION_ATTR = "data-hf-authored-duration"; const AUTHORED_END_ATTR = "data-hf-authored-end"; diff --git a/packages/core/src/slideshow/index.ts b/packages/core/src/slideshow/index.ts index 4612820a55..a6e689b09c 100644 --- a/packages/core/src/slideshow/index.ts +++ b/packages/core/src/slideshow/index.ts @@ -1,3 +1,2 @@ -export * from "./slideshow.types"; -export * from "./parseSlideshow"; -export { isSceneLikeCompositionId } from "./sceneId"; +// Moved to @hyperframes/parsers/slideshow. Re-exported here for back-compat. +export * from "@hyperframes/parsers/slideshow"; diff --git a/packages/core/src/utils/urlPath.ts b/packages/core/src/utils/urlPath.ts index dbcebec7d7..65c7409042 100644 --- a/packages/core/src/utils/urlPath.ts +++ b/packages/core/src/utils/urlPath.ts @@ -1,11 +1,2 @@ -export function decodeUrlPathVariants(path: string): string[] { - const variants = [path]; - try { - const decoded = decodeURIComponent(path); - if (decoded !== path) variants.unshift(decoded); - } catch { - // Malformed percent sequences may be literal filesystem names. - } - - return variants; -} +// Moved to @hyperframes/parsers. Re-exported here for back-compat. +export { decodeUrlPathVariants } from "@hyperframes/parsers"; diff --git a/packages/lint/package.json b/packages/lint/package.json index 2565c52fa6..d2168acdf3 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -42,7 +42,6 @@ "prepublishOnly": "echo skip" }, "dependencies": { - "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", "postcss": "^8.5.8" }, diff --git a/packages/lint/src/project.ts b/packages/lint/src/project.ts index 34e6e167e1..13c226ab73 100644 --- a/packages/lint/src/project.ts +++ b/packages/lint/src/project.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "node:path"; -import { decodeUrlPathVariants, rewriteAssetPath } from "@hyperframes/core"; +import { decodeUrlPathVariants } from "@hyperframes/parsers"; +import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths"; import { lintHyperframeHtml } from "./hyperframeLinter.js"; import type { HyperframeLintFinding, HyperframeLintResult } from "./types.js"; diff --git a/packages/lint/src/rules/fonts.ts b/packages/lint/src/rules/fonts.ts index 63fea4e49e..e761afc471 100644 --- a/packages/lint/src/rules/fonts.ts +++ b/packages/lint/src/rules/fonts.ts @@ -1,4 +1,4 @@ -import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "@hyperframes/core/fonts/aliases"; +import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "@hyperframes/parsers"; import type { LintContext, HyperframeLintFinding } from "../context"; import { isRegistrySourceFile, isRegistryInstalledFile } from "./composition"; diff --git a/packages/lint/src/rules/slideshow.ts b/packages/lint/src/rules/slideshow.ts index 5580dc66ad..f435be33bf 100644 --- a/packages/lint/src/rules/slideshow.ts +++ b/packages/lint/src/rules/slideshow.ts @@ -5,7 +5,7 @@ import { parseSlideshowManifest, resolveSlideshow, isSceneLikeCompositionId, -} from "@hyperframes/core/slideshow"; +} from "@hyperframes/parsers/slideshow"; type Scene = { id: string; start: number; duration: number }; diff --git a/packages/parsers/package.json b/packages/parsers/package.json index 82c0f91247..e6c1a54acf 100644 --- a/packages/parsers/package.json +++ b/packages/parsers/package.json @@ -62,6 +62,18 @@ "node": "./dist/gsapParser.js", "import": "./src/gsapParser.ts", "types": "./src/gsapParser.ts" + }, + "./slideshow": { + "bun": "./src/slideshow/index.ts", + "node": "./dist/slideshow.js", + "import": "./src/slideshow/index.ts", + "types": "./src/slideshow/index.ts" + }, + "./asset-paths": { + "bun": "./src/assets.ts", + "node": "./dist/assets.js", + "import": "./src/assets.ts", + "types": "./src/assets.ts" } }, "publishConfig": { @@ -99,6 +111,14 @@ "./gsap-parser-recast": { "import": "./dist/gsapParser.js", "types": "./dist/gsapParser.d.ts" + }, + "./slideshow": { + "import": "./dist/slideshow.js", + "types": "./dist/slideshow.d.ts" + }, + "./asset-paths": { + "import": "./dist/assets.js", + "types": "./dist/assets.d.ts" } }, "main": "./dist/index.js", diff --git a/packages/parsers/src/assetPaths.ts b/packages/parsers/src/assetPaths.ts new file mode 100644 index 0000000000..c1f75bf944 --- /dev/null +++ b/packages/parsers/src/assetPaths.ts @@ -0,0 +1,39 @@ +/** + * 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); +} diff --git a/packages/parsers/src/assets.ts b/packages/parsers/src/assets.ts new file mode 100644 index 0000000000..bc0726a5ec --- /dev/null +++ b/packages/parsers/src/assets.ts @@ -0,0 +1,4 @@ +// Node-only asset-path utilities (use node:path). Behind the +// @hyperframes/parsers/asset-paths subpath so the main entry stays browser-safe. +export * from "./assetPaths.js"; +export * from "./rewriteSubCompPaths.js"; diff --git a/packages/parsers/src/fontAliases.ts b/packages/parsers/src/fontAliases.ts new file mode 100644 index 0000000000..9328f0dc5a --- /dev/null +++ b/packages/parsers/src/fontAliases.ts @@ -0,0 +1,129 @@ +/** + * Single source of truth for the deterministic font alias map. Both the + * producer's @font-face injector and the core lint rules import from here, + * eliminating manual drift between the two. + * + * Keys are lowercase font family names. Values are canonical font slugs + * matching CANONICAL_FONTS keys in the producer's deterministicFonts module. + */ +export const FONT_ALIAS_MAP = { + // ── Canonical bundled fonts (self-referencing) ──────────────────────── + inter: "inter", + montserrat: "montserrat", + outfit: "outfit", + nunito: "nunito", + oswald: "oswald", + "league gothic": "league-gothic", + "archivo black": "archivo-black", + "space mono": "space-mono", + "ibm plex mono": "ibm-plex-mono", + "jetbrains mono": "jetbrains-mono", + "eb garamond": "eb-garamond", + "playfair display": "playfair-display", + "source code pro": "source-code-pro", + "noto sans jp": "noto-sans-jp", + roboto: "roboto", + "open sans": "open-sans", + lato: "lato", + poppins: "poppins", + + // ── Common aliases → nearest canonical ──────────────────────────────── + "helvetica neue": "inter", + helvetica: "inter", + arial: "inter", + "helvetica bold": "inter", + futura: "montserrat", + "din alternate": "montserrat", + "arial black": "montserrat", + "bebas neue": "league-gothic", + "courier new": "jetbrains-mono", + courier: "jetbrains-mono", + garamond: "eb-garamond", + "noto sans japanese": "noto-sans-jp", + "segoe ui": "roboto", + + // ── macOS sans-serif system fonts → inter ───────────────────────────── + "sf pro": "inter", + "sf pro display": "inter", + "sf pro text": "inter", + "sf pro rounded": "inter", + avenir: "inter", + "avenir next": "inter", + "lucida grande": "inter", + geneva: "inter", + optima: "inter", + + // ── Windows sans-serif system fonts → inter ─────────────────────────── + verdana: "inter", + tahoma: "inter", + "trebuchet ms": "inter", + calibri: "inter", + candara: "inter", + corbel: "inter", + "lucida sans": "inter", + "lucida sans unicode": "inter", + + // ── Linux sans-serif system fonts → inter ───────────────────────────── + "noto sans": "inter", + "dejavu sans": "inter", + "liberation sans": "inter", + + // ── Monospace system fonts → jetbrains-mono ─────────────────────────── + "sf mono": "jetbrains-mono", + menlo: "jetbrains-mono", + monaco: "jetbrains-mono", + consolas: "jetbrains-mono", + "lucida console": "jetbrains-mono", + "lucida sans typewriter": "jetbrains-mono", + "andale mono": "jetbrains-mono", + "dejavu sans mono": "jetbrains-mono", + "liberation mono": "jetbrains-mono", + + // ── Serif system fonts → eb-garamond ────────────────────────────────── + georgia: "eb-garamond", + palatino: "eb-garamond", + "palatino linotype": "eb-garamond", + "book antiqua": "eb-garamond", + cambria: "eb-garamond", + times: "eb-garamond", + "times new roman": "eb-garamond", + "dejavu serif": "eb-garamond", + "liberation serif": "eb-garamond", +} satisfies Readonly>; + +export const FONT_ALIAS_KEYS: ReadonlySet = new Set(Object.keys(FONT_ALIAS_MAP)); + +/** + * Human-readable display names for canonical font slugs. Used by the lint + * rule to tell authors what their aliased font will render as. + */ +export const CANONICAL_FONT_DISPLAY_NAMES: Readonly> = { + inter: "Inter", + montserrat: "Montserrat", + outfit: "Outfit", + nunito: "Nunito", + oswald: "Oswald", + "league-gothic": "League Gothic", + "archivo-black": "Archivo Black", + "space-mono": "Space Mono", + "ibm-plex-mono": "IBM Plex Mono", + "jetbrains-mono": "JetBrains Mono", + "eb-garamond": "EB Garamond", + "playfair-display": "Playfair Display", + "source-code-pro": "Source Code Pro", + "noto-sans-jp": "Noto Sans JP", + roboto: "Roboto", + "open-sans": "Open Sans", + lato: "Lato", + poppins: "Poppins", +}; + +/** + * Resolve a font alias to its canonical display name, or undefined if the + * alias is not in the map. + */ +export function resolveAliasDisplayName(alias: string): string | undefined { + const slug = (FONT_ALIAS_MAP as Record)[alias.toLowerCase()]; + if (!slug) return undefined; + return CANONICAL_FONT_DISPLAY_NAMES[slug]; +} diff --git a/packages/parsers/src/index.ts b/packages/parsers/src/index.ts index f7498a9170..39053133d4 100644 --- a/packages/parsers/src/index.ts +++ b/packages/parsers/src/index.ts @@ -4,3 +4,15 @@ export * from "./htmlParser.js"; export * from "./hfIds.js"; export { unrollComputedTimeline } from "./gsapUnroll.js"; export { queryByAttr } from "./utils/cssSelector.js"; + +// Pure, browser-safe composition primitives shared by the linter (so it can +// consume them without depending on @hyperframes/core). The Node-only asset +// path helpers live behind the ./asset-paths subpath to keep this entry +// browser-safe. +export { decodeUrlPathVariants } from "./utils/urlPath.js"; +export { + FONT_ALIAS_MAP, + FONT_ALIAS_KEYS, + CANONICAL_FONT_DISPLAY_NAMES, + resolveAliasDisplayName, +} from "./fontAliases.js"; diff --git a/packages/parsers/src/rewriteSubCompPaths.ts b/packages/parsers/src/rewriteSubCompPaths.ts new file mode 100644 index 0000000000..d0c7eb0cb1 --- /dev/null +++ b/packages/parsers/src/rewriteSubCompPaths.ts @@ -0,0 +1,115 @@ +/** + * 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( + elements: Iterable, + 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( + elements: Iterable, + 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 || ""})`; + }); +} diff --git a/packages/parsers/src/slideshow/index.ts b/packages/parsers/src/slideshow/index.ts new file mode 100644 index 0000000000..37c13a9965 --- /dev/null +++ b/packages/parsers/src/slideshow/index.ts @@ -0,0 +1,3 @@ +export * from "./slideshow.types.js"; +export * from "./parseSlideshow.js"; +export { isSceneLikeCompositionId } from "./sceneId.js"; diff --git a/packages/core/src/slideshow/parseSlideshow.test.ts b/packages/parsers/src/slideshow/parseSlideshow.test.ts similarity index 100% rename from packages/core/src/slideshow/parseSlideshow.test.ts rename to packages/parsers/src/slideshow/parseSlideshow.test.ts diff --git a/packages/core/src/slideshow/parseSlideshow.ts b/packages/parsers/src/slideshow/parseSlideshow.ts similarity index 100% rename from packages/core/src/slideshow/parseSlideshow.ts rename to packages/parsers/src/slideshow/parseSlideshow.ts diff --git a/packages/core/src/slideshow/sceneId.ts b/packages/parsers/src/slideshow/sceneId.ts similarity index 100% rename from packages/core/src/slideshow/sceneId.ts rename to packages/parsers/src/slideshow/sceneId.ts diff --git a/packages/core/src/slideshow/slideshow.types.ts b/packages/parsers/src/slideshow/slideshow.types.ts similarity index 100% rename from packages/core/src/slideshow/slideshow.types.ts rename to packages/parsers/src/slideshow/slideshow.types.ts diff --git a/packages/parsers/src/utils/urlPath.ts b/packages/parsers/src/utils/urlPath.ts new file mode 100644 index 0000000000..dbcebec7d7 --- /dev/null +++ b/packages/parsers/src/utils/urlPath.ts @@ -0,0 +1,11 @@ +export function decodeUrlPathVariants(path: string): string[] { + const variants = [path]; + try { + const decoded = decodeURIComponent(path); + if (decoded !== path) variants.unshift(decoded); + } catch { + // Malformed percent sequences may be literal filesystem names. + } + + return variants; +} diff --git a/packages/parsers/tsup.config.ts b/packages/parsers/tsup.config.ts index 3fee07ef05..3bfcaf3318 100644 --- a/packages/parsers/tsup.config.ts +++ b/packages/parsers/tsup.config.ts @@ -10,6 +10,8 @@ export default defineConfig({ springEase: "src/springEase.ts", hfIds: "src/hfIds.ts", gsapParser: "src/gsapParser.ts", + slideshow: "src/slideshow/index.ts", + assets: "src/assets.ts", }, format: ["esm"], outDir: "dist", From 682beaa44cd3c048a0a63f8bf98bc241ae56af8b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 27 Jun 2026 13:24:50 -0400 Subject: [PATCH 2/2] feat(lint): add browser entry; harden CSS url() regex (ReDoS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @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 --- .fallowrc.jsonc | 6 +++ docs/packages/lint.mdx | 19 +++++++++ docs/packages/parsers.mdx | 3 ++ packages/lint/package.json | 11 +++++ packages/lint/src/browser.test.ts | 22 ++++++++++ packages/lint/src/browser.ts | 19 +++++++++ packages/lint/src/project.ts | 12 +----- packages/lint/src/rules/composition.ts | 2 +- packages/lint/src/rules/fonts.ts | 2 +- packages/lint/src/shouldBlockRender.ts | 12 ++++++ packages/lint/tsup.config.ts | 41 +++++++++++++------ packages/parsers/package.json | 11 +++++ packages/parsers/src/assetPaths.ts | 10 ++++- packages/parsers/src/composition.ts | 12 ++++++ .../src}/rewriteSubCompPaths.test.ts | 0 packages/parsers/tsup.config.ts | 1 + 16 files changed, 157 insertions(+), 26 deletions(-) create mode 100644 packages/lint/src/browser.test.ts create mode 100644 packages/lint/src/browser.ts create mode 100644 packages/lint/src/shouldBlockRender.ts create mode 100644 packages/parsers/src/composition.ts rename packages/{core/src/compiler => parsers/src}/rewriteSubCompPaths.test.ts (100%) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index a312d7bd74..a4d0163de3 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -341,6 +341,12 @@ // packages/core/src/slideshow/ into parsers; flagged only because the move // makes fallow treat it as a fresh finding. "packages/parsers/src/slideshow/sceneId.ts", + // assetPaths.ts / rewriteSubCompPaths.ts: pure URL/asset-path helpers moved + // verbatim from packages/core/src/compiler/ into parsers. The move makes + // fallow score them as fresh (high CRAP = inherited complexity with no + // coverage mapping yet); the logic is unchanged. + "packages/parsers/src/assetPaths.ts", + "packages/parsers/src/rewriteSubCompPaths.ts", // gsapParser.ts: the recast/babel GSAP writer is a 2500-line legacy parser; // moved from packages/core/src/parsers/ — same complexity rationale. "packages/parsers/src/gsapParser.ts", diff --git a/docs/packages/lint.mdx b/docs/packages/lint.mdx index 2c8ea68c85..c794f954b3 100644 --- a/docs/packages/lint.mdx +++ b/docs/packages/lint.mdx @@ -79,6 +79,25 @@ if (shouldBlockRender(result)) { } ``` +## Browser usage + +The rule engine runs **fully client-side** — no Node.js, no filesystem, no server round-trip. Import from the `@hyperframes/lint/browser` entry to validate composition HTML directly in a browser-only editor or tool: + +```typescript +import { lintHyperframeHtml, shouldBlockRender } from '@hyperframes/lint/browser'; + +const result = await lintHyperframeHtml(htmlString, { filePath: 'index.html' }); +if (!result.ok) { + for (const f of result.findings) console.warn(f.code, f.message); +} +``` + +The browser entry exposes `lintHyperframeHtml`, `lintMediaUrls`, and `shouldBlockRender` — everything that operates on an HTML string. It is built with a browser target and contains **zero `node:` builtins**, so it bundles cleanly for the client (verified at build time). + + + `lintProject` (which walks a project **directory**) is filesystem-based and is **not** part of the browser entry — import it from the main `@hyperframes/lint` entry in Node. + + ## What the Linter Catches Detected issues include: diff --git a/docs/packages/parsers.mdx b/docs/packages/parsers.mdx index 658be7ea5a..0627ef4002 100644 --- a/docs/packages/parsers.mdx +++ b/docs/packages/parsers.mdx @@ -32,6 +32,9 @@ npm install @hyperframes/parsers | `@hyperframes/parsers/gsap-constants` | `SUPPORTED_PROPS`, `SUPPORTED_EASES`, property groups | | `@hyperframes/parsers/spring-ease` | Spring-ease curve generation | | `@hyperframes/parsers/hf-ids` | Deterministic element id stamping | +| `@hyperframes/parsers/slideshow` | Slideshow manifest parser (`parseSlideshowManifest`, `resolveSlideshow`) | +| `@hyperframes/parsers/composition` | Pure, browser-safe composition primitives (data types, font aliases, URL helper) | +| `@hyperframes/parsers/asset-paths` | Node-only asset-path rewriting helpers (`rewriteAssetPath`, …) | The package ships subpath entries so consumers tree-shake to what they use — importing `@hyperframes/parsers/hf-ids` (a couple KB) does **not** pull in the GSAP AST machinery (recast/babel/acorn). diff --git a/packages/lint/package.json b/packages/lint/package.json index d2168acdf3..7cf78d61d0 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -11,6 +11,7 @@ "README.md" ], "type": "module", + "sideEffects": false, "main": "./src/index.ts", "types": "./src/index.ts", "exports": { @@ -20,6 +21,12 @@ "import": "./src/index.ts", "types": "./src/index.ts" }, + "./browser": { + "browser": "./src/browser.ts", + "bun": "./src/browser.ts", + "import": "./src/browser.ts", + "types": "./src/browser.ts" + }, "./package.json": "./package.json" }, "publishConfig": { @@ -29,6 +36,10 @@ "import": "./dist/index.js", "types": "./dist/index.d.ts" }, + "./browser": { + "import": "./dist/browser.js", + "types": "./dist/browser.d.ts" + }, "./package.json": "./package.json" }, "main": "./dist/index.js", diff --git a/packages/lint/src/browser.test.ts b/packages/lint/src/browser.test.ts new file mode 100644 index 0000000000..3f52c219f9 --- /dev/null +++ b/packages/lint/src/browser.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { lintHyperframeHtml, shouldBlockRender } from "./browser.js"; + +// Guards that @hyperframes/lint/browser exposes a working, node-free rule engine. +// (The platform:"browser" tsup build is the compile-time node-free guarantee; +// this verifies the API actually runs.) +describe("@hyperframes/lint/browser", () => { + it("lints an HTML string with no filesystem access", async () => { + const html = ` +
+ `; + const result = await lintHyperframeHtml(html, { filePath: "index.html" }); + expect(typeof result.ok).toBe("boolean"); + expect(Array.isArray(result.findings)).toBe(true); + }); + + it("exposes the pure shouldBlockRender gate", () => { + expect(shouldBlockRender(true, false, 1, 0)).toBe(true); + expect(shouldBlockRender(true, false, 0, 3)).toBe(false); + expect(shouldBlockRender(false, true, 0, 1)).toBe(true); + }); +}); diff --git a/packages/lint/src/browser.ts b/packages/lint/src/browser.ts new file mode 100644 index 0000000000..568ae1b400 --- /dev/null +++ b/packages/lint/src/browser.ts @@ -0,0 +1,19 @@ +/** + * Browser-safe entry for @hyperframes/lint. + * + * Exposes the composition rule engine — HTML-string in, findings out — with + * **zero Node.js dependencies**: no `node:fs`, no filesystem, no server. This + * lets browser-only editors and tools validate compositions entirely + * client-side, before any network call. + * + * The Node-only project layer (`lintProject`, which walks a directory) is NOT + * exported here — import it from the main `@hyperframes/lint` entry in Node. + */ +export type { + HyperframeLintSeverity, + HyperframeLintFinding, + HyperframeLintResult, + HyperframeLinterOptions, +} from "./types.js"; +export { lintHyperframeHtml, lintMediaUrls } from "./hyperframeLinter.js"; +export { shouldBlockRender } from "./shouldBlockRender.js"; diff --git a/packages/lint/src/project.ts b/packages/lint/src/project.ts index 13c226ab73..82a5094f6b 100644 --- a/packages/lint/src/project.ts +++ b/packages/lint/src/project.ts @@ -1,6 +1,7 @@ +export { shouldBlockRender } from "./shouldBlockRender.js"; import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "node:path"; -import { decodeUrlPathVariants } from "@hyperframes/parsers"; +import { decodeUrlPathVariants } from "@hyperframes/parsers/composition"; import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths"; import { lintHyperframeHtml } from "./hyperframeLinter.js"; import type { HyperframeLintFinding, HyperframeLintResult } from "./types.js"; @@ -241,15 +242,6 @@ export async function lintProject(projectDir: string): Promise 0) || (strictAll && (totalErrors > 0 || totalWarnings > 0)); -} - function lintProjectAudioFiles( projectDir: string, htmlSources: HtmlSource[], diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts index 4c1ccd1499..1d296a2277 100644 --- a/packages/lint/src/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -1,6 +1,6 @@ import type { LintContext, HyperframeLintFinding, ExtractedBlock } from "../context"; import { findHtmlTag, readAttr, readJsonAttr, stripJsComments, truncateSnippet } from "../utils"; -import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers"; +import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers/composition"; // Agent guidance thresholds: warning-only nudges for files/tracks that become hard // to inspect and revise reliably in a single composition. diff --git a/packages/lint/src/rules/fonts.ts b/packages/lint/src/rules/fonts.ts index e761afc471..a942704e6d 100644 --- a/packages/lint/src/rules/fonts.ts +++ b/packages/lint/src/rules/fonts.ts @@ -1,4 +1,4 @@ -import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "@hyperframes/parsers"; +import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "@hyperframes/parsers/composition"; import type { LintContext, HyperframeLintFinding } from "../context"; import { isRegistrySourceFile, isRegistryInstalledFile } from "./composition"; diff --git a/packages/lint/src/shouldBlockRender.ts b/packages/lint/src/shouldBlockRender.ts new file mode 100644 index 0000000000..04ba838bc0 --- /dev/null +++ b/packages/lint/src/shouldBlockRender.ts @@ -0,0 +1,12 @@ +/** + * Pure render-gate decision — no Node.js dependencies, so it is safe to import + * from the browser entry alongside the rule engine. + */ +export function shouldBlockRender( + strictErrors: boolean, + strictAll: boolean, + totalErrors: number, + totalWarnings: number, +): boolean { + return (strictErrors && totalErrors > 0) || (strictAll && (totalErrors > 0 || totalWarnings > 0)); +} diff --git a/packages/lint/tsup.config.ts b/packages/lint/tsup.config.ts index f5380e58e2..e4948a4d3c 100644 --- a/packages/lint/tsup.config.ts +++ b/packages/lint/tsup.config.ts @@ -1,14 +1,31 @@ import { defineConfig } from "tsup"; -export default defineConfig({ - entry: { index: "src/index.ts" }, - format: ["esm"], - outDir: "dist", - target: "node22", - platform: "node", - bundle: true, - splitting: false, - sourcemap: true, - clean: true, - dts: true, -}); +export default defineConfig([ + { + entry: { index: "src/index.ts" }, + format: ["esm"], + outDir: "dist", + target: "node22", + platform: "node", + bundle: true, + splitting: false, + sourcemap: true, + clean: true, + dts: true, + }, + { + // Browser-safe subset. platform: "browser" makes the build FAIL if any + // node:* builtin sneaks into the rule engine — a compile-time guarantee + // that @hyperframes/lint/browser stays client-side runnable. + entry: { browser: "src/browser.ts" }, + format: ["esm"], + outDir: "dist", + target: "es2022", + platform: "browser", + bundle: true, + splitting: false, + sourcemap: true, + clean: false, + dts: true, + }, +]); diff --git a/packages/parsers/package.json b/packages/parsers/package.json index e6c1a54acf..7f410a6688 100644 --- a/packages/parsers/package.json +++ b/packages/parsers/package.json @@ -11,6 +11,7 @@ "README.md" ], "type": "module", + "sideEffects": false, "main": "./src/index.ts", "types": "./src/index.ts", "exports": { @@ -74,6 +75,12 @@ "node": "./dist/assets.js", "import": "./src/assets.ts", "types": "./src/assets.ts" + }, + "./composition": { + "bun": "./src/composition.ts", + "node": "./dist/composition.js", + "import": "./src/composition.ts", + "types": "./src/composition.ts" } }, "publishConfig": { @@ -119,6 +126,10 @@ "./asset-paths": { "import": "./dist/assets.js", "types": "./dist/assets.d.ts" + }, + "./composition": { + "import": "./dist/composition.js", + "types": "./dist/composition.d.ts" } }, "main": "./dist/index.js", diff --git a/packages/parsers/src/assetPaths.ts b/packages/parsers/src/assetPaths.ts index c1f75bf944..f02c374464 100644 --- a/packages/parsers/src/assetPaths.ts +++ b/packages/parsers/src/assetPaths.ts @@ -7,8 +7,14 @@ 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; +/** + * Regex matching CSS `url(...)` references — captures the quote style and the + * raw URL. The URL group is anchored to non-whitespace at both ends so the + * surrounding `\s*` can never overlap it (avoids polynomial-ReDoS backtracking); + * the captured value is whitespace-bounded already, matching the old behavior + * after callers `.trim()` it. + */ +export const CSS_URL_RE = /\burl\(\s*(["']?)([^)"'\s](?:[^)"']*[^)"'\s])?)\1\s*\)/g; /** Attributes that may contain relative asset paths. */ export const PATH_ATTRS = ["src", "href"] as const; diff --git a/packages/parsers/src/composition.ts b/packages/parsers/src/composition.ts new file mode 100644 index 0000000000..a6592d6968 --- /dev/null +++ b/packages/parsers/src/composition.ts @@ -0,0 +1,12 @@ +// Pure, browser-safe composition primitives (data types, font aliases, URL +// helper). Recast/linkedom-free, so browser consumers (e.g. the lint rule +// engine via @hyperframes/lint/browser) can import these without pulling the +// GSAP/HTML parser machinery from the main entry. +export * from "./types.js"; +export { + FONT_ALIAS_MAP, + FONT_ALIAS_KEYS, + CANONICAL_FONT_DISPLAY_NAMES, + resolveAliasDisplayName, +} from "./fontAliases.js"; +export { decodeUrlPathVariants } from "./utils/urlPath.js"; diff --git a/packages/core/src/compiler/rewriteSubCompPaths.test.ts b/packages/parsers/src/rewriteSubCompPaths.test.ts similarity index 100% rename from packages/core/src/compiler/rewriteSubCompPaths.test.ts rename to packages/parsers/src/rewriteSubCompPaths.test.ts diff --git a/packages/parsers/tsup.config.ts b/packages/parsers/tsup.config.ts index 3bfcaf3318..c806696157 100644 --- a/packages/parsers/tsup.config.ts +++ b/packages/parsers/tsup.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ gsapParser: "src/gsapParser.ts", slideshow: "src/slideshow/index.ts", assets: "src/assets.ts", + composition: "src/composition.ts", }, format: ["esm"], outDir: "dist",