From f081013b9d20b825fd1227be8621361b41532f8e Mon Sep 17 00:00:00 2001 From: nholtware Date: Thu, 5 Nov 2026 13:15:11 -0600 Subject: [PATCH] feat: story import/export as JSON Co-Authored-By: Claude Sonnet 5.5 --- src/lib/exchange.test.ts | 48 +++++++++++++++++++++++++ src/lib/exchange.ts | 77 ++++++++++++++++++++++++++++++++++++++++ src/routes/Build.tsx | 11 ++++++ src/routes/BuildList.tsx | 29 ++++++++++++++- 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 src/lib/exchange.test.ts create mode 100644 src/lib/exchange.ts diff --git a/src/lib/exchange.test.ts b/src/lib/exchange.test.ts new file mode 100644 index 0000000..ce642c3 --- /dev/null +++ b/src/lib/exchange.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { exportStory, parseImport } from "./exchange"; +import { emptyDoc } from "./stories"; + +describe("exchange", () => { + it("round-trips a story", () => { + const input = { title: "T", description: "d", doc: emptyDoc() }; + expect(parseImport(exportStory(input))).toEqual({ + ...input, + doc: { + ...input.doc, + nodes: { start: { ...input.doc.nodes.start!, ending: undefined } }, + }, + }); + }); + + it("accepts a bare {title, doc} and defaults optional fields", () => { + const s = parseImport( + JSON.stringify({ + title: " X ", + doc: { start: "a", nodes: { a: { title: "A" } } }, + }), + ); + expect(s.title).toBe("X"); + expect(s.description).toBe(""); + expect(s.doc.nodes.a).toMatchObject({ text: "", choices: [] }); + }); + + it.each([ + ["not json", /valid JSON/], + ["[]", /Missing title/], + [JSON.stringify({ format: "other/9", title: "t" }), /Unsupported format/], + [JSON.stringify({ title: "t" }), /Missing doc/], + [ + JSON.stringify({ + title: "t", + doc: { start: "a", nodes: { "Bad Key": {} } }, + }), + /Invalid node key/, + ], + [ + JSON.stringify({ title: "t", doc: { start: "a", nodes: { a: {} } } }), + /no title/, + ], + ])("rejects %s", (text, err) => { + expect(() => parseImport(text)).toThrow(err); + }); +}); diff --git a/src/lib/exchange.ts b/src/lib/exchange.ts new file mode 100644 index 0000000..862c842 --- /dev/null +++ b/src/lib/exchange.ts @@ -0,0 +1,77 @@ +import { KEY_RE, type StoryDoc, type StoryInput } from "./stories"; + +const FORMAT = "adventure-story/1"; + +export function exportStory(s: StoryInput): string { + return JSON.stringify( + { format: FORMAT, title: s.title, description: s.description, doc: s.doc }, + null, + 2, + ); +} + +/** Parse an exported (or bare {title, doc}) story. Throws a readable Error. */ +export function parseImport(text: string): StoryInput { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + throw new Error("Not valid JSON"); + } + const o = raw as Record | null; + if (!o || typeof o !== "object") throw new Error("Expected a JSON object"); + if (o.format !== undefined && o.format !== FORMAT) { + throw new Error(`Unsupported format "${String(o.format)}"`); + } + if (typeof o.title !== "string" || !o.title.trim()) { + throw new Error("Missing title"); + } + const doc = o.doc as StoryDoc | undefined; + if ( + !doc || + typeof doc.start !== "string" || + !doc.nodes || + typeof doc.nodes !== "object" + ) { + throw new Error("Missing doc.start / doc.nodes"); + } + const nodes: StoryDoc["nodes"] = {}; + for (const [k, v] of Object.entries(doc.nodes)) { + if (!KEY_RE.test(k)) throw new Error(`Invalid node key "${k}"`); + const node = v as Partial; + if (typeof node?.title !== "string") + throw new Error(`Node "${k}" has no title`); + nodes[k] = { + title: node.title, + text: typeof node.text === "string" ? node.text : "", + ending: + node.ending === "win" || + node.ending === "lose" || + node.ending === "neutral" + ? node.ending + : undefined, + choices: Array.isArray(node.choices) + ? node.choices.map((c) => ({ + label: String(c?.label ?? ""), + to: String(c?.to ?? ""), + })) + : [], + }; + } + return { + title: o.title.trim(), + description: typeof o.description === "string" ? o.description : "", + doc: { start: doc.start, nodes }, + }; +} + +export function downloadJson(filename: string, text: string) { + const url = URL.createObjectURL( + new Blob([text], { type: "application/json" }), + ); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/src/routes/Build.tsx b/src/routes/Build.tsx index 0e9223b..c0eae9f 100644 --- a/src/routes/Build.tsx +++ b/src/routes/Build.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router"; +import { downloadJson, exportStory } from "../lib/exchange"; import { analyze, ApiError, @@ -130,6 +131,16 @@ export function Editor({ story }: { story: Story }) { + {published && Play →} {message ?? (dirty ? "Unsaved changes" : "")} diff --git a/src/routes/BuildList.tsx b/src/routes/BuildList.tsx index 34d50a0..a825b7e 100644 --- a/src/routes/BuildList.tsx +++ b/src/routes/BuildList.tsx @@ -1,4 +1,6 @@ +import { useState } from "react"; import { Link, useLoaderData, useNavigate, useRevalidator } from "react-router"; +import { parseImport } from "../lib/exchange"; import { createStory, deleteStory, @@ -13,6 +15,7 @@ export function BuildList() { const stories = useLoaderData() as StorySummary[]; const navigate = useNavigate(); const { revalidate } = useRevalidator(); + const [importError, setImportError] = useState(null); async function create() { const { story } = await createStory({ @@ -23,6 +26,17 @@ export function BuildList() { navigate(`/build/${story.id}`); } + async function importFile(file: File | undefined) { + if (!file) return; + try { + const input = parseImport(await file.text()); + const { story } = await createStory(input); + navigate(`/build/${story.id}`); + } catch (e) { + setImportError(e instanceof Error ? e.message : "Import failed"); + } + } + async function remove(s: StorySummary) { if (!confirm(`Delete "${s.title}"? This cannot be undone.`)) return; await deleteStory(s.id); @@ -33,8 +47,21 @@ export function BuildList() {

Your stories

- + {" "} +

+ {importError &&

Import failed: {importError}

}
    {stories.map((s) => (