diff --git a/packages/core/package.json b/packages/core/package.json
index e4d5aa19b..9be376dbc 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -26,6 +26,10 @@
"import": "./src/beats/index.ts",
"types": "./src/beats/index.ts"
},
+ "./slideshow": {
+ "import": "./src/slideshow/index.ts",
+ "types": "./src/slideshow/index.ts"
+ },
"./lint": {
"import": "./src/lint/index.ts",
"types": "./src/lint/index.ts"
@@ -137,6 +141,10 @@
"import": "./dist/lint/index.js",
"types": "./dist/lint/index.d.ts"
},
+ "./slideshow": {
+ "import": "./dist/slideshow/index.js",
+ "types": "./dist/slideshow/index.d.ts"
+ },
"./compiler": {
"import": "./dist/compiler/index.js",
"types": "./dist/compiler/index.d.ts"
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 8f38c7e05..8f0d84d7d 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -37,6 +37,18 @@ export type {
WaveformData,
} from "./core.types";
+export type {
+ SlideshowManifest,
+ SlideRef,
+ SlideHotspot,
+ SlideSequence,
+ ResolvedSlide,
+ ResolvedSlideSequence,
+ ResolvedSlideshow,
+} from "./slideshow/slideshow.types";
+
+export { parseSlideshowManifest, resolveSlideshow } from "./slideshow/parseSlideshow";
+
export {
CANVAS_DIMENSIONS,
VALID_CANVAS_RESOLUTIONS,
diff --git a/packages/core/src/lint/hyperframeLinter.ts b/packages/core/src/lint/hyperframeLinter.ts
index 9467c3bea..1dc88067c 100644
--- a/packages/core/src/lint/hyperframeLinter.ts
+++ b/packages/core/src/lint/hyperframeLinter.ts
@@ -9,6 +9,7 @@ import { compositionRules } from "./rules/composition";
import { adapterRules } from "./rules/adapters";
import { textureRules } from "./rules/textures";
import { fontRules } from "./rules/fonts";
+import { slideshowRules } from "./rules/slideshow";
const ALL_RULES = [
...coreRules,
@@ -19,6 +20,7 @@ const ALL_RULES = [
...adapterRules,
...textureRules,
...fontRules,
+ ...slideshowRules,
];
export async function lintHyperframeHtml(
diff --git a/packages/core/src/lint/rules/core.ts b/packages/core/src/lint/rules/core.ts
index a55c653d6..5a057ad4b 100644
--- a/packages/core/src/lint/rules/core.ts
+++ b/packages/core/src/lint/rules/core.ts
@@ -164,7 +164,9 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
const attrs = script.attrs || "";
if (
/\bsrc\s*=/.test(attrs) ||
- /\btype\s*=\s*["'](?:application\/json|importmap|module)["']/.test(attrs)
+ /\btype\s*=\s*["'](?:application\/json|application\/hyperframes-slideshow\+json|importmap|module)["']/.test(
+ attrs,
+ )
)
continue;
const syntaxError = getInlineScriptSyntaxError(script.content);
diff --git a/packages/core/src/lint/rules/slideshow.test.ts b/packages/core/src/lint/rules/slideshow.test.ts
new file mode 100644
index 000000000..8b7ed6946
--- /dev/null
+++ b/packages/core/src/lint/rules/slideshow.test.ts
@@ -0,0 +1,73 @@
+import { describe, it, expect } from "vitest";
+import { lintHyperframeHtml } from "../hyperframeLinter.js";
+
+async function findSlideshow(html: string) {
+ const result = await lintHyperframeHtml(html, { isSubComposition: true });
+ return result.findings.filter((f) => f.code.startsWith("slideshow_"));
+}
+
+describe("slideshow lint rule", () => {
+ it("passes a composition with no slideshow island", async () => {
+ const html = `
`;
+ expect(await findSlideshow(html)).toEqual([]);
+ });
+
+ it("passes a valid island where sceneId resolves to a data-composition-id scene", async () => {
+ const html = ``;
+ expect(await findSlideshow(html)).toEqual([]);
+ });
+
+ it("flags a sceneId that matches only a .clip[id] (not a data-composition-id)", async () => {
+ const html = ``;
+ const findings = await findSlideshow(html);
+ expect(findings.length).toBeGreaterThan(0);
+ expect(findings[0]?.message).toContain("a");
+ });
+
+ it("flags an unresolved sceneId", async () => {
+ const html = ``;
+ const findings = await findSlideshow(html);
+ expect(findings.length).toBeGreaterThan(0);
+ expect(findings[0]!.message).toContain("ghost");
+ });
+
+ it("flags invalid JSON in the island", async () => {
+ const html = `
+
+
`;
+ const findings = await findSlideshow(html);
+ expect(findings.length).toBeGreaterThan(0);
+ expect(findings[0]!.code).toBe("slideshow_invalid");
+ });
+
+ it("passes when sceneId resolves to a data-composition-id element (no .clip[id])", async () => {
+ const html = ``;
+ expect(await findSlideshow(html)).toEqual([]);
+ });
+
+ it("flags a hotspot targeting an unknown sequence", async () => {
+ const html = ``;
+ const findings = await findSlideshow(html);
+ expect(findings.length).toBeGreaterThan(0);
+ expect(findings[0]!.message).toContain("no-such-seq");
+ });
+});
diff --git a/packages/core/src/lint/rules/slideshow.ts b/packages/core/src/lint/rules/slideshow.ts
new file mode 100644
index 000000000..c19a3e9cd
--- /dev/null
+++ b/packages/core/src/lint/rules/slideshow.ts
@@ -0,0 +1,82 @@
+// fallow-ignore-file code-duplication
+import type { LintContext, HyperframeLintFinding } from "../context";
+import type { LintRule } from "../types";
+import { readAttr } from "../utils";
+import { parseSlideshowManifest, resolveSlideshow } from "../../slideshow/parseSlideshow";
+
+type Scene = { id: string; start: number; duration: number };
+
+/** Mirrors isSceneLikeCompositionId in packages/core/src/runtime/timeline.ts */
+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;
+}
+
+function parseTiming(raw: string): { start: number; duration: number } | null {
+ const startStr = readAttr(raw, "data-start");
+ const durationStr = readAttr(raw, "data-duration");
+ if (startStr === null || durationStr === null) return null;
+ const start = Number(startStr);
+ const duration = Number(durationStr);
+ if (!Number.isFinite(start) || !Number.isFinite(duration)) return null;
+ return { start, duration };
+}
+
+function collectCompositionIdScenes(ctx: LintContext, seen: Set, out: Scene[]): void {
+ for (const tag of ctx.tags) {
+ const compositionId = readAttr(tag.raw, "data-composition-id");
+ if (!compositionId || !isSceneLikeCompositionId(compositionId) || seen.has(compositionId))
+ continue;
+ const timing = parseTiming(tag.raw);
+ if (!timing || timing.duration <= 0) continue;
+ seen.add(compositionId);
+ out.push({ id: compositionId, ...timing });
+ }
+}
+
+function extractScenesFromClips(ctx: LintContext): Scene[] {
+ const seen = new Set();
+ const scenes: Scene[] = [];
+ collectCompositionIdScenes(ctx, seen, scenes);
+ return scenes;
+}
+
+export const slideshowRules: LintRule[] = [
+ (ctx) => {
+ const findings: HyperframeLintFinding[] = [];
+
+ let manifest;
+ try {
+ manifest = parseSlideshowManifest(ctx.source);
+ } catch (e) {
+ findings.push({
+ code: "slideshow_invalid",
+ severity: "error",
+ message: `Slideshow island contains invalid JSON or structure: ${e instanceof Error ? e.message : String(e)}`,
+ fixHint:
+ 'Ensure the
+