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
+14
View File
@@ -333,6 +333,20 @@
// 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",
// 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",
+13 -14
View File
@@ -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:*",
+19
View File
@@ -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).
<Info>
`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.
</Info>
## What the Linter Catches
Detected issues include:
+3
View File
@@ -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`, …) |
<Info>
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).
+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,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";
+7 -129
View File
@@ -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<Record<string, string>>;
export const FONT_ALIAS_KEYS: ReadonlySet<string> = 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<Record<string, string>> = {
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<string, string>)[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";
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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";
+2 -3
View File
@@ -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";
+2 -11
View File
@@ -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";
+11 -1
View File
@@ -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",
@@ -42,7 +53,6 @@
"prepublishOnly": "echo skip"
},
"dependencies": {
"@hyperframes/core": "workspace:*",
"@hyperframes/parsers": "workspace:*",
"postcss": "^8.5.8"
},
+22
View File
@@ -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 = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
</body></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);
});
});
+19
View File
@@ -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";
+3 -10
View File
@@ -1,6 +1,8 @@
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, rewriteAssetPath } from "@hyperframes/core";
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";
@@ -240,15 +242,6 @@ export async function lintProject(projectDir: string): Promise<ProjectLintResult
return { results, totalErrors, totalWarnings, totalInfos };
}
export function shouldBlockRender(
strictErrors: boolean,
strictAll: boolean,
totalErrors: number,
totalWarnings: number,
): boolean {
return (strictErrors && totalErrors > 0) || (strictAll && (totalErrors > 0 || totalWarnings > 0));
}
function lintProjectAudioFiles(
projectDir: string,
htmlSources: HtmlSource[],
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -1,4 +1,4 @@
import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "@hyperframes/core/fonts/aliases";
import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "@hyperframes/parsers/composition";
import type { LintContext, HyperframeLintFinding } from "../context";
import { isRegistrySourceFile, isRegistryInstalledFile } from "./composition";
+1 -1
View File
@@ -5,7 +5,7 @@ import {
parseSlideshowManifest,
resolveSlideshow,
isSceneLikeCompositionId,
} from "@hyperframes/core/slideshow";
} from "@hyperframes/parsers/slideshow";
type Scene = { id: string; start: number; duration: number };
+12
View File
@@ -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));
}
+29 -12
View File
@@ -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,
},
]);
+31
View File
@@ -11,6 +11,7 @@
"README.md"
],
"type": "module",
"sideEffects": false,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
@@ -62,6 +63,24 @@
"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"
},
"./composition": {
"bun": "./src/composition.ts",
"node": "./dist/composition.js",
"import": "./src/composition.ts",
"types": "./src/composition.ts"
}
},
"publishConfig": {
@@ -99,6 +118,18 @@
"./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"
},
"./composition": {
"import": "./dist/composition.js",
"types": "./dist/composition.d.ts"
}
},
"main": "./dist/index.js",
+45
View File
@@ -0,0 +1,45 @@
/**
* 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. 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;
/** 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);
}
+4
View File
@@ -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";
+12
View File
@@ -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";
+129
View File
@@ -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<Record<string, string>>;
export const FONT_ALIAS_KEYS: ReadonlySet<string> = 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<Record<string, string>> = {
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<string, string>)[alias.toLowerCase()];
if (!slug) return undefined;
return CANONICAL_FONT_DISPLAY_NAMES[slug];
}
+12
View File
@@ -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";
+115
View File
@@ -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<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 || ""})`;
});
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./slideshow.types.js";
export * from "./parseSlideshow.js";
export { isSceneLikeCompositionId } from "./sceneId.js";
+11
View File
@@ -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;
}
+3
View File
@@ -10,6 +10,9 @@ export default defineConfig({
springEase: "src/springEase.ts",
hfIds: "src/hfIds.ts",
gsapParser: "src/gsapParser.ts",
slideshow: "src/slideshow/index.ts",
assets: "src/assets.ts",
composition: "src/composition.ts",
},
format: ["esm"],
outDir: "dist",