refactor: make @hyperframes/lint depend only on parsers (#1773)

* refactor: make @hyperframes/lint depend only on parsers, not core

Relocates the leaf utilities lint pulled from core — URL/asset-path helpers,
font aliases, and the slideshow manifest parser — into the standalone
@hyperframes/parsers base, and drops @hyperframes/core from lint's
dependencies. Core keeps back-compat re-export stubs at the old paths, so
producer/studio/cli are unchanged.

Why: lint was the lightweight validator from #1749, but depending on core
transitively pulled studio-server (hono) and bpm-detective — irrelevant to
linting. Now installing @hyperframes/lint pulls only parsers + postcss, and
the core<->lint dependency cycle is gone.

- parsers main entry stays browser-safe (pure utils only); the node:path
  asset helpers live behind the new @hyperframes/parsers/asset-paths subpath
- slideshow parser exposed via @hyperframes/parsers/slideshow

* feat(lint): add browser entry; harden CSS url() regex (ReDoS)

@hyperframes/lint/browser — a fully client-side rule engine (lintHyperframeHtml,
lintMediaUrls, shouldBlockRender) with zero node: builtins, so browser-only
editors can validate compositions with no Node.js and no server round-trip.
Closes the browser-validation ask on #1749.

- shouldBlockRender extracted from the fs-bound project.ts into its own pure
  module so the browser entry stays node-free
- pure composition primitives (data types, font aliases, URL helper) exposed via
  a new recast-free @hyperframes/parsers/composition subpath, so the browser
  bundle tree-shakes out the GSAP/recast machinery (verified: esbuild
  platform=browser bundles with 0 node builtins)
- lint built with a platform:browser tsup pass — compile-time guarantee the
  browser entry never pulls a node builtin
- harden CSS_URL_RE against polynomial ReDoS (CodeQL js/polynomial-redos);
  behavior-preserving, verified against existing tests + an old/new parity check
- parsers/lint marked sideEffects:false
This commit is contained in:
Miguel Ángel
2026-06-27 13:51:21 -04:00
committed by GitHub
parent 7e0a4cd02e
commit 6aaab32ccb
35 changed files with 541 additions and 340 deletions
+7 -39
View File
@@ -1,39 +1,7 @@
/**
* Shared primitives for scanning and rewriting asset paths in HTML/CSS.
*
* Used by: rewriteSubCompPaths (core), collectExternalAssets (producer),
* localizeExternalAssets (CLI publish).
*/
import { isAbsolute, relative, resolve } from "node:path";
/** Regex matching CSS `url(...)` references — captures the quote style and the raw URL. */
export const CSS_URL_RE = /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g;
/** Attributes that may contain relative asset paths. */
export const PATH_ATTRS = ["src", "href"] as const;
/** Returns true for URLs/prefixes that should never be rewritten. */
export function isNonRelativeUrl(val: string): boolean {
return (
!val ||
val.startsWith("http://") ||
val.startsWith("https://") ||
val.startsWith("//") ||
val.startsWith("data:") ||
val.startsWith("#") ||
val.startsWith("/")
);
}
/**
* Cross-platform containment check: is `childPath` inside `parentPath`?
* Equality counts as "inside".
*/
export function isPathInside(childPath: string, parentPath: string): boolean {
const absChild = resolve(childPath);
const absParent = resolve(parentPath);
if (absChild === absParent) return true;
const rel = relative(absParent, absChild);
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
}
// Moved to @hyperframes/parsers. Re-exported here for back-compat.
export {
CSS_URL_RE,
PATH_ATTRS,
isNonRelativeUrl,
isPathInside,
} from "@hyperframes/parsers/asset-paths";
@@ -1,58 +0,0 @@
import { describe, expect, it } from "vitest";
import {
rewriteAssetPath,
rewriteCssAssetUrls,
rewriteInlineStyleAssetUrls,
} from "./rewriteSubCompPaths.js";
describe("rewriteAssetPath", () => {
it("rewrites `../` against the sub-composition dir", () => {
expect(rewriteAssetPath("compositions/scene.html", "../icon.svg")).toBe("icon.svg");
});
it("leaves plain relative paths untouched", () => {
expect(rewriteAssetPath("compositions/scene.html", "assets/logo.png")).toBe("assets/logo.png");
});
it("leaves absolute URLs and data URIs untouched", () => {
expect(rewriteAssetPath("compositions/scene.html", "https://x/y")).toBe("https://x/y");
expect(rewriteAssetPath("compositions/scene.html", "data:image/png;base64,AA")).toBe(
"data:image/png;base64,AA",
);
expect(rewriteAssetPath("compositions/scene.html", "#hash")).toBe("#hash");
});
// Regression guard for a Windows-only bug: the rewriter used to import
// `path` (native) and emit `:\fonts\brand.woff2` — native `join` used
// backslashes, and `resolve("/", x).slice(1)` chopped the `D` off a
// `D:\…` absolute path. URLs must be POSIX regardless of host OS.
it("never emits backslashes on any platform", () => {
const out = rewriteAssetPath("compositions/nested/scene.html", "../../fonts/brand.woff2");
expect(out).toBe("fonts/brand.woff2");
expect(out).not.toMatch(/\\/);
expect(out).not.toMatch(/^:/);
});
it("CSS url(...) rewrites also stay POSIX under nesting", () => {
const css = `@font-face { src: url("../../fonts/brand.woff2") format("woff2"); }`;
const out = rewriteCssAssetUrls(css, "compositions/nested/scene.html");
expect(out).toContain(`url("fonts/brand.woff2")`);
expect(out).not.toMatch(/\\/);
expect(out).not.toMatch(/:\\/);
});
it("rewrites CSS urls inside inline style attributes", () => {
const elements = [{ style: `background-image: url("../cover.png")` }];
rewriteInlineStyleAssetUrls(
elements,
"compositions/scene.html",
(el) => el.style,
(el, value) => {
el.style = value;
},
);
expect(elements[0]?.style).toBe(`background-image: url("cover.png")`);
});
});
@@ -1,115 +1,7 @@
/**
* Rewrite relative asset paths in sub-composition content so they resolve
* correctly after the content is inlined into the root document.
*
* A sub-composition at "compositions/scene.html" referencing "../icon.svg"
* means the project root — but after inlining into root index.html, the
* "../" escapes the project directory and causes 404s. This function
* resolves each relative path against the sub-composition's directory,
* then normalizes it to be relative to the project root.
*
* Used by both the core bundler (preview) and the producer compiler (render)
* to ensure consistent behavior.
*/
// URL paths in HTML output are POSIX regardless of host OS — use the `posix`
// submodule so Windows builds don't emit backslash-separated paths (or worse,
// drive-letter-prefixed artifacts from `resolve("/", ...)`).
import { posix } from "path";
const { join, resolve, dirname } = posix;
import { CSS_URL_RE, PATH_ATTRS, isNonRelativeUrl } from "./assetPaths.js";
const isAbsoluteOrSpecial = isNonRelativeUrl;
/**
* Returns true only for paths that traverse up with `../`.
* Plain relative paths like `assets/foo.svg` are already correct from the
* root perspective — the browser resolves them against the served root, which
* is the project root, so they don't need rewriting.
*/
function needsRewrite(val: string): boolean {
return val.startsWith("../") || val === "..";
}
/**
* Rewrite a single relative path from a sub-composition's context to the
* project root context.
*
* @param compSrcPath - The `data-composition-src` value (e.g. "compositions/scene.html")
* @param relativePath - The asset path to rewrite (e.g. "../icon.svg")
* @returns The rewritten path relative to project root (e.g. "icon.svg"), or
* the original path if no rewriting is needed.
*/
export function rewriteAssetPath(compSrcPath: string, relativePath: string): string {
if (isAbsoluteOrSpecial(relativePath)) return relativePath;
if (!needsRewrite(relativePath)) return relativePath;
const compDir = dirname(compSrcPath);
if (!compDir || compDir === ".") return relativePath;
const resolved = join(compDir, relativePath);
const normalized = resolve("/", resolved).slice(1);
return normalized;
}
/**
* Rewrite all relative `src` and `href` attributes on elements within a
* DOM tree, adjusting paths from the sub-composition's directory context
* to the project root.
*
* @param elements - Iterable of DOM elements to scan (e.g. from querySelectorAll)
* @param compSrcPath - The `data-composition-src` value
* @param getAttr - Function to read an attribute from an element
* @param setAttr - Function to set an attribute on an element
*/
export function rewriteAssetPaths<T>(
elements: Iterable<T>,
compSrcPath: string,
getAttr: (el: T, attr: string) => string | null | undefined,
setAttr: (el: T, attr: string, value: string) => void,
): void {
for (const el of elements) {
for (const attr of PATH_ATTRS) {
const val = (getAttr(el, attr) || "").trim();
const rewritten = rewriteAssetPath(compSrcPath, val);
if (rewritten !== val) {
setAttr(el, attr, rewritten);
}
}
}
}
/**
* Rewrite CSS url(...) references inside inline style attributes.
*/
export function rewriteInlineStyleAssetUrls<T>(
elements: Iterable<T>,
compSrcPath: string,
getStyle: (el: T) => string | null | undefined,
setStyle: (el: T, value: string) => void,
): void {
const compDir = dirname(compSrcPath);
if (!compDir || compDir === ".") return;
for (const el of elements) {
const style = getStyle(el);
if (!style) continue;
const rewritten = rewriteCssAssetUrls(style, compSrcPath);
if (rewritten !== style) {
setStyle(el, rewritten);
}
}
}
/**
* Rewrite CSS url(...) references in a sub-composition's inline styles so
* ../foo.woff2 remains valid after the CSS is hoisted into the root document.
*/
export function rewriteCssAssetUrls(cssText: string, compSrcPath: string): string {
if (!cssText) return cssText;
return cssText.replace(CSS_URL_RE, (full, quote: string, rawUrl: string) => {
const urlValue = (rawUrl || "").trim();
const rewritten = rewriteAssetPath(compSrcPath, urlValue);
if (rewritten === urlValue) return full;
return `url(${quote || ""}${rewritten}${quote || ""})`;
});
}
// Moved to @hyperframes/parsers. Re-exported here for back-compat.
export {
rewriteAssetPath,
rewriteAssetPaths,
rewriteInlineStyleAssetUrls,
rewriteCssAssetUrls,
} from "@hyperframes/parsers/asset-paths";
+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";
@@ -1,218 +0,0 @@
// packages/core/src/slideshow/parseSlideshow.test.ts
import { describe, it, expect } from "vitest";
import { parseSlideshowManifest, resolveSlideshow } from "./parseSlideshow";
const ISLAND = `<!doctype html><html><body>
<script type="application/hyperframes-slideshow+json">
{ "slides": [
{ "sceneId": "a", "fragments": [2.0, 1.0], "hotspots": [{ "id": "h1", "label": "Why?", "target": "deep" }] },
{ "sceneId": "b" }
],
"slideSequences": [ { "id": "deep", "label": "Deep dive", "slides": [ { "sceneId": "c" } ] } ]
}
</script>
</body></html>`;
const SCENES = [
{ id: "a", start: 0, duration: 5 },
{ id: "b", start: 5, duration: 5 },
{ id: "c", start: 10, duration: 3 },
];
describe("parseSlideshowManifest", () => {
it("returns null when no island present", () => {
expect(parseSlideshowManifest("<html></html>")).toBeNull();
});
it("parses the island JSON", () => {
const m = parseSlideshowManifest(ISLAND);
expect(m?.slides.length).toBe(2);
expect(m?.slideSequences?.[0].id).toBe("deep");
});
it("throws when slideSequences is present but not an array", () => {
const html = `<script type="application/hyperframes-slideshow+json">
{ "slides": [{ "sceneId": "a" }], "slideSequences": {} }
</script>`;
expect(() => parseSlideshowManifest(html)).toThrow();
});
it("rejects a non-object manifest (e.g. a JSON array)", () => {
const html = `<script type="application/hyperframes-slideshow+json">[42, null]</script>`;
expect(() => parseSlideshowManifest(html)).toThrow();
});
it("throws when a slide entry is malformed (sceneId not a string)", () => {
const html = `<script type="application/hyperframes-slideshow+json">
{ "slides": [{ "sceneId": 42 }] }
</script>`;
expect(() => parseSlideshowManifest(html)).toThrow();
});
});
describe("resolveSlideshow", () => {
it("resolves scene time ranges and sorts fragments", () => {
const m = parseSlideshowManifest(ISLAND);
if (!m) throw new Error("manifest expected");
const { resolved, errors } = resolveSlideshow(m, SCENES);
expect(errors).toEqual([]);
expect(resolved.slides[0].start).toBe(0);
expect(resolved.slides[0].end).toBe(5);
expect(resolved.slides[0].fragments).toEqual([1.0, 2.0]); // sorted
expect(resolved.sequences.deep.slides[0].start).toBe(10);
});
it("honors explicit startTime/endTime overrides", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "a", startTime: 1, endTime: 4 }],
};
const { resolved } = resolveSlideshow(m, SCENES);
expect(resolved.slides[0].start).toBe(1);
expect(resolved.slides[0].end).toBe(4);
});
it("reports an error for an unresolved sceneId", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "missing" }],
};
const { errors } = resolveSlideshow(m, SCENES);
expect(errors.some((e) => e.includes("missing"))).toBe(true);
});
it("flags duplicate slideSequence ids instead of silently overwriting", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "a" }],
slideSequences: [
{ id: "dup", label: "First", slides: [{ sceneId: "c" }] },
{ id: "dup", label: "Second", slides: [{ sceneId: "c" }] },
],
};
const { errors } = resolveSlideshow(m, SCENES);
expect(errors.some((e) => e.includes("duplicate slideSequence id"))).toBe(true);
});
it("reports an error for a fragment outside the slide range", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "a", fragments: [99] }],
};
const { errors } = resolveSlideshow(m, SCENES);
expect(errors.some((e) => e.includes("fragment"))).toBe(true);
});
it("reports an error for a hotspot target with no sequence", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "a", hotspots: [{ id: "h", label: "x", target: "nope" }] }],
};
const { errors } = resolveSlideshow(m, SCENES);
expect(errors.some((e) => e.includes("nope"))).toBe(true);
});
it("reports an error for overlapping main-line slides", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [
{ sceneId: "a", startTime: 0, endTime: 6 },
{ sceneId: "b", startTime: 5, endTime: 10 },
],
};
const { errors } = resolveSlideshow(m, SCENES);
expect(errors.some((e) => e.includes("overlap"))).toBe(true);
});
// Partial-override cases
it("fills missing endTime from scene when only startTime is provided and scene exists", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "a", startTime: 2 }],
};
const { resolved, errors } = resolveSlideshow(m, SCENES);
expect(errors).toEqual([]);
expect(resolved.slides[0].start).toBe(2);
expect(resolved.slides[0].end).toBe(5); // scene a: start=0, duration=5
});
it("fills missing startTime from scene when only endTime is provided and scene exists", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "a", endTime: 3 }],
};
const { resolved, errors } = resolveSlideshow(m, SCENES);
expect(errors).toEqual([]);
expect(resolved.slides[0].start).toBe(0); // scene a: start=0
expect(resolved.slides[0].end).toBe(3);
});
it("reports a clear error when only startTime is provided but scene is absent", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "x", startTime: 2 }],
};
const { errors } = resolveSlideshow(m, SCENES);
expect(errors.length).toBeGreaterThan(0);
// Must mention the missing bound (endTime), not the misleading "unresolved sceneId"
expect(errors.some((e) => e.includes("endTime"))).toBe(true);
expect(errors.some((e) => e.includes("unresolved sceneId"))).toBe(false);
});
it("reports a clear error when only endTime is provided but scene is absent", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "x", endTime: 5 }],
};
const { errors } = resolveSlideshow(m, SCENES);
expect(errors.length).toBeGreaterThan(0);
// Must mention the missing bound (startTime), not the misleading "unresolved sceneId"
expect(errors.some((e) => e.includes("startTime"))).toBe(true);
expect(errors.some((e) => e.includes("unresolved sceneId"))).toBe(false);
});
it("reports an error for an inverted explicit range (endTime <= startTime)", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "a", startTime: 5, endTime: 2 }],
};
const { errors } = resolveSlideshow(m, SCENES);
expect(errors.some((e) => e.includes("endTime") && e.includes("startTime"))).toBe(true);
});
it("de-duplicates fragments before resolving", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "a", fragments: [2, 1, 2, 1, 3] }],
};
const { resolved, errors } = resolveSlideshow(m, SCENES);
expect(errors).toEqual([]);
expect(resolved.slides[0].fragments).toEqual([1, 2, 3]);
});
it("reports an error for a hotspot targeting an empty sequence", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "a", hotspots: [{ id: "h", label: "x", target: "empty" }] }],
slideSequences: [{ id: "empty", label: "Empty", slides: [] }],
};
const { errors } = resolveSlideshow(m, SCENES);
expect(errors.some((e) => e.includes("empty sequence"))).toBe(true);
});
it("full override with no scene produces no error", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "noexist", startTime: 1, endTime: 4 }],
};
const { resolved, errors } = resolveSlideshow(m, SCENES);
expect(errors).toEqual([]);
expect(resolved.slides[0].start).toBe(1);
expect(resolved.slides[0].end).toBe(4);
});
it("parses and carries through the per-slide autoplay flag", () => {
const island = `<script type="application/hyperframes-slideshow+json">
{ "slides": [ { "sceneId": "a", "autoplay": true }, { "sceneId": "b" } ] }
</script>`;
const m = parseSlideshowManifest(island);
expect(m?.slides[0].autoplay).toBe(true);
expect(m?.slides[1].autoplay).toBeUndefined();
const { resolved } = resolveSlideshow(m!, SCENES);
expect(resolved.slides[0].autoplay).toBe(true);
expect(resolved.slides[1].autoplay).toBeUndefined();
});
it("rejects a manifest whose slide autoplay is not a boolean", () => {
const island = `<script type="application/hyperframes-slideshow+json">
{ "slides": [ { "sceneId": "a", "autoplay": "yes" } ] }
</script>`;
expect(() => parseSlideshowManifest(island)).toThrow();
});
});
@@ -1,203 +0,0 @@
// packages/core/src/slideshow/parseSlideshow.ts
import type {
SlideshowManifest,
SlideRef,
ResolvedSlide,
ResolvedSlideshow,
ResolvedSlideSequence,
} from "./slideshow.types";
export const SLIDESHOW_ISLAND_TYPE = "application/hyperframes-slideshow+json";
/**
* Builds the island <script> matcher. Capture group 1 = inner JSON.
*
* Factory (fresh RegExp per call) on purpose: a RegExp with the `g` flag carries
* a mutable `lastIndex`, so callers that need `g` must call this each time rather
* than caching a shared instance.
*/
export function slideshowIslandRegex(flags = "i"): RegExp {
// Escape ALL regex metacharacters in the constant (CodeQL flags the incomplete
// `\` escape). The constant has none today, but a complete escape is correct.
const escaped = SLIDESHOW_ISLAND_TYPE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`<script[^>]*type=["']${escaped}["'][^>]*>([\\s\\S]*?)<\\/script>`, flags);
}
interface SceneRange {
id: string;
start: number;
duration: number;
}
/** Extract the JSON island from composition HTML. Returns null if absent. */
export function parseSlideshowManifest(html: string): SlideshowManifest | null {
// Match <script type="application/hyperframes-slideshow+json"> ... </script>
const re = slideshowIslandRegex("i");
const match = re.exec(html);
if (!match || match[1] === undefined) return null;
const raw = match[1].trim();
if (raw.length === 0) return null;
const parsed: unknown = JSON.parse(raw);
if (!isManifest(parsed)) {
throw new Error("slideshow island is not a valid SlideshowManifest");
}
return parsed;
}
function isOptionalNumberArray(v: unknown): boolean {
return v === undefined || (Array.isArray(v) && v.every((n) => typeof n === "number"));
}
function isOptionalBoolean(v: unknown): v is boolean | undefined {
return v === undefined || typeof v === "boolean";
}
function isSlideRef(v: unknown): v is SlideRef {
if (typeof v !== "object" || v === null) return false;
const r = v as Record<string, unknown>;
if (typeof r["sceneId"] !== "string") return false;
if (!isOptionalNumberArray(r["fragments"])) return false;
if (r["hotspots"] !== undefined && !Array.isArray(r["hotspots"])) return false;
if (!isOptionalBoolean(r["autoplay"])) return false;
return true;
}
function isSlideSequence(v: unknown): boolean {
if (typeof v !== "object" || v === null) return false;
const s = v as Record<string, unknown>;
return (
typeof s["id"] === "string" &&
typeof s["label"] === "string" &&
Array.isArray(s["slides"]) &&
s["slides"].every(isSlideRef)
);
}
function isManifest(v: unknown): v is SlideshowManifest {
if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
const o = v as Record<string, unknown>;
if (!Array.isArray(o["slides"]) || !o["slides"].every(isSlideRef)) return false;
if (o["slideSequences"] !== undefined) {
if (!Array.isArray(o["slideSequences"]) || !o["slideSequences"].every(isSlideSequence))
return false;
}
return true;
}
function missingBoundError(sceneId: string, missing: "startTime" | "endTime"): string {
const present = missing === "startTime" ? "endTime" : "startTime";
return `slide "${sceneId}" sets ${present} but ${missing} cannot be resolved (no scene "${sceneId}")`;
}
// fallow-ignore-next-line complexity
function resolveTimeRange(
ref: SlideRef,
scene: SceneRange | undefined,
errors: string[],
): { start: number; end: number } {
const { startTime, endTime, sceneId } = ref;
// Both explicit — use them directly, no scene needed.
if (startTime !== undefined && endTime !== undefined) {
return { start: startTime, end: endTime };
}
// Neither explicit — resolve both from scene.
if (startTime === undefined && endTime === undefined) {
if (!scene) {
errors.push(`slide references unresolved sceneId "${sceneId}"`);
return { start: 0, end: 0 };
}
return { start: scene.start, end: scene.start + scene.duration };
}
// Exactly one bound explicit — fill from scene, or report a clear error.
if (!scene) {
const missing = startTime === undefined ? "startTime" : "endTime";
errors.push(missingBoundError(sceneId, missing));
const bound = startTime ?? endTime ?? 0;
return { start: bound, end: bound };
}
return {
start: startTime ?? scene.start,
end: endTime ?? scene.start + scene.duration,
};
}
function validateFragments(
sceneId: string,
fragments: number[],
start: number,
end: number,
errors: string[],
): void {
for (const f of fragments) {
if (f < start || f > end) {
errors.push(`slide "${sceneId}" fragment ${f} is outside range [${start}, ${end}]`);
}
}
}
function resolveSlide(
ref: SlideRef,
sceneById: Map<string, SceneRange>,
errors: string[],
): ResolvedSlide {
const scene = sceneById.get(ref.sceneId);
const { start, end } = resolveTimeRange(ref, scene, errors);
if (ref.startTime !== undefined && ref.endTime !== undefined && end <= start) {
errors.push(`slide "${ref.sceneId}" has endTime (${end}) <= startTime (${start})`);
}
const fragments = [...new Set(ref.fragments ?? [])].sort((a, b) => a - b);
validateFragments(ref.sceneId, fragments, start, end, errors);
return { ...ref, start, end, fragments, hotspots: ref.hotspots ?? [] };
}
export function resolveSlideshow(
manifest: SlideshowManifest,
scenes: SceneRange[],
): { resolved: ResolvedSlideshow; errors: string[] } {
const errors: string[] = [];
const sceneById = new Map(scenes.map((s) => [s.id, s]));
const sequences: Record<string, ResolvedSlideSequence> = {};
for (const seq of manifest.slideSequences ?? []) {
// Flag duplicate sequence ids rather than silently overwriting the earlier one.
if (Object.prototype.hasOwnProperty.call(sequences, seq.id)) {
errors.push(`duplicate slideSequence id "${seq.id}" — only the last definition is kept`);
}
sequences[seq.id] = {
id: seq.id,
label: seq.label,
slides: seq.slides.map((s) => resolveSlide(s, sceneById, errors)),
};
}
const slides = manifest.slides.map((s) => resolveSlide(s, sceneById, errors));
// Validate hotspot targets.
const allSlides = [...slides, ...Object.values(sequences).flatMap((s) => s.slides)];
for (const slide of allSlides) {
for (const h of slide.hotspots) {
const seq = sequences[h.target];
if (!seq) {
errors.push(`hotspot "${h.id}" targets unknown sequence "${h.target}"`);
} else if (seq.slides.length === 0) {
errors.push(`hotspot "${h.id}" targets empty sequence "${h.target}"`);
}
}
}
// Validate no main-line overlap (sorted by start; adjacent compare).
const ordered = [...slides].sort((a, b) => a.start - b.start);
for (let i = 1; i < ordered.length; i++) {
const prev = ordered[i - 1];
const curr = ordered[i];
if (prev !== undefined && curr !== undefined && curr.start < prev.end) {
errors.push(`main-line slides "${prev.sceneId}" and "${curr.sceneId}" overlap`);
}
}
return { resolved: { slides, sequences }, errors };
}
-15
View File
@@ -1,15 +0,0 @@
// packages/core/src/slideshow/sceneId.ts
/**
* Whether a composition id names a "scene-like" composition i.e. a real slide
* scene, not the root timeline (`main`) or a non-scene overlay (captions, ambient
* layers). Shared by the runtime scene-window computation and the slideshow lint
* rule so the two can never drift.
*/
export function isSceneLikeCompositionId(compositionId: string): boolean {
const normalized = compositionId.trim().toLowerCase();
if (!normalized || normalized === "main") return false;
if (normalized.includes("caption")) return false;
if (normalized.includes("ambient")) return false;
return true;
}
@@ -1,66 +0,0 @@
// packages/core/src/slideshow/slideshow.types.ts
/** Current manifest schema version. Stamped on persist so future schema
* changes can detect and migrate older islands. */
export const SLIDESHOW_MANIFEST_VERSION = 1;
/** Raw author-facing shapes parsed from the JSON island. */
export interface SlideshowManifest {
/** Schema version (absent on pre-versioning islands → treat as 1). */
version?: number;
slides: SlideRef[];
slideSequences?: SlideSequence[];
}
export interface SlideRef {
sceneId: string;
startTime?: number;
endTime?: number;
notes?: string;
fragments?: number[];
hotspots?: SlideHotspot[];
/**
* When true, the slide's first `<video>` plays automatically on enter (the
* presenter lands on the slide and the clip plays). The slideshow still holds
* it never auto-advances so the presenter clicks Next when ready.
* Defaults to false. Use it when the video is the slide's primary content and
* its natural end is the cue to advance, not for background/ambient clips.
*/
autoplay?: boolean;
// Reserved — TTS deferred. Parsed and carried, never consumed.
ttsScript?: string;
ttsAudioUrl?: string;
ttsDurationMs?: number;
}
export interface SlideHotspot {
id: string;
label: string;
target: string; // references a SlideSequence.id
region?: { x: number; y: number; w: number; h: number }; // % of slide
}
export interface SlideSequence {
id: string;
label: string;
slides: SlideRef[];
}
/** A slide with its time range resolved from the matching scene. */
export interface ResolvedSlide extends SlideRef {
start: number;
end: number;
fragments: number[]; // always present, sorted, defaulted to []
hotspots: SlideHotspot[]; // always present, defaulted to []
}
export interface ResolvedSlideSequence {
id: string;
label: string;
slides: ResolvedSlide[];
}
export interface ResolvedSlideshow {
slides: ResolvedSlide[];
sequences: Record<string, ResolvedSlideSequence>; // keyed by sequence id
}
+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";