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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ load `apps/extension/.output/chrome-mv3` unpacked.

```
apps/extension/ Chrome extension: recorder + editor (WXT + React)
packages/schema/ Demo format: Zod schema + TypeScript types
packages/schema/ Demo format: Valibot schema + TypeScript types
packages/player-core/ Framework-agnostic playback engine
packages/exporter/ Builds the exported bundle in the browser
adapters/player-react/ <InteractiveDemo /> for React
Expand Down
2 changes: 1 addition & 1 deletion packages/exporter/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { defineConfig } from "vitest/config";
// (browser-only APIs), and buildDemoBundle calls into it for any non-webp photo step.
export default defineConfig({
// Pre-bundle up front — an on-demand optimize mid-run forces Vite to reload the page,
// breaking whatever test was in flight (same issue player-core hit with zod).
// breaking whatever test was in flight (same issue player-core hit with the schema's validator).
optimizeDeps: { include: ["jszip"] },
test: {
include: ["src/__test__/unit/**/*.test.ts"],
Expand Down
2 changes: 1 addition & 1 deletion packages/player-core/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Framework-agnostic playback engine used by both adapters and by the extension's

- `pnpm build`: Vite (CSS inlined into JS, no `.css` output) + `dts-bundle-generator` for `dist/index.d.ts`.
- **Keep `exports["."].default` pointing at `./dist/index.js`** (only `types` points at `src`). Resolving raw source forces every consumer's bundler to compile `.svelte` files, and the extension's build breaks.
- `@rustrak/openshowcase-schema` stays `external` in `vite.config.ts`. Inlining it bundles a second copy of zod.
- `@rustrak/openshowcase-schema` stays `external` in `vite.config.ts`. Inlining it bundles a second copy of valibot.
- `pnpm size`: 35 KB brotli budget for `dist/index.js`.
- `pnpm test`: Vitest projects `unit` (Node, for `core/`) and `component` (covers `__test__/component` + `__test__/integration` in real Chromium via Playwright, not jsdom).

Expand Down
4 changes: 2 additions & 2 deletions packages/player-core/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ export default defineConfig({
formats: ["es"],
fileName: () => "index.js",
},
// The schema (and zod behind it) is a declared dependency, not something to inline:
// The schema (and valibot behind it) is a declared dependency, not something to inline:
// every consumer already has it (the adapters import `parseDemo` from it), so bundling
// a private copy here only doubled zod in the host app.
// a private copy here only doubled valibot in the host app.
rollupOptions: {
external: ["@rustrak/openshowcase-schema"],
},
Expand Down
4 changes: 2 additions & 2 deletions packages/player-core/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { defineConfig } from "vitest/config";

export default defineConfig({
plugins: [tailwindcss(), svelte()],
// Pre-bundle zod up front — the integration test is the first to pull in @rustrak/openshowcase-schema's
// Pre-bundle valibot up front — the integration test is the first to pull in @rustrak/openshowcase-schema's
// runtime code inside the browser project, and an on-demand optimize mid-run forces Vite
// to reload the page, breaking whatever test was in flight.
optimizeDeps: { include: ["@rustrak/openshowcase-schema > zod"] },
optimizeDeps: { include: ["@rustrak/openshowcase-schema > valibot"] },
test: {
projects: [
{
Expand Down
5 changes: 3 additions & 2 deletions packages/schema/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# @rustrak/openshowcase-schema

Zod schema + TS types for the demo format (`steps.json`). This is the source of truth for every other workspace. Its only runtime dependency is `zod` (3 KB brotli budget, `pnpm size`).
Valibot schema + TS types for the demo format (`steps.json`). This is the source of truth for every other workspace. Its only runtime dependency is `valibot` (3 KB brotli budget, `pnpm size`). Valibot was picked over Zod for the host app's bundle: see issue #4.

- Bump `SCHEMA_VERSION` in `src/index.ts` on any breaking change to the `Demo`/`Step` shape.
- `parseDemo()` is the single validation point for untrusted JSON. Consumers rely on it, so don't add ad hoc validation elsewhere.
- `parseDemo()` is the single validation point for untrusted JSON. Consumers rely on it, so don't add ad hoc validation elsewhere. It throws `DemoParseError`, never the library's own error: that keeps the validator swappable without breaking the public API.
- `pnpm test`: Vitest in Node. `parse-demo.test.ts` pins the validation rules, so any change to a rule shows up there.
- Changes ripple everywhere. Grep `packages/`, `adapters/` and `apps/` for a field before renaming or removing it.
16 changes: 14 additions & 2 deletions packages/schema/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @rustrak/openshowcase-schema

The [OpenShowcase](https://github.com/rustrak/openshowcase) demo format (`steps.json`): a Zod schema and TypeScript types.
The [OpenShowcase](https://github.com/rustrak/openshowcase) demo format (`steps.json`): a [Valibot](https://valibot.dev) schema and TypeScript types.

## Install

Expand All @@ -16,7 +16,19 @@ import { parseDemo, type Demo } from "@rustrak/openshowcase-schema";
const demo: Demo = parseDemo(await (await fetch("/demos/my-demo/steps.json")).json());
```

`parseDemo` throws a `ZodError` if the data doesn't match the schema.
`parseDemo` throws a `DemoParseError` if the data doesn't match the schema. Its `issues` list each problem with the dot path to the offending value:

```ts
import { DemoParseError, parseDemo } from "@rustrak/openshowcase-schema";

try {
parseDemo(json);
} catch (error) {
if (error instanceof DemoParseError) {
console.error(error.issues); // [{ path: "steps.1.hotspot.x", message: "..." }]
}
}
```

## Format overview

Expand Down
21 changes: 12 additions & 9 deletions packages/schema/package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
{
"name": "@rustrak/openshowcase-schema",
"version": "0.1.0",
"description": "Zod schema and TypeScript types for the OpenShowcase interactive demo format",
"description": "Valibot schema and TypeScript types for the OpenShowcase interactive demo format",
"keywords": [
"openshowcase",
"interactive-demo",
"product-demo",
"product-tour",
"schema",
"zod"
"valibot"
],
"license": "MIT",
"author": "AbianS",
Expand Down Expand Up @@ -41,10 +41,16 @@
"build": "rslib build",
"dev": "rslib build --watch",
"check-types": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"size": "size-limit"
},
"dependencies": {
"zod": "4.6.5"
"devDependencies": {
"@rslib/core": "1.0.1",
"@size-limit/file": "14.0.0",
"size-limit": "14.0.0",
"typescript": "6.0.3",
"vitest": "5.0.1"
},
"size-limit": [
{
Expand All @@ -53,10 +59,7 @@
"limit": "3 KB"
}
],
"devDependencies": {
"@rslib/core": "1.0.1",
"@size-limit/file": "14.0.0",
"size-limit": "14.0.0",
"typescript": "6.0.3"
"dependencies": {
"valibot": "1.5.0"
}
}
7 changes: 5 additions & 2 deletions packages/schema/rslib.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { defineConfig } from "@rslib/core";

// Pure TS+zod library, no framework/CSS to process. autoExternal (default on) keeps `zod`
// Pure TS+valibot library, no framework/CSS to process. autoExternal (default on) keeps `valibot`
// as a real `import` in the output rather than bundling it in — correct for a package meant
// to be published standalone, where the consumer brings their own zod.
// to be published standalone, where the consumer brings their own valibot.
export default defineConfig({
// Build-only tsconfig: tests live under src/ (so check-types sees them) but must not get
// emitted declarations in dist/.
source: { tsconfigPath: "./tsconfig.build.json" },
lib: [
{
format: "esm",
Expand Down
190 changes: 190 additions & 0 deletions packages/schema/src/__test__/unit/parse-demo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { describe, expect, it } from "vitest";
import { type Demo, DemoParseError, parseDemo } from "../../index";

function createDemo(): Demo {
return {
id: "demo-1",
title: "Overview",
video: {
src: "assets/recording.webm",
width: 3000,
height: 1790,
durationSec: 12.5,
},
theme: { wrapper: "browser", autoplay: true, appearance: "light" },
steps: [
{ id: "step-1", type: "video", startTime: 1.23, endTime: 3.99 },
{
id: "step-2",
type: "photo",
image: { src: "assets/step-1.webp", width: 3000, height: 1790 },
hotspot: {
x: 0.16,
y: 0.32,
label: "Click the project",
bgColor: "#C5F11E",
textColor: "#0C0C0C",
position: "auto",
},
panZoom: { x: 0.28, y: 0.32, scale: 1.8 },
},
{
id: "step-3",
type: "video",
startTime: 4,
endTime: 9,
playbackRate: 2,
panZoom: { x: 0.5, y: 0.5, scale: 1.5, duration: 800, easing: "fast" },
},
],
};
}

type Mutable = Record<string, unknown> & { steps: Record<string, unknown>[] };

/** A deep copy of the demo, with `edit` applied, as untyped JSON. */
function demoWith(edit: (demo: Mutable) => void): unknown {
const demo = JSON.parse(JSON.stringify(createDemo())) as Mutable;
edit(demo);
return demo;
}

const photo = (demo: Mutable) => demo.steps[1] as Record<string, never>;
const video = (demo: Mutable) => demo.steps[2] as Record<string, unknown>;

describe("parseDemo", () => {
it("returns a valid demo unchanged", () => {
expect(parseDemo(createDemo())).toEqual(createDemo());
});

it("accepts a demo without a recording and with no steps", () => {
const demo = demoWith((d) => {
delete d.video;
d.steps = [];
});
expect(parseDemo(demo)).toEqual(demo);
});

it("accepts a hotspot with only its position", () => {
const demo = demoWith((d) => {
photo(d).hotspot = { x: 0, y: 1 } as never;
});
expect(parseDemo(demo)).toEqual(demo);
});

it("drops unknown keys", () => {
const demo = demoWith((d) => {
d.extra = 1;
photo(d).junk = true as never;
});
expect(parseDemo(demo)).toEqual(createDemo());
});

it.each<[string, (d: Mutable) => void]>([
["a missing title", (d) => delete d.title],
["a numeric id", (d) => (d.id = 1)],
["a missing theme", (d) => delete d.theme],
[
"an unknown wrapper",
(d) => (d.theme = { ...(d.theme as object), wrapper: "phone" }),
],
[
"a non-boolean autoplay",
(d) => (d.theme = { ...(d.theme as object), autoplay: "yes" }),
],
[
"steps that aren't an array",
(d) => ((d as Record<string, unknown>).steps = {}),
],
[
"a recording with zero duration",
(d) => (d.video = { ...(d.video as object), durationSec: 0 }),
],
["an unknown step type", (d) => (photo(d).type = "gif" as never)],
[
"an image with zero width",
(d) => (photo(d).image = { src: "a.webp", width: 0, height: 1 } as never),
],
[
"a hotspot x above 1",
(d) => (photo(d).hotspot = { x: 1.5, y: 0.5 } as never),
],
[
"a hotspot y below 0",
(d) => (photo(d).hotspot = { x: 0.5, y: -0.1 } as never),
],
[
"a NaN hotspot coordinate",
(d) => (photo(d).hotspot = { x: Number.NaN, y: 0.5 } as never),
],
[
"an unknown hotspot position",
(d) =>
(photo(d).hotspot = { x: 0.5, y: 0.5, position: "middle" } as never),
],
[
"a zoom scale below 1",
(d) => (photo(d).panZoom = { x: 0.5, y: 0.5, scale: 0.5 } as never),
],
[
"a zero zoom duration",
(d) =>
(photo(d).panZoom = { x: 0.5, y: 0.5, scale: 2, duration: 0 } as never),
],
[
"an unknown zoom easing",
(d) =>
(photo(d).panZoom = {
x: 0.5,
y: 0.5,
scale: 2,
easing: "bounce",
} as never),
],
["a negative clip start", (d) => (video(d).startTime = -1)],
["a zero clip end", (d) => (video(d).endTime = 0)],
[
"an infinite clip end",
(d) => (video(d).endTime = Number.POSITIVE_INFINITY),
],
["a zero playback rate", (d) => (video(d).playbackRate = 0)],
])("rejects %s", (_, edit) => {
expect(() => parseDemo(demoWith(edit))).toThrow(DemoParseError);
});

it("rejects null", () => {
expect(() => parseDemo(null)).toThrow(DemoParseError);
});
});

describe("DemoParseError", () => {
it("is an Error that lists every issue with its path", () => {
const data = demoWith((d) => {
d.title = 42;
photo(d).hotspot = { x: 1.5, y: 0.5 } as never;
});

let error: unknown;
try {
parseDemo(data);
} catch (caught) {
error = caught;
}

expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(DemoParseError);
const { name, issues } = error as DemoParseError;
expect(name).toBe("DemoParseError");
expect(issues.map((issue) => issue.path)).toEqual([
"title",
"steps.1.hotspot.x",
]);
for (const issue of issues) expect(issue.message).not.toBe("");
});

it("names the failing path in its message", () => {
expect(() => parseDemo(demoWith((d) => (video(d).endTime = 0)))).toThrow(
/steps\.2\.endTime/,
);
});
});
Loading
Loading