Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 13 additions & 14 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions docs/packages/lint.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions docs/packages/parsers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
46 changes: 7 additions & 39 deletions packages/core/src/compiler/assetPaths.ts
Original file line number Diff line number Diff line change
@@ -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";
122 changes: 7 additions & 115 deletions packages/core/src/compiler/rewriteSubCompPaths.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading