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
29 changes: 29 additions & 0 deletions .changeset/json-deck-prop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@textcortex/slidewise": minor
---

Add `jsonDeck` prop and expose `resolveJsonDeck` — the AI-facing entry point for feeding model-generated decks into the editor.

**`SlidewiseEditor` / `Slidewise.Root`**

- New top-level `jsonDeck?: Deck | string` prop. Pass either a parsed `Deck` object or a JSON string and Slidewise will `JSON.parse` (when needed) and run the value through `migrate()` before mounting — no manual normalisation required. Takes precedence over `deck` when both are provided.
- `deck` is now optional; one of `deck` or `jsonDeck` must be supplied. Existing callers passing only `deck` are unaffected.

**Why**

This is the contract LLMs target when generating slides. The exported `Deck` TypeScript type is the JSON schema: hosts can prompt their model to emit a `Deck`-shaped object (or stringified JSON) and pipe it straight into `<SlidewiseEditor jsonDeck={...} />` without writing glue.

**New export: `resolveJsonDeck(input: Deck | string): Deck`**

Same parse + migrate helper Slidewise uses internally. Use it to validate AI output before passing it to the editor, or when building tools that emit `Deck` JSON outside of React.

```tsx
import { SlidewiseEditor, resolveJsonDeck } from "@textcortex/slidewise";

// Pass JSON directly:
<SlidewiseEditor jsonDeck={aiGeneratedJsonString} />

// Or validate first:
const deck = resolveJsonDeck(aiGeneratedJsonString);
<SlidewiseEditor deck={deck} />
```
21 changes: 20 additions & 1 deletion packages/slidewise/src/SlidewiseEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,25 @@ export interface SlidewiseEditorProps {
* `onChange` — that would loop. Hold the deck in a stable ref, and
* only pass a new one when you intentionally want to reset the editor
* (e.g. discard changes, load a different file).
*
* One of `deck` or `jsonDeck` is required.
*/
deck?: Deck;
/**
* Deck supplied as JSON — either a `Deck` object or a JSON string. This
* is the AI-facing entry point: feed model output directly without
* manually calling `JSON.parse` or `migrate()`. The value is parsed (if
* a string) and run through `migrate()` so older schema versions are
* upgraded transparently.
*
* Takes precedence over `deck` when both are provided. Pass a stable
* reference (or stable string) on subsequent renders — changing it
* resets the editor's internal state, same as swapping `deck`.
*
* The JSON shape is the public `Deck` type exported from this package.
* Use it as the schema your LLM targets when generating new decks.
*/
deck: Deck;
jsonDeck?: Deck | string;
/** Fires after every committed mutation; receives the updated deck. */
onChange?: (deck: Deck) => void;
/** Fires when the user clicks "Save" in the top bar. */
Expand Down Expand Up @@ -159,6 +176,7 @@ export const SlidewiseEditor = forwardRef<
>(function SlidewiseEditor(
{
deck,
jsonDeck,
onChange,
onSave,
onExport,
Expand Down Expand Up @@ -199,6 +217,7 @@ export const SlidewiseEditor = forwardRef<

const rootProps: SlidewiseRootProps = {
deck,
jsonDeck,
onChange,
onSave,
onExport,
Expand Down
43 changes: 38 additions & 5 deletions packages/slidewise/src/compound/SlidewiseRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
useEffect,
useId,
useImperativeHandle,
useMemo,
useRef,
useState,
type CSSProperties,
Expand All @@ -15,6 +16,7 @@ import {
useEditorStore,
} from "@/lib/StoreProvider";
import { collectFontFamilies, ensureGoogleFontsLoaded } from "@/lib/fonts";
import { resolveJsonDeck } from "@/lib/schema/json";
import type { Deck } from "@/lib/types";
import { GridView } from "@/components/editor/GridView";
import { PlayMode } from "@/components/editor/PlayMode";
Expand All @@ -39,8 +41,22 @@ export interface SlidewiseRootProps {
* Deck to load on mount. Pass a new reference only when you intend to
* reset the editor's state (e.g. discard changes, load a different file)
* — passing a new reference on every `onChange` would loop.
*
* One of `deck` or `jsonDeck` is required.
*/
deck: Deck;
deck?: Deck;
/**
* Deck supplied as JSON — either a `Deck` object or a JSON string. This is
* the AI-facing entry point: feed model output directly without manually
* calling `JSON.parse` or `migrate()`. The input is parsed (if a string)
* and run through `migrate()` to upgrade older schema versions.
*
* Takes precedence over `deck` when both are provided. Like `deck`, the
* value should only change when you intentionally want to reset the
* editor's state — pass a stable reference (or stable string) on subsequent
* renders to avoid re-loading on every commit.
*/
jsonDeck?: Deck | string;
/** Fires after every committed mutation. */
onChange?: (deck: Deck) => void;
/** Fires when the user invokes save (top bar button or imperative API). */
Expand Down Expand Up @@ -222,15 +238,30 @@ export interface SlidewiseRootHandle {
* which is just `<Slidewise.Root>` rendering the standard layout.
*/
export const Root = forwardRef<SlidewiseRootHandle, PropsWithChildren<SlidewiseRootProps>>(
function SlidewiseRoot(props, ref) {
function SlidewiseRoot({ deck, jsonDeck, ...rest }, ref) {
const resolvedDeck = useMemo(
() => resolveInputDeck(deck, jsonDeck),
[deck, jsonDeck]
);
return (
<EditorStoreProvider initialDeck={props.deck}>
<RootInner {...props} forwardedRef={ref} />
<EditorStoreProvider initialDeck={resolvedDeck}>
<RootInner {...rest} deck={resolvedDeck} forwardedRef={ref} />
</EditorStoreProvider>
);
}
);

function resolveInputDeck(
deck: Deck | undefined,
jsonDeck: Deck | string | undefined
): Deck {
if (jsonDeck !== undefined) return resolveJsonDeck(jsonDeck);
if (deck !== undefined) return deck;
throw new Error(
"Slidewise: <Root> requires either a `deck` or `jsonDeck` prop."
);
}

function RootInner({
deck,
onChange,
Expand Down Expand Up @@ -258,7 +289,9 @@ function RootInner({
style,
children,
forwardedRef,
}: PropsWithChildren<SlidewiseRootProps> & {
}: PropsWithChildren<Omit<SlidewiseRootProps, "deck" | "jsonDeck">> & {
/** Resolved deck — always provided by the outer `Root`. */
deck: Deck;
forwardedRef: Ref<SlidewiseRootHandle>;
}) {
const store = useEditorStore();
Expand Down
1 change: 1 addition & 0 deletions packages/slidewise/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export { parsePptx, serializeDeck } from "./lib/pptx";
export type { ParseDiagnostics, ParseResult } from "./lib/pptx/types";

export { migrate, CURRENT_DECK_VERSION } from "./lib/schema/migrate";
export { resolveJsonDeck } from "./lib/schema/json";

export type {
Deck,
Expand Down
39 changes: 39 additions & 0 deletions packages/slidewise/src/lib/schema/__tests__/json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { resolveJsonDeck } from "../json";
import { CURRENT_DECK_VERSION } from "../migrate";

describe("schema/json.resolveJsonDeck", () => {
it("accepts a parsed Deck object and stamps the current version", () => {
const out = resolveJsonDeck({
title: "From AI",
slides: [{ id: "s1", background: "#FFFFFF", elements: [] }],
} as never);
expect(out.version).toBe(CURRENT_DECK_VERSION);
expect(out.title).toBe("From AI");
expect(out.slides).toHaveLength(1);
});

it("accepts a JSON string", () => {
const json = JSON.stringify({
version: CURRENT_DECK_VERSION,
title: "Stringified",
slides: [{ id: "s1", background: "#000", elements: [] }],
});
const out = resolveJsonDeck(json);
expect(out.title).toBe("Stringified");
expect(out.slides[0].id).toBe("s1");
});

it("throws on malformed JSON strings", () => {
expect(() => resolveJsonDeck("{ not valid")).toThrow();
});

it("rejects decks from a newer schema", () => {
const json = JSON.stringify({
version: CURRENT_DECK_VERSION + 1,
title: "Future",
slides: [],
});
expect(() => resolveJsonDeck(json)).toThrow(/newer than this build/);
});
});
18 changes: 18 additions & 0 deletions packages/slidewise/src/lib/schema/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Deck } from "@/lib/types";
import { migrate } from "./migrate";

/**
* Normalise a host-supplied deck JSON — either a parsed `Deck` object or a
* JSON string — into a current-schema `Deck`. Runs the input through
* `migrate()` so older `version` stamps are upgraded and the basic shape is
* validated. This is the entry point hosts use when feeding AI-generated
* decks to `<SlidewiseEditor jsonDeck={...} />`.
*
* Throws if the string isn't valid JSON, or if the resulting object is
* missing the basic `Deck` shape / its `version` is newer than this build.
*/
export function resolveJsonDeck(input: Deck | string): Deck {
const parsed: unknown =
typeof input === "string" ? (JSON.parse(input) as unknown) : input;
return migrate(parsed);
}
Loading