Download index.html from Inkwell-Software/fountain-structure: direct link, hf CLI and curl.
- Browser
- Download file 23.7 kB
-
https://huggingface.co/spaces/Inkwell-Software/fountain-structure/resolve/main/index.html
- Command line
-
hf download hf://spaces/Inkwell-Software/fountain-structure/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Inkwell-Software/fountain-structure/resolve/main/index.html
23.7 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Fountain structure β Inkwell</title> | |
| <style> | |
| :root { | |
| --bg: oklch(0.18 0.03 264); | |
| --panel: oklch(0.22 0.032 264); | |
| --ink: oklch(0.93 0.02 264); | |
| --muted: oklch(0.72 0.03 264); | |
| --line: oklch(0.35 0.03 264); | |
| --accent: oklch(0.72 0.09 264); | |
| --scene: oklch(0.78 0.08 230); | |
| --char: oklch(0.82 0.09 80); | |
| --dlg: oklch(0.8 0.06 150); | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| min-height: 100vh; | |
| background: var(--bg); | |
| color: var(--ink); | |
| font: 16px/1.5 ui-sans-serif, system-ui, sans-serif; | |
| } | |
| header { | |
| padding: 1.5rem 1.25rem 1rem; | |
| border-bottom: 1px solid var(--line); | |
| } | |
| header a { color: var(--accent); text-decoration: none; } | |
| h1 { margin: 0.25rem 0 0; font-size: 1.35rem; font-weight: 600; } | |
| .sub { color: var(--muted); font-size: 0.95rem; max-width: 40rem; } | |
| main { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 1rem; | |
| padding: 1rem 1.25rem 2rem; | |
| } | |
| @media (max-width: 800px) { main { grid-template-columns: 1fr; } } | |
| textarea { | |
| width: 100%; | |
| min-height: 28rem; | |
| resize: vertical; | |
| background: var(--panel); | |
| color: var(--ink); | |
| border: 1px solid var(--line); | |
| border-radius: 10px; | |
| padding: 1rem; | |
| font: 14px/1.45 ui-monospace, "Courier New", monospace; | |
| } | |
| .panel { | |
| background: var(--panel); | |
| border: 1px solid var(--line); | |
| border-radius: 10px; | |
| padding: 1rem 1.1rem; | |
| } | |
| .panel h2 { margin: 0 0 0.6rem; font-size: 0.8rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); } | |
| .counts { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1rem; } | |
| .chip { border: 1px solid var(--line); border-radius: 999px; padding: 0.15rem 0.6rem; font-size: 0.8rem; } | |
| ul { margin: 0; padding-left: 1.1rem; } | |
| li { margin: 0.2rem 0; } | |
| .scene { color: var(--scene); } | |
| .char { color: var(--char); } | |
| .note { color: var(--muted); font-size: 0.85rem; margin-top: 1rem; } | |
| footer { padding: 0 1.25rem 2rem; color: var(--muted); font-size: 0.85rem; } | |
| footer a { color: var(--accent); } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <a href="https://www.inkwell.software/?utm_source=huggingface&utm_medium=referral&utm_campaign=demo_spaces&utm_content=fountain_structure_app_home" target="_blank" rel="noopener">Inkwell</a> | |
| <h1>Fountain structure</h1> | |
| <p class="sub">Paste a scene. See headings, speakers, and element counts. Runs in your browser β nothing is uploaded.</p> | |
| </header> | |
| <main> | |
| <textarea id="src" spellcheck="false" aria-label="Fountain source"></textarea> | |
| <div class="panel" id="out" aria-live="polite"></div> | |
| </main> | |
| <footer> | |
| Demo tokenizer, not Plotβs parser. | |
| <a href="https://www.inkwell.software/plot?utm_source=huggingface&utm_medium=referral&utm_campaign=demo_spaces&utm_content=fountain_structure_app_plot" target="_blank" rel="noopener">Plot</a> Β· | |
| <a href="https://app.inkwell.software/signup?utm_source=huggingface&utm_medium=referral&utm_campaign=demo_spaces&utm_content=fountain_structure_app_signup" target="_blank" rel="noopener">Start writing free</a> | |
| </footer> | |
| <script> | |
| const FIXTURE = `Title: THE REWRITE | |
| Credit: Written by | |
| Author: A. Fictional | |
| INT. COFFEE SHOP β MORNING | |
| Maya stares at the cursor. The room is too quiet. | |
| MAYA | |
| (half a smile) | |
| What if the ending was the setup? | |
| JULES | |
| Then we already wrote it. | |
| CUT TO: | |
| INT. EDIT BAY β NIGHT | |
| Jules pins a pink page to the wall. | |
| JULES (V.O.) | |
| Lock it. Then change it on purpose. | |
| `; | |
| // --------------------------------------------------------------------- | |
| // The patterns and control flow below mirror the Fountain parser Inkwell | |
| // uses: every pattern it matches, in the order it matches them, with the | |
| // two pieces of state it keys on β `prevBlank` and `inDialogue` β rather | |
| // than inferring them from the previous element's kind. | |
| // --------------------------------------------------------------------- | |
| const CHARACTER_NAME_WORD = "[A-Z][A-Z0-9.'_/&,-]*[A-Z0-9]"; | |
| const CHARACTER_NAME_SEGMENT = "[A-Z](?:[A-Z0-9.'_/&,-]*[A-Z0-9])?"; | |
| const CHARACTER_NAME_JOIN = "(?:[.,]?[ ]|[ ][&][ ])"; | |
| const CHARACTER_NAME_TOKEN_SOURCE = | |
| `(?:${CHARACTER_NAME_WORD}(?:${CHARACTER_NAME_JOIN}${CHARACTER_NAME_SEGMENT})*` + | |
| `|[A-Z](?:${CHARACTER_NAME_JOIN}${CHARACTER_NAME_SEGMENT})+)`; | |
| // The `|[A-Z]` alternative is the parser's deliberate delta: a lone capital | |
| // ("Q") is a legal full cue name. | |
| const CHARACTER_PATTERN = new RegExp( | |
| `^(${CHARACTER_NAME_TOKEN_SOURCE}|[A-Z])(\\s*\\(([^)]+)\\))?(\\s*\\^)?$` | |
| ); | |
| const SCENE_HEADING_PATTERN = /^(INT|EXT|EST|INT\.\/EXT|INT\/EXT|I\/E)[.\s]/i; | |
| const FORCED_SCENE_HEADING = /^\./; | |
| const FORCED_ACTION = /^!/; | |
| const FORCED_CHARACTER = /^@/; | |
| const FORCED_TRANSITION = /^>/; | |
| const TRANSITION_PATTERN = /^[A-Z\s]+TO:$/; | |
| const PAGE_BREAK_PATTERN = /^={3,}\s*$/; | |
| const SYNOPSIS_PATTERN = /^=(?!=)\s*(.+)$/; | |
| const CENTERED_PATTERN = /^>(.+)<\s*$/; | |
| const SECTION_PATTERN = /^(#{1,6})\s+(.+)$/; | |
| const NOTE_PATTERN = /^\[\[(.*)\]\]$/; | |
| const NOTE_OPEN_PATTERN = /^\[\[(.*)$/; | |
| const NOTE_CLOSE_PATTERN = /^(.*)\]\]\s*$/; | |
| const LYRIC_PATTERN = /^~(.*)$/; | |
| const TITLE_PAGE_KEY_PATTERN = /^[A-Za-z][A-Za-z\s]*:/; | |
| const EXTENSION_PATTERN = /\(([^)]+)\)\s*$/; | |
| const DUAL_DIALOGUE_MARKER = /\^\s*$/; | |
| const characterExtensions = ["V.O.", "O.S.", "CONT'D", "O.C.", "FILTER", "SUBTITLE"]; | |
| const KNOWN_EXTENSIONS = new Set(characterExtensions); | |
| // Everything below is pasted by the visitor, so it is untrusted input even | |
| // though it never leaves the browser. Without this, a scene heading like | |
| // `INT. ROOM <img src=x onerror=...>` becomes an active event handler and | |
| // can read the very draft this page promises to keep local. | |
| function esc(value) { | |
| return String(value).replace(/[&<>"']/g, (ch) => ({ | |
| "&": "&", "<": "<", ">": ">", '"': """, "'": "'" | |
| })[ch]); | |
| } | |
| // `lineStartsFountainStructure` β the parser exports this, and its action | |
| // collector breaks on it. A line that starts a structure therefore ENDS the | |
| // action block above it even when the line itself is finally classified as | |
| // action: `He waits.` followed by a bare `.` is two action nodes, because | |
| // `/^\./` breaks the collector and a lone dot is not a usable forced slug. | |
| // | |
| // The raw-vs-trimmed split below is the parser's, verbatim. Section, | |
| // synopsis and lyric are tested against the RAW line and so do not match | |
| // when indented; the rest are tested trimmed. Normalising that away here | |
| // would silently widen the rule. | |
| // `parseCharacterCue`'s name projection. Strip in the parser's order: | |
| // caret, then extension. The trim between them matters β removing the | |
| // caret from `JULES (V.O.) ^` leaves a trailing space, and the extension | |
| // pattern is anchored on `)$`, so without it the extension stays. | |
| // | |
| // The extension comes off ONLY when it is a KNOWN one. The parser keeps | |
| // an unrecognised parenthetical as part of the name β `@BOB (WHISPERING)` | |
| // is a character called `BOB (WHISPERING)`, not `BOB` with a stage | |
| // direction β so stripping it unconditionally would rename the speaker. The lookup | |
| // is on the UPPERCASED inner text because the parser upper-cases before | |
| // testing, which is what makes `(v.o.)` a known extension too. | |
| function speakerName(line) { | |
| const withoutCaret = line.trim().replace(/^@/, "").replace(DUAL_DIALOGUE_MARKER, "").trim(); | |
| const ext = EXTENSION_PATTERN.exec(withoutCaret); | |
| if (ext && KNOWN_EXTENSIONS.has((ext[1] ?? "").trim().toUpperCase())) { | |
| return withoutCaret.replace(EXTENSION_PATTERN, "").trim(); | |
| } | |
| return withoutCaret; | |
| } | |
| // Would the action collector have swallowed this line? It is NOT simply | |
| // the negation of `lineStartsFountainStructure`: forced action is checked | |
| // FIRST and continues the block with its `!` stripped. That carve-out is | |
| // deliberate: a `!` line inside a multi-line action continues that | |
| // paragraph instead of splitting it into several. | |
| function actionCollectorConsumes(line) { | |
| return FORCED_ACTION.test(line.trim()) || !lineStartsFountainStructure(line); | |
| } | |
| function lineStartsFountainStructure(line) { | |
| const trimmed = line.trim(); | |
| return ( | |
| SECTION_PATTERN.test(line) || | |
| SYNOPSIS_PATTERN.test(line) || | |
| PAGE_BREAK_PATTERN.test(trimmed) || | |
| LYRIC_PATTERN.test(line) || | |
| FORCED_SCENE_HEADING.test(trimmed) || | |
| FORCED_ACTION.test(trimmed) || | |
| FORCED_CHARACTER.test(trimmed) || | |
| FORCED_TRANSITION.test(trimmed) || | |
| CENTERED_PATTERN.test(trimmed) || | |
| NOTE_PATTERN.test(trimmed) || | |
| trimmed.startsWith("[[") | |
| ); | |
| } | |
| // Boneyard is a PRE-PASS in the parser (`extractBoneyard`), not a line | |
| // rule: `/* ... */` spans lines, and the interior is removed before any | |
| // classification runs. Testing `startsWith("/*")` per line would read | |
| // every interior line as action or dialogue. | |
| function extractBoneyard(rawLines) { | |
| const cleaned = []; | |
| // RUNS, not lines: the parser emits one boneyard node per `/* β¦ */` | |
| // pair however many lines it spans, so a four-line run is one element. | |
| let boneyardRuns = 0; | |
| let inBoneyard = false; | |
| // Body of the run currently open. Counting on the OPENER would report a | |
| // node the parser never creates: at the end of the file it keeps an | |
| // unclosed run only if the run has content, so a dangling `/*` with a | |
| // blank or whitespace-only body emits nothing. A run is therefore | |
| // counted when it CLOSES, and an unclosed one only if it has content. | |
| let openText = ""; | |
| for (const raw of rawLines) { | |
| if (inBoneyard) { | |
| const close = raw.indexOf("*/"); | |
| if (close === -1) { | |
| openText += `${raw}\n`; | |
| continue; | |
| } | |
| inBoneyard = false; | |
| openText = ""; | |
| boneyardRuns++; | |
| cleaned.push(raw.slice(close + 2)); | |
| continue; | |
| } | |
| const open = raw.indexOf("/*"); | |
| if (open === -1) { | |
| cleaned.push(raw); | |
| continue; | |
| } | |
| const before = raw.slice(0, open); | |
| if (before.trim()) cleaned.push(before); | |
| const afterOpen = raw.slice(open + 2); | |
| const close = afterOpen.indexOf("*/"); | |
| if (close === -1) { | |
| inBoneyard = true; | |
| // The parser seeds its buffer the same way β `afterOpen` plus the | |
| // newline β so `/* cut me` with no closer still has a body. | |
| openText = `${afterOpen}\n`; | |
| cleaned.push(""); | |
| continue; | |
| } | |
| boneyardRuns++; | |
| const rest = afterOpen.slice(close + 2); | |
| cleaned.push(rest.trim() ? rest : ""); | |
| } | |
| if (inBoneyard && openText.trim()) boneyardRuns++; | |
| return { lines: cleaned, boneyardRuns }; | |
| } | |
| // Title page is the other pre-pass (`parseTitlePage`). It reads only the | |
| // document PREFIX, ends at the first blank line, accepts any `Key: value` | |
| // rather than a fixed list, and consumes NOTHING if a line is neither a | |
| // key nor an indented continuation. A per-line keyword test would fire on | |
| // `Contact: he said` in the middle of a scene. | |
| function countTitlePageLines(lines) { | |
| let currentKey = ""; | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i] ?? ""; | |
| if (line.trim() === "") return i; | |
| if (TITLE_PAGE_KEY_PATTERN.test(line)) { | |
| currentKey = line.split(":")[0].trim(); | |
| continue; | |
| } | |
| if (currentKey && (line.startsWith(" ") || line.startsWith("\t"))) continue; | |
| return 0; | |
| } | |
| return currentKey ? lines.length : 0; | |
| } | |
| // One step of `parseFountain`'s classification loop: the same branches, in | |
| // the same order, returning the same two state bits. Order is load-bearing | |
| // in three places β `===` is a page break only because it is tested before | |
| // the synopsis rule, `>text<` is centered only because it is tested before | |
| // the forced transition, and a forced `.slug` / `!action` / `@cue` beats | |
| // every unforced rule below it. | |
| function step(rawLine, state, nextRaw) { | |
| const t = rawLine.trim(); | |
| // A `[[` note run consumes EVERY line until `]]` β including blank ones | |
| // β because the parser's inner loop does not re-classify inside the run. | |
| // This is checked before the blank test for that reason. Without it a | |
| // two-line note would read as two action lines: wrong kind AND wrong count. | |
| if (state.inNote) { | |
| return { | |
| kind: "note", | |
| continues: true, | |
| prevBlank: false, | |
| inDialogue: state.inDialogue, | |
| inNote: !NOTE_CLOSE_PATTERN.test(rawLine), | |
| }; | |
| } | |
| if (t === "") return { kind: "blank", prevBlank: true, inDialogue: false }; | |
| // A dialogue block's CONTINUATION lines are swallowed by the parser's | |
| // inner loop, which breaks ONLY on a blank line or a parenthetical β it | |
| // never re-enters the branch chain. So `#`, `~`, `[[β¦]]`, `>x<`, `!x`, | |
| // `.x`, `@x` and `===` inside dialogue are all dialogue, not the element | |
| // their shape suggests. | |
| // | |
| // The asymmetry is real and this keys on it: the FIRST line after a cue | |
| // DOES go through the full chain, so `@McClane` then `# hashtag` really | |
| // is a section. Only a line following an emitted DIALOGUE line is an | |
| // unconditional continuation β hence `prevKind`, not `inDialogue`. | |
| if ( | |
| state.prevKind === "dialogue" && | |
| !(/^\s*\(/.test(rawLine) && /\)\s*$/.test(t)) | |
| ) { | |
| return { kind: "dialogue", continues: true, prevBlank: false, inDialogue: true }; | |
| } | |
| if (PAGE_BREAK_PATTERN.test(t)) | |
| // A page break leaves prevBlank TRUE: the parser treats the line after | |
| // it as though a blank preceded it, so a cue can follow one directly. | |
| return { kind: "page_break", prevBlank: true, inDialogue: false }; | |
| // SECTION, SYNOPSIS and LYRIC take `rawLine`, NOT `t`. The parser | |
| // classifies these three with `line.match(...)` on the unmodified line | |
| // while every other branch here tests `line.trim()`, so an indented | |
| // ` # Act One`, ` = synopsis` or ` ~sing it` is ACTION upstream. Testing | |
| // them trimmed widens all three rules and the page counts a structural | |
| // element the parser never emitted. | |
| // | |
| // This is the same asymmetry `lineStartsFountainStructure` below | |
| // documents and mirrors. | |
| if (SECTION_PATTERN.test(rawLine)) | |
| return { kind: "section", prevBlank: false, inDialogue: false }; | |
| if (SYNOPSIS_PATTERN.test(rawLine)) | |
| return { kind: "synopsis", prevBlank: false, inDialogue: false }; | |
| // Notes and lyrics deliberately do NOT break dialogue context. | |
| if (NOTE_PATTERN.test(t)) | |
| return { kind: "note", prevBlank: false, inDialogue: state.inDialogue }; | |
| // `[[` with no closing `]]` on the same line opens a run. | |
| if (NOTE_OPEN_PATTERN.test(t) && !NOTE_PATTERN.test(t)) | |
| return { kind: "note", prevBlank: false, inDialogue: state.inDialogue, inNote: true }; | |
| if (LYRIC_PATTERN.test(rawLine)) | |
| return { kind: "lyric", prevBlank: false, inDialogue: state.inDialogue }; | |
| if (CENTERED_PATTERN.test(t)) | |
| return { kind: "centered", prevBlank: false, inDialogue: false }; | |
| // A forced slug carries its marker as SYNTAX, not as heading text: the | |
| // parser stores `.SNOWY EXTERIOR` as "SNOWY EXTERIOR". A bare `.` has no | |
| // text at all and falls through to the rules below, exactly as there. | |
| if (FORCED_SCENE_HEADING.test(t) && !t.startsWith("..")) { | |
| const forcedText = t.slice(1).trim(); | |
| if (forcedText) { | |
| return { | |
| kind: "scene_heading", | |
| text: forcedText, | |
| prevBlank: false, | |
| inDialogue: false, | |
| }; | |
| } | |
| } | |
| // `!` forces action even mid-dialogue, which is the whole point of it β | |
| // reached here, above the dialogue branch, exactly as the parser does. | |
| if (FORCED_ACTION.test(t)) | |
| return { kind: "action", prevBlank: false, inDialogue: false }; | |
| if (FORCED_CHARACTER.test(t)) | |
| return { kind: "character", prevBlank: false, inDialogue: true }; | |
| // An unforced slugline needs a blank line before it, or the document | |
| // start. Without that gate a line of dialogue reading `INT. ROOM is | |
| // printed on the card` would be counted as a scene. | |
| if ((state.prevBlank || state.atDocumentStart) && SCENE_HEADING_PATTERN.test(t)) | |
| return { kind: "scene_heading", prevBlank: false, inDialogue: false }; | |
| if ( | |
| state.prevBlank && | |
| TRANSITION_PATTERN.test(t) && | |
| (nextRaw === undefined || nextRaw.trim() === "") | |
| ) { | |
| return { kind: "transition", prevBlank: false, inDialogue: false }; | |
| } | |
| // Forced transition: `>FADE OUT.` β no `TO:`, no trailing `<`, and no | |
| // blank-line context required. | |
| if (FORCED_TRANSITION.test(t) && !CENTERED_PATTERN.test(t)) | |
| return { kind: "transition", prevBlank: false, inDialogue: false }; | |
| // A parenthetical is only a parenthetical INSIDE dialogue; standing | |
| // alone in action it is action. | |
| if (state.inDialogue && /^\s*\(/.test(rawLine) && /\)\s*$/.test(t)) | |
| return { kind: "parenthetical", prevBlank: false, inDialogue: true }; | |
| if (state.inDialogue) | |
| return { kind: "dialogue", prevBlank: false, inDialogue: true }; | |
| // An unforced cue needs a blank line before, a non-blank line after, and | |
| // the shared name shape. CHARACTER_PATTERN already admits the extension | |
| // and the dual-dialogue caret, so no pre-stripping and no length cap: | |
| // the token grammar is the length rule. It also rejects what a free | |
| // charset would accept β a connector-ended name like `THE CAR | |
| // EXPLODES.` is action, and a lone `Q` is a cue. | |
| if ( | |
| state.prevBlank && | |
| CHARACTER_PATTERN.test(t) && | |
| nextRaw !== undefined && | |
| nextRaw.trim() !== "" | |
| ) { | |
| return { kind: "character", prevBlank: false, inDialogue: true }; | |
| } | |
| return { kind: "action", prevBlank: false, inDialogue: false }; | |
| } | |
| function analyze(text) { | |
| // BOTH replacements, in the parser's order. A lone `\r` is a classic-Mac | |
| // line ending, and without that second pass such a file would read as a | |
| // single line β the whole screenplay one scene heading with carriage | |
| // returns inside it and no speakers at all. | |
| const raw = text | |
| .replace(/\r\n/g, "\n") | |
| .replace(/\r/g, "\n") | |
| .split("\n"); | |
| const { lines, boneyardRuns } = extractBoneyard(raw); | |
| const titleLines = countTitlePageLines(lines); | |
| const counts = {}; | |
| const scenes = []; | |
| const speakers = new Set(); | |
| if (boneyardRuns) counts.boneyard = boneyardRuns; | |
| // A title page is ONE element however many `Key: value` entries it | |
| // holds β `countTitlePageLines` returns the lines to SKIP, not a count. | |
| if (titleLines) counts.title_page = 1; | |
| // `prevBlank` starts TRUE only when a title page precedes the body, since | |
| // a title page ends with a blank line. A blanket `true` also opens the | |
| // character-cue branch on line one and reads a leading uppercase action | |
| // like `BLACK.` as a speaker, which the parser guards against the same | |
| // way. | |
| const state = { | |
| prevBlank: titleLines > 0, | |
| inDialogue: false, | |
| inNote: false, | |
| atDocumentStart: true, | |
| prevKind: "blank", | |
| }; | |
| for (let i = titleLines; i < lines.length; i++) { | |
| const line = lines[i] ?? ""; | |
| const next = lines[i + 1]; | |
| const result = step(line, state, next); | |
| // Count ELEMENTS, not lines. `parseFountain` emits ONE node for a | |
| // multi-line action or dialogue block, and the page labels these | |
| // "Elements" β so `JULES / First line. / Second line.` is one dialogue | |
| // element, not two. Action and dialogue are the only kinds the parser | |
| // collects across lines (its loops break on a blank, and on anything | |
| // `lineStartsFountainStructure` matches β which is exactly the set of | |
| // branches above these two); notes are collected by the run above. | |
| // A same-kind neighbour is only a CONTINUATION if the collector would | |
| // actually have swallowed it β merging on kind alone would report | |
| // `He waits.` + `.` as one action where the parser emits two, because | |
| // `/^\./` breaks the collector but a lone dot is not a usable forced | |
| // slug so it lands back on action. Dialogue continuations arrive with | |
| // `continues: true` already set: that loop breaks only on a blank line | |
| // or a parenthetical and never consults the structure predicate. | |
| const collectsAcrossLines = result.kind === "action" || result.kind === "dialogue"; | |
| const continues = | |
| result.continues === true || | |
| (collectsAcrossLines && state.prevKind === result.kind && actionCollectorConsumes(line)); | |
| if (!continues) counts[result.kind] = (counts[result.kind] || 0) + 1; | |
| if (result.kind === "scene_heading") scenes.push(result.text ?? line.trim()); | |
| if (result.kind === "character") speakers.add(speakerName(line)); | |
| state.prevBlank = result.prevBlank; | |
| state.inDialogue = result.inDialogue; | |
| state.inNote = result.inNote === true; | |
| state.prevKind = result.kind; | |
| // `atDocumentStart` is a one-shot: the parser snapshots it on the | |
| // first NON-BLANK body line and clears it immediately after. | |
| if (result.kind !== "blank") state.atDocumentStart = false; | |
| } | |
| return { counts, scenes, speakers: [...speakers] }; | |
| } | |
| function render(text) { | |
| const { counts, scenes, speakers } = analyze(text); | |
| const skip = new Set(["blank"]); | |
| const chips = Object.entries(counts) | |
| .filter(([k]) => !skip.has(k)) | |
| .sort((a, b) => b[1] - a[1]) | |
| .map(([k, n]) => `<span class="chip">${esc(k.replace("_", " "))} ${n}</span>`) | |
| .join(""); | |
| const sceneList = scenes.length | |
| ? scenes.map((s) => `<li class="scene">${esc(s)}</li>`).join("") | |
| : "<li class=\"note\">No scene headings yet.</li>"; | |
| const speakerList = speakers.length | |
| ? speakers.map((s) => `<li class="char">${esc(s)}</li>`).join("") | |
| : "<li class=\"note\">No speakers yet.</li>"; | |
| document.getElementById("out").innerHTML = ` | |
| <h2>Elements</h2> | |
| <div class="counts">${chips || "<span class=\"note\">Paste Fountain to begin.</span>"}</div> | |
| <h2>Scenes</h2> | |
| <ul>${sceneList}</ul> | |
| <h2>Speakers</h2> | |
| <ul>${speakerList}</ul> | |
| <p class="note">The sample scene is fictional.</p>`; | |
| } | |
| const src = document.getElementById("src"); | |
| src.value = FIXTURE; | |
| src.addEventListener("input", () => render(src.value)); | |
| render(src.value); | |
| </script> | |
| </body> | |
| </html> | |