feat: story import/export as JSON
All checks were successful
/ checks (push) Successful in 40s
/ deploy (push) Successful in 4s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nholtware 2026-11-05 13:15:11 -06:00
parent 0cbc9bac3c
commit f081013b9d
4 changed files with 164 additions and 1 deletions

48
src/lib/exchange.test.ts Normal file
View file

@ -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);
});
});

77
src/lib/exchange.ts Normal file
View file

@ -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<string, unknown> | 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<StoryDoc["nodes"][string]>;
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);
}

View file

@ -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 }) {
<button onClick={() => void togglePublish()}>
{published ? "Unpublish" : "Publish"}
</button>
<button
onClick={() =>
downloadJson(
`${story.id}.json`,
exportStory({ title, description, doc }),
)
}
>
Export JSON
</button>
{published && <Link to={`/play/${story.id}`}>Play →</Link>}
<span role="status">{message ?? (dirty ? "Unsaved changes" : "")}</span>
</div>

View file

@ -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<string | null>(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() {
<section>
<h1>Your stories</h1>
<p>
<button onClick={create}>New story</button>
<button onClick={create}>New story</button>{" "}
<label className="button">
Import JSON
<input
type="file"
accept="application/json,.json"
hidden
onChange={(e) => {
void importFile(e.target.files?.[0]);
e.target.value = "";
}}
/>
</label>
</p>
{importError && <p role="alert">Import failed: {importError}</p>}
<ul className="cards">
{stories.map((s) => (
<li key={s.id}>