diff --git a/packages/lint/src/rules/slideshow.test.ts b/packages/lint/src/rules/slideshow.test.ts
index 8b7ed694649..4c801e66bf0 100644
--- a/packages/lint/src/rules/slideshow.test.ts
+++ b/packages/lint/src/rules/slideshow.test.ts
@@ -51,6 +51,22 @@ describe("slideshow lint rule", () => {
expect(findings[0]!.code).toBe("slideshow_invalid");
});
+ it.each([null, {}, { id: "h1", label: 42, target: "deep" }])(
+ "reports malformed hotspot %j as a structure finding without crashing lint",
+ async (hotspot) => {
+ const html = `
diff --git a/packages/parsers/src/slideshow/parseSlideshow.test.ts b/packages/parsers/src/slideshow/parseSlideshow.test.ts
index 8251a164057..429621d8964 100644
--- a/packages/parsers/src/slideshow/parseSlideshow.test.ts
+++ b/packages/parsers/src/slideshow/parseSlideshow.test.ts
@@ -48,6 +48,56 @@ describe("parseSlideshowManifest", () => {
`;
expect(() => parseSlideshowManifest(html)).toThrow();
});
+
+ it.each([
+ null,
+ 42,
+ "branch",
+ [],
+ {},
+ { id: 1, label: "Go", target: "deep" },
+ { id: "h1", label: null, target: "deep" },
+ { id: "h1", label: "Go", target: [] },
+ { id: "h1", label: "Go", target: "deep", region: null },
+ { id: "h1", label: "Go", target: "deep", region: { x: 10, y: 20 } },
+ { id: "h1", label: "Go", target: "deep", region: { x: "10", y: 20, w: 30, h: 40 } },
+ ])("rejects malformed hotspot %j in main-line and branch slides", (hotspot) => {
+ for (const manifest of [
+ { slides: [{ sceneId: "a", hotspots: [hotspot] }] },
+ {
+ slides: [{ sceneId: "a" }],
+ slideSequences: [
+ { id: "deep", label: "Deep", slides: [{ sceneId: "c", hotspots: [hotspot] }] },
+ ],
+ },
+ ]) {
+ const html = ``;
+ expect(() => parseSlideshowManifest(html)).toThrow("not a valid SlideshowManifest");
+ }
+ });
+
+ it("preserves valid hotspot regions and optional hotspot arrays", () => {
+ const manifest = {
+ slides: [
+ {
+ sceneId: "a",
+ hotspots: [
+ { id: "h1", label: "Go", target: "deep", region: { x: 0, y: 12.5, w: 100, h: 20 } },
+ ],
+ },
+ ],
+ slideSequences: [{ id: "deep", label: "Deep", slides: [{ sceneId: "c", hotspots: [] }] }],
+ };
+ const html = ``;
+ expect(parseSlideshowManifest(html)).toEqual(manifest);
+ });
+
+ it("rejects a hotspot region whose JSON number overflows to infinity", () => {
+ const html = ``;
+ expect(() => parseSlideshowManifest(html)).toThrow("not a valid SlideshowManifest");
+ });
});
describe("resolveSlideshow", () => {
diff --git a/packages/parsers/src/slideshow/parseSlideshow.ts b/packages/parsers/src/slideshow/parseSlideshow.ts
index a05537ce507..906ed3c6c82 100644
--- a/packages/parsers/src/slideshow/parseSlideshow.ts
+++ b/packages/parsers/src/slideshow/parseSlideshow.ts
@@ -2,6 +2,7 @@
import type {
SlideshowManifest,
SlideRef,
+ SlideHotspot,
ResolvedSlide,
ResolvedSlideshow,
ResolvedSlideSequence,
@@ -52,12 +53,39 @@ function isOptionalBoolean(v: unknown): v is boolean | undefined {
return v === undefined || typeof v === "boolean";
}
+function isRecord(v: unknown): v is Record
{
+ return typeof v === "object" && v !== null && !Array.isArray(v);
+}
+
+function isHotspotRegion(v: unknown): boolean {
+ return (
+ isRecord(v) &&
+ [v["x"], v["y"], v["w"], v["h"]].every(
+ (coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate),
+ )
+ );
+}
+
+function isSlideHotspot(v: unknown): v is SlideHotspot {
+ return (
+ isRecord(v) &&
+ typeof v["id"] === "string" &&
+ typeof v["label"] === "string" &&
+ typeof v["target"] === "string" &&
+ (v["region"] === undefined || isHotspotRegion(v["region"]))
+ );
+}
+
function isSlideRef(v: unknown): v is SlideRef {
- if (typeof v !== "object" || v === null) return false;
- const r = v as Record;
+ if (!isRecord(v)) return false;
+ const r = v;
if (typeof r["sceneId"] !== "string") return false;
if (!isOptionalNumberArray(r["fragments"])) return false;
- if (r["hotspots"] !== undefined && !Array.isArray(r["hotspots"])) return false;
+ if (
+ r["hotspots"] !== undefined &&
+ (!Array.isArray(r["hotspots"]) || !r["hotspots"].every(isSlideHotspot))
+ )
+ return false;
if (!isOptionalBoolean(r["autoplay"])) return false;
return true;
}
diff --git a/packages/player/src/slideshow/hyperframes-slideshow.test.ts b/packages/player/src/slideshow/hyperframes-slideshow.test.ts
index b2d3efe67a1..0ed739a6991 100644
--- a/packages/player/src/slideshow/hyperframes-slideshow.test.ts
+++ b/packages/player/src/slideshow/hyperframes-slideshow.test.ts
@@ -1750,6 +1750,14 @@ describe("slideshow parts (pure)", () => {
expect(parts.player).toBe(root.querySelector("hyperframes-player"));
expect(parts.manifest.slides.map((s) => s.sceneId)).toEqual(["intro"]);
});
+
+ it("classifies a malformed hotspot as a malformed island before slideshow initialization", () => {
+ const root = subtree(`
+ `);
+ expect(locateSlideshowParts(root)).toEqual({ kind: "incomplete", reason: "malformed-island" });
+ });
});
// ---------------------------------------------------------------------------