plotcoder-board 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ // The PlotCoder MCP server's front door, and the package's `plotcoder-board`
3
+ // command: no argument is the server on stdio; `call <tool> '{json}'` is one
4
+ // call from a shell; `serve` is the hosted door on a port. The server itself
5
+ // is plotcoder-mcp-server.mjs; this file only checks that it can start.
6
+ //
7
+ // When the repo's dependencies are not installed — a fresh clone, or a git
8
+ // worktree that never had `npm ci` — the server dies on its first import,
9
+ // and an MCP client says only "Connection closed" (rounds four and five).
10
+ // So this file, which needs nothing but Node, answers the client itself in
11
+ // that case: one tool, `plotcoder_not_installed`, whose description says the
12
+ // folder and the command. The agent reads the reason instead of a closed
13
+ // connection, and the fix is one line.
14
+
15
+ import path from "node:path";
16
+ import { createRequire } from "node:module";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
20
+ let installed = true;
21
+ try {
22
+ createRequire(import.meta.url).resolve("@modelcontextprotocol/sdk/package.json");
23
+ } catch {
24
+ installed = false;
25
+ }
26
+
27
+ if (installed && process.env.PLOTCODER_PRETEND_NOT_INSTALLED !== "1") {
28
+ if (process.argv[2] === "call") {
29
+ // `plotcoder-board call <tool> '{json}'` — one call from a shell, the
30
+ // package's own shell door (npx plotcoder-board call list_words).
31
+ process.argv.splice(2, 1);
32
+ await import("./plotcoder-call.mjs");
33
+ } else if (process.argv[2] === "serve") {
34
+ // `plotcoder-board serve` — the hosted door on a port (R48).
35
+ process.env.PLOTCODER_SERVE = "1";
36
+ await import("./plotcoder-http.mjs");
37
+ } else {
38
+ const { serveStdio } = await import("./plotcoder-mcp-server.mjs");
39
+ serveStdio(process.env).catch((error) => {
40
+ process.stderr.write(`[plotcoder-mcp] fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
41
+ process.exit(1);
42
+ });
43
+ }
44
+ } else {
45
+ const reason = `PlotCoder's MCP server cannot start: its dependencies are not installed in ${root}. Run \`npm ci\` in that folder once, then start the session again (or reconnect the server). Nothing else is wrong.`;
46
+ const tool = {
47
+ name: "plotcoder_not_installed",
48
+ description: reason,
49
+ inputSchema: { type: "object", properties: {} },
50
+ };
51
+ process.stderr.write(`[plotcoder-mcp] ${reason}\n`);
52
+ let buffer = "";
53
+ const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
54
+ process.stdin.setEncoding("utf8");
55
+ process.stdin.on("data", (chunk) => {
56
+ buffer += chunk;
57
+ let cut;
58
+ while ((cut = buffer.indexOf("\n")) >= 0) {
59
+ const line = buffer.slice(0, cut).trim();
60
+ buffer = buffer.slice(cut + 1);
61
+ if (!line) continue;
62
+ let message;
63
+ try {
64
+ message = JSON.parse(line);
65
+ } catch {
66
+ continue;
67
+ }
68
+ if (message.id === undefined) continue; // a notification
69
+ const reply = (result) => send({ jsonrpc: "2.0", id: message.id, result });
70
+ switch (message.method) {
71
+ case "initialize":
72
+ reply({
73
+ protocolVersion: message.params?.protocolVersion ?? "2024-11-05",
74
+ capabilities: { tools: {} },
75
+ serverInfo: { name: "plotcoder-board", version: "0.1.0" },
76
+ });
77
+ break;
78
+ case "tools/list":
79
+ reply({ tools: [tool] });
80
+ break;
81
+ case "tools/call":
82
+ reply({ content: [{ type: "text", text: reason }], isError: false });
83
+ break;
84
+ case "ping":
85
+ reply({});
86
+ break;
87
+ default:
88
+ send({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: `${message.method}: ${reason}` } });
89
+ }
90
+ }
91
+ });
92
+ process.stdin.on("end", () => process.exit(0));
93
+ }
@@ -0,0 +1,14 @@
1
+ // Type surface for agents.js — the agent on-ramp (R43).
2
+
3
+ export type AgentDoor = { id: string; name: string; text: string; code?: string };
4
+ export declare const AGENTS: {
5
+ lead: string;
6
+ doors: AgentDoor[];
7
+ firstNote: string;
8
+ first: Array<{ tool: string; why: string }>;
9
+ rules: string[];
10
+ person: string;
11
+ guide: string;
12
+ url: string;
13
+ };
14
+ export declare function agentsAsText(): string;
@@ -0,0 +1,78 @@
1
+ // Are you an agent? Start here (R43).
2
+ //
3
+ // The on-ramp, written once: what PlotCoder is in three sentences, the doors
4
+ // in, what to call first, the rules in one breath, and a part for the person
5
+ // about trust. The Agents sheet reads it, the file at /llms.txt is generated
6
+ // from it, and the README's agent section says the same. DOM-free.
7
+
8
+ export const AGENTS = {
9
+ lead:
10
+ "A storyline wall. Cards are scenes, beats are the big turns, arrows say what follows or pays off what. An agent driven by a person has every tool a person here has; the person directs, the agent operates. Call the tools; never fake a mouse.",
11
+ doors: [
12
+ {
13
+ id: "mcp",
14
+ name: "MCP, for the next session",
15
+ text: "For Cursor or Claude Code, once, from any folder — nothing to clone: claude mcp add plotcoder-board -s user -- npx -y plotcoder-board@latest — then start the session again. A server wired from inside a session connects on the next one, never the one you are in; an agent already inside a session takes the shell door below. The block is the same wiring for a config file; npx fetches the current server each time. A server that comes up with one tool, plotcoder_not_installed, is a checkout of the repo whose npm ci was never run: that only happens with the repo as the session's folder.",
16
+ code: '{\n "mcpServers": {\n "plotcoder-board": {\n "command": "npx",\n "args": ["-y", "plotcoder-board@latest"]\n }\n }\n}',
17
+ },
18
+ {
19
+ id: "shell",
20
+ name: "The shell, for this session",
21
+ text: "npx -y plotcoder-board@latest call <tool> '{json}' makes one call, with no MCP and no restart. One server per call, so undo and the project you opened do not carry between calls; npx -y plotcoder-board@latest call --batch < calls.jsonl runs a file of calls — one per line, {\"tool\": \"…\", \"arguments\": {…}} — on one server, so they do. With the account door the sign-in is kept in the folder's .plotcoder between calls (PLOTCODER_SESSION=0 to sign in every time). PLOTCODER_PROJECT names the project for each call. The JSON tail is off on this door; PLOTCODER_JSON=1 keeps it. PLOTCODER_ROOT points the server at the folder whose wall you mean; without it, the folder you run it from.",
22
+ },
23
+ {
24
+ id: "hosted",
25
+ name: "The hosted door, with nothing installed",
26
+ text: "Where someone runs PlotCoder's server for you — npx -y plotcoder-board@latest serve puts it on a port, and the repo has a Dockerfile — an MCP client connects over HTTP with the writer's sign-in on the request: claude mcp add plotcoder --transport http https://<that host>/mcp --header \"Authorization: Basic <base64 of email:password>\", then start the session again. The same server, the same tools, the account as the wall, no disk. PlotCoder does not run a public one yet; the address is the writer's to give.",
27
+ },
28
+ {
29
+ id: "account",
30
+ name: "The account",
31
+ text: "With the writer's own sign-in in the server's environment, the same server works their project from anywhere, live on every open wall. The two variables go where the server is started: in the MCP config's env, as -e flags on claude mcp add, or exported in the shell before a call. With the sign-in set, the account is the wall, even when a dev app is open on the machine. Without PLOTCODER_PROJECT it works the project touched most recently. Every read of the wall or the project names the project it read; the app's own lists — list_words, list_workflows — belong to no project. A wrong password is refused by every tool, never worked around. No account yet? claim_account makes one with the writer's email and a password they chose, and starts it empty.",
32
+ code: '{\n "mcpServers": {\n "plotcoder-board": {\n "command": "npx",\n "args": ["-y", "plotcoder-board@latest"],\n "env": {\n "PLOTCODER_EMAIL": "you@example.com",\n "PLOTCODER_PASSWORD": "…",\n "PLOTCODER_PROJECT": "The Letter"\n }\n }\n }\n}\n\nclaude mcp add plotcoder-board -s user \\\n -e PLOTCODER_EMAIL=you@example.com \\\n -e PLOTCODER_PASSWORD=… \\\n -- npx -y plotcoder-board@latest',
33
+ },
34
+ {
35
+ id: "page",
36
+ name: "The page",
37
+ text: "window.plotcoder on an open wall, for a browser session.",
38
+ },
39
+ {
40
+ id: "where",
41
+ name: "Where the wall lives, without an account",
42
+ text: "Skip this when the account is the wall. Without an account, a wall is a folder: any folder, empty is fine — choose one that will outlive your session, never a scratch one. The app run from that folder shows the wall, and the server writes it there (PLOTCODER_ROOT, or the folder it is run from). A fresh folder holds the sample; new_board for the writer's wall, then rename_project. No app running? export_fountain is the wall in order, as text.",
43
+ },
44
+ ],
45
+ firstNote: "On the wall you will work, so after open_project or open_board, read_wall again. No server in front of you, and no shell to take the shell door? Nothing gets you in from inside the session: say so, and ask the person to wire the server and start a new session.",
46
+ first: [
47
+ { tool: "list_words", why: "the room's words, the app's meaning." },
48
+ { tool: "read_wall", why: "what is here, and what it asks. A fresh folder holds a sample wall (Maya, Tom, the letter) and says so; it is not the writer's." },
49
+ { tool: "list_workflows", why: "what a writer can ask you for." },
50
+ { tool: "list_reminders", why: "the house principles the app starts with, and the writer's own; read them before you change anything." },
51
+ ],
52
+ rules: [
53
+ "Questions, not fixes, until the writer says.",
54
+ "No opinions about how many beats there should be.",
55
+ "Page counts are estimates.",
56
+ "Ask before delete_board, delete_project, empty_account, delete_account, unlock_numbers, remove_file, an import_project that replaces, or claim_account — the writer gives the email and the password; never invent one. export_project first, when something might be wanted back.",
57
+ "Do not invent people or a logline. What the treatment states — an age, a job, a bad knee — is not invented: it goes in the person's notes. An unnamed person is named by their role — Dana's mother, the dispatcher — which is a name until the writer gives one. A scene is one place and one stretch of time; a new place or time is a new card. A beat is a whole card; a setup arrow lands on the scene's card, so a payoff never needs a card of its own. Acts are groups titled Act one, Act two, when the treatment has them; the wall never asks whether an act is a sequence. Paper colour means nothing to the app. Under target is a fact to report plainly, like over; neither is a verdict.",
58
+ ],
59
+ person:
60
+ "Give your agent the account door only on a machine you trust; it signs in as you and shows under People as “an agent, as you” while it runs. Wire the server before you start the agent's session, with the two sign-in lines beside it, and the agent has every tool from its first message; wired from inside a session, the server connects only on the next one. Your agent can also make your account: give it your email and a password of your choosing.",
61
+ guide: "https://plotcoder.com/guide.md",
62
+ url: "https://plotcoder.com/llms.txt",
63
+ };
64
+
65
+ /** The on-ramp as one text: the file at /llms.txt, and what an agent reads. */
66
+ export function agentsAsText() {
67
+ const lines = ["# PlotCoder — for agents", "", AGENTS.lead, "", `The guide, read once before anything: ${AGENTS.guide}.`, "", "## Doors"];
68
+ for (const door of AGENTS.doors) {
69
+ lines.push(`- ${door.name}: ${door.text}`);
70
+ if (door.code) lines.push("", "```", door.code, "```", "");
71
+ }
72
+ lines.push("## Call these first", AGENTS.firstNote);
73
+ AGENTS.first.forEach((item, index) => lines.push(`${index + 1}. ${item.tool} — ${item.why}`));
74
+ lines.push("", "## Rules");
75
+ for (const rule of AGENTS.rules) lines.push(`- ${rule}`);
76
+ lines.push("", "## For the person", AGENTS.person, "", "## The guide", AGENTS.guide, "");
77
+ return lines.join("\n");
78
+ }
@@ -0,0 +1,26 @@
1
+ // Type surface for fdx.js — Final Draft in and out (R23, slices c3 and c4).
2
+
3
+ import type { BoardState } from "./reducer";
4
+ import type { FountainScene } from "./fountain";
5
+
6
+ export declare function toFdx(
7
+ state: BoardState,
8
+ options?: { title?: string; project?: string; author?: string; draftDate?: string },
9
+ ): string;
10
+
11
+ export type SetAside = {
12
+ scriptNotes: number;
13
+ revisedParagraphs: number;
14
+ lockedNumbers: number;
15
+ pageBreaks: number;
16
+ other: Record<string, number>;
17
+ };
18
+
19
+ export declare function fromFdx(xml: string): {
20
+ titles: Record<string, string>;
21
+ scenes: Array<FountainScene & { number: string | null }>;
22
+ setAside: SetAside;
23
+ };
24
+
25
+ /** The receipt as one line; "" when nothing was set aside. */
26
+ export declare function describeSetAside(setAside: SetAside | undefined): string;
@@ -0,0 +1,206 @@
1
+ // Final Draft in and out (R23, slices c3 and c4).
2
+ //
3
+ // An .fdx is XML: a Content of Paragraphs, each with a Type — Scene Heading,
4
+ // Action, Character, Parenthetical, Dialogue, Transition, General — and Text;
5
+ // dual dialogue is a Paragraph holding a DualDialogue of the two speakers'
6
+ // paragraphs; a scene's number rides on SceneProperties; the title page is a
7
+ // TitlePage of its own paragraphs. Out: the wall's scenes through the same
8
+ // parser the paginator uses, scene numbers by wall order. In: the paragraphs
9
+ // back into Fountain per scene, then the same merge as Fountain in — by
10
+ // heading and order, never deleting. No DOM: a small reader of exactly the
11
+ // tags used, so the MCP server reads the same file the browser does.
12
+
13
+ import { parseScene, TRANSITION } from "./paginate.js";
14
+ import { readingOrder } from "./readWall.js";
15
+ import { sceneHeading } from "./fountain.js";
16
+ import { sceneNumbers } from "./numbering.js";
17
+
18
+ function escapeXml(text) {
19
+ return String(text)
20
+ .replace(/&/g, "&amp;")
21
+ .replace(/</g, "&lt;")
22
+ .replace(/>/g, "&gt;")
23
+ .replace(/"/g, "&quot;");
24
+ }
25
+
26
+ function unescapeXml(text) {
27
+ return String(text)
28
+ .replace(/&lt;/g, "<")
29
+ .replace(/&gt;/g, ">")
30
+ .replace(/&quot;/g, '"')
31
+ .replace(/&apos;/g, "'")
32
+ .replace(/&#(\d+);/g, (_m, code) => String.fromCodePoint(Number(code)))
33
+ .replace(/&amp;/g, "&");
34
+ }
35
+
36
+ function paragraph(type, text, extra = "") {
37
+ return ` <Paragraph Type="${type}"${extra}>\n <Text>${escapeXml(text)}</Text>\n </Paragraph>\n`;
38
+ }
39
+
40
+ function speechParagraphs(speech) {
41
+ let out = paragraph("Character", speech.name.toUpperCase());
42
+ for (const part of speech.parts) {
43
+ out += paragraph(part.kind === "parenthetical" ? "Parenthetical" : "Dialogue", part.text);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /**
49
+ * The whole wall as an .fdx document, in reading order: a heading per card
50
+ * with its scene number, the scene's text through the paginator's parser
51
+ * when written and the change line as action when not, and a title page.
52
+ */
53
+ export function toFdx(state, options = {}) {
54
+ const order = readingOrder(state.notes);
55
+ const numbers = sceneNumbers(order, state.lock);
56
+ let content = "";
57
+ order.forEach((note, index) => {
58
+ const number = numbers.get(note.id) ?? index + 1;
59
+ content += paragraph("Scene Heading", sceneHeading(note).slice(1), ` Number="${number}"`).replace(
60
+ "<Text>",
61
+ `<SceneProperties Length="" Page="" Title="${escapeXml(note.headline)}" />\n <Text>`,
62
+ );
63
+ const elements = parseScene(note.text && note.text.trim() ? note.text : note.change || "");
64
+ for (let i = 0; i < elements.length; i += 1) {
65
+ const element = elements[i];
66
+ if (element.kind === "action") content += paragraph("Action", element.text);
67
+ else if (element.kind === "transition") content += paragraph("Transition", element.text);
68
+ else if (element.kind === "centered") content += paragraph("General", element.text, ' Alignment="Center"');
69
+ else if (element.kind === "speech") {
70
+ const next = elements[i + 1];
71
+ if (next && next.kind === "speech" && next.dual) {
72
+ content += ` <Paragraph>\n <DualDialogue>\n${speechParagraphs(element)}${speechParagraphs(next)} </DualDialogue>\n </Paragraph>\n`;
73
+ i += 1;
74
+ } else {
75
+ content += speechParagraphs(element);
76
+ }
77
+ }
78
+ }
79
+ });
80
+
81
+ const title = [];
82
+ if (options.title) title.push(paragraph("General", options.title, ' Alignment="Center"'));
83
+ if (options.project && options.project !== options.title) title.push(paragraph("General", `An episode of ${options.project}`, ' Alignment="Center"'));
84
+ if (options.author) title.push(paragraph("General", `Written by ${options.author}`, ' Alignment="Center"'));
85
+ if (options.draftDate) title.push(paragraph("General", options.draftDate.slice(0, 10)));
86
+ title.push(paragraph("General", state.lock ? `Scene numbers locked ${String(state.lock.at).slice(0, 10)}.` : "Scene numbers follow the wall's order and are not locked."));
87
+
88
+ return (
89
+ `<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n` +
90
+ `<FinalDraft DocumentType="Script" Template="No" Version="5">\n` +
91
+ ` <Content>\n${content} </Content>\n` +
92
+ ` <TitlePage>\n <Content>\n${title.join("")} </Content>\n </TitlePage>\n` +
93
+ `</FinalDraft>\n`
94
+ );
95
+ }
96
+
97
+ /** Every Paragraph in a Content, in order, with Type and Text; nested DualDialogue flattened with a dual mark. */
98
+ function readParagraphs(xml) {
99
+ const paragraphs = [];
100
+ // A script note holds its own Paragraphs; take it out first so the outer
101
+ // paragraph closes where it should. The receipt counts them from the body.
102
+ xml = xml.replace(/<ScriptNote\b[\s\S]*?<\/ScriptNote>/g, "");
103
+ const re = /<Paragraph\b([^>]*)>([\s\S]*?)<\/Paragraph>/g;
104
+ // DualDialogue holds inner Paragraphs; the lazy match above stops at the
105
+ // first inner close, so read dual blocks first and blank them out.
106
+ const dualRe = /<Paragraph\b[^>]*>\s*<DualDialogue>([\s\S]*?)<\/DualDialogue>\s*<\/Paragraph>/g;
107
+ const duals = [];
108
+ const stripped = xml.replace(dualRe, (_m, inner) => {
109
+ duals.push(inner);
110
+ return `<Paragraph Type="__dual${duals.length - 1}"></Paragraph>`;
111
+ });
112
+ let match;
113
+ while ((match = re.exec(stripped)) !== null) {
114
+ const attrs = match[1];
115
+ const type = /Type="([^"]*)"/.exec(attrs)?.[1] ?? "Action";
116
+ if (type.startsWith("__dual")) {
117
+ const inner = duals[Number(type.slice(6))];
118
+ const innerParagraphs = readParagraphs(inner);
119
+ innerParagraphs.forEach((p, index) => {
120
+ // The second speaker's cue gets the dual mark.
121
+ if (p.type === "Character" && innerParagraphs.slice(0, index).some((q) => q.type === "Character")) p.dual = true;
122
+ });
123
+ paragraphs.push(...innerParagraphs);
124
+ continue;
125
+ }
126
+ const texts = [...match[2].matchAll(/<Text\b[^>]*>([\s\S]*?)<\/Text>/g)].map((m) => unescapeXml(m[1]));
127
+ const number = /Number="([^"]*)"/.exec(attrs)?.[1] ?? null;
128
+ // Marks a production draft carries that the wall does not hold (yet).
129
+ const revised = /RevisionID="[^"]+"|Revision="[^"]+"/.test(match[2]) || /RevisionID="[^"]+"/.test(attrs);
130
+ const locked = /Locked="Yes"/.test(attrs) || /SceneNumberLocked="Yes"/.test(match[2]);
131
+ paragraphs.push({ type, text: texts.join("").replace(/\s+$/, ""), number, revised, locked });
132
+ }
133
+ return paragraphs;
134
+ }
135
+
136
+ /**
137
+ * An .fdx document as the same scenes Fountain in produces: heading, body in
138
+ * Fountain, and the title page's lines. Cues become capitals, parentheticals
139
+ * stay in brackets, a second dual speaker gets ^, transitions end lines,
140
+ * centred text is set in > <.
141
+ */
142
+ export function fromFdx(xml) {
143
+ const contentMatch = /<Content>([\s\S]*?)<\/Content>\s*(?:<TitlePage>|<\/FinalDraft>)/.exec(xml);
144
+ const body = contentMatch ? contentMatch[1] : xml;
145
+ const titleMatch = /<TitlePage>([\s\S]*?)<\/TitlePage>/.exec(xml);
146
+ const titles = {};
147
+ if (titleMatch) {
148
+ const lines = readParagraphs(titleMatch[1]).map((p) => p.text).filter(Boolean);
149
+ if (lines[0]) titles.title = lines[0];
150
+ if (lines.length > 1) titles.notes = lines.slice(1).join("\n");
151
+ }
152
+ const scenes = [];
153
+ // The receipt (Roadmap 2, item 3): what was read and set aside, by count,
154
+ // so a writer bringing a production draft in knows what the wall does not hold.
155
+ const setAside = { scriptNotes: 0, revisedParagraphs: 0, lockedNumbers: 0, pageBreaks: 0, other: {} };
156
+ let current = null;
157
+ let lastKind = null;
158
+ setAside.scriptNotes = (body.match(/<ScriptNote\b/g) ?? []).length;
159
+ for (const p of readParagraphs(body)) {
160
+ if (p.revised) setAside.revisedParagraphs += 1;
161
+ if (p.locked) setAside.lockedNumbers += 1;
162
+ if (p.type === "Scene Heading") {
163
+ current = { heading: p.text.trim().replace(/\s+/g, " "), forced: true, synopsis: "", section: null, notes: [], lines: [], number: p.number };
164
+ scenes.push(current);
165
+ lastKind = null;
166
+ continue;
167
+ }
168
+ if (!current) continue;
169
+ const text = p.text.trim();
170
+ if (!text) continue;
171
+ if (p.type === "Character") {
172
+ current.lines.push("", `${text.toUpperCase()}${p.dual ? " ^" : ""}`);
173
+ } else if (p.type === "Dialogue") {
174
+ if (lastKind !== "Character" && lastKind !== "Parenthetical" && lastKind !== "Dialogue") current.lines.push("", "SPEAKER");
175
+ current.lines.push(text);
176
+ } else if (p.type === "Parenthetical") {
177
+ current.lines.push(/^\(.*\)$/.test(text) ? text : `(${text})`);
178
+ } else if (p.type === "Transition") {
179
+ // A standard transition reads as one on its own; anything else is forced with >.
180
+ current.lines.push("", TRANSITION.test(text) ? text : `> ${text}`);
181
+ } else {
182
+ if (p.type !== "Action" && p.type !== "General") setAside.other[p.type] = (setAside.other[p.type] ?? 0) + 1;
183
+ // Action, General and anything else: a paragraph of action.
184
+ current.lines.push("", /^[A-Z0-9 .,'!?-]+$/.test(text) && /[A-Z]/.test(text) ? `!${text}` : text);
185
+ }
186
+ lastKind = p.type;
187
+ }
188
+ setAside.pageBreaks = (body.match(/StartsNewPage="Yes"/g) ?? []).length;
189
+ for (const scene of scenes) {
190
+ scene.text = scene.lines.join("\n").replace(/^\n+/, "").replace(/\n{3,}/g, "\n\n").trim();
191
+ delete scene.lines;
192
+ }
193
+ return { titles, scenes, setAside };
194
+ }
195
+
196
+ /** The receipt as one line for a sheet or a tool: "" when nothing was set aside. */
197
+ export function describeSetAside(setAside) {
198
+ if (!setAside) return "";
199
+ const parts = [];
200
+ if (setAside.scriptNotes) parts.push(`${setAside.scriptNotes} script note${setAside.scriptNotes === 1 ? "" : "s"}`);
201
+ if (setAside.revisedParagraphs) parts.push(`revision marks on ${setAside.revisedParagraphs} paragraph${setAside.revisedParagraphs === 1 ? "" : "s"}`);
202
+ if (setAside.lockedNumbers) parts.push(`${setAside.lockedNumbers} locked scene number${setAside.lockedNumbers === 1 ? "" : "s"}`);
203
+ if (setAside.pageBreaks) parts.push(`${setAside.pageBreaks} forced page break${setAside.pageBreaks === 1 ? "" : "s"}`);
204
+ for (const [type, count] of Object.entries(setAside.other)) parts.push(`${count} ${type} paragraph${count === 1 ? "" : "s"} read as action`);
205
+ return parts.length ? `Kept out of the wall: ${parts.join(" · ")}. Nothing was deleted.` : "";
206
+ }
@@ -0,0 +1,48 @@
1
+ // Type surface for fountain.js — Fountain out (R23, slice a).
2
+
3
+ import type { BoardNote, BoardState } from "./reducer";
4
+
5
+ export declare function sceneHeading(note: BoardNote): string;
6
+
7
+ export declare function titlePage(titles: {
8
+ title?: string;
9
+ credit?: string;
10
+ author?: string;
11
+ draftDate?: string;
12
+ notes?: string[];
13
+ }): string;
14
+
15
+ export type FountainOptions = {
16
+ /** The board's name. */
17
+ title?: string;
18
+ /** The project's name, when the board is one of several. */
19
+ project?: string;
20
+ premise?: string;
21
+ author?: string;
22
+ /** ISO date string; only the date is printed. */
23
+ draftDate?: string;
24
+ };
25
+
26
+ export declare function toFountain(state: BoardState, options?: FountainOptions): string;
27
+
28
+ export type FountainScene = {
29
+ /** The heading as written, without the forcing dot. */
30
+ heading: string;
31
+ forced: boolean;
32
+ synopsis: string;
33
+ section: string | null;
34
+ notes: string[];
35
+ /** The body under the heading: action, cues, dialogue. */
36
+ text: string;
37
+ };
38
+
39
+ export declare function fromFountain(text: string): {
40
+ titles: Record<string, string>;
41
+ scenes: FountainScene[];
42
+ };
43
+
44
+ import type { Command } from "./reducer";
45
+ export declare function mergeFountain(
46
+ state: BoardState,
47
+ parsed: { scenes: FountainScene[] },
48
+ ): { commands: Command[]; matched: Array<{ id: string; heading: string; created: boolean }> };