openwriter 0.37.0 → 0.38.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,65 @@
1
+ /**
2
+ * Manifest loaders — shared by the HTTP routes (preview/export) and the MCP
3
+ * tools (compile_manuscript / export_manuscript). Resolving a manuscript doc by
4
+ * stable docId, listing manuscripts in the profile, and the export filename
5
+ * helper all live here so both surfaces read identical data.
6
+ *
7
+ * adr: adr/manuscript-engine.md
8
+ */
9
+ import { readdirSync, readFileSync } from 'fs';
10
+ import { join } from 'path';
11
+ import matter from 'gray-matter';
12
+ import { filenameByDocId } from '../documents.js';
13
+ import { readFrontmatter } from '../backlinks.js';
14
+ import { getDataDir } from '../helpers.js';
15
+ /** Every manuscript doc in the active profile (content_type === 'manuscript',
16
+ * not archived). Powers the always-on Manuscripts launcher + MCP listing. */
17
+ export function listManuscripts() {
18
+ const dir = getDataDir();
19
+ const out = [];
20
+ for (const f of readdirSync(dir)) {
21
+ if (!f.endsWith('.md') || f.startsWith('_'))
22
+ continue;
23
+ try {
24
+ const { data } = matter(readFileSync(join(dir, f), 'utf-8'));
25
+ if (data.content_type === 'manuscript' && !data.archivedAt) {
26
+ out.push({ docId: data.docId, title: data.title || f.replace(/\.md$/, ''), filename: f });
27
+ }
28
+ }
29
+ catch { /* skip unreadable */ }
30
+ }
31
+ return out.sort((a, b) => a.title.localeCompare(b.title));
32
+ }
33
+ /** Load a manifest doc by docId: its body (ordered pointer list) + render meta.
34
+ * Meta comes from the doc's frontmatter (round-trip-safe): the book title
35
+ * defaults to the doc title minus a trailing "— Manuscript"; author/output/trim
36
+ * come from manuscriptContext (set via the Settings panel, later). */
37
+ export function loadManifest(docId) {
38
+ if (!docId)
39
+ return null;
40
+ const filename = filenameByDocId(docId);
41
+ if (!filename)
42
+ return null;
43
+ const fm = readFrontmatter(filename);
44
+ if (!fm)
45
+ return null;
46
+ const ctx = (fm.data.manuscriptContext || {});
47
+ const title = (typeof ctx.title === 'string' && ctx.title) ||
48
+ String(fm.data.title || '').replace(/\s*[—–-]\s*manuscript\s*$/i, '') ||
49
+ 'Untitled';
50
+ return {
51
+ body: fm.content,
52
+ meta: {
53
+ title,
54
+ author: typeof ctx.author === 'string' ? ctx.author : undefined,
55
+ output: typeof ctx.output === 'string' ? ctx.output : undefined,
56
+ trim: typeof ctx.trim === 'string' ? ctx.trim : undefined,
57
+ // Render-time book style; defaults to 'spaced' downstream in bookCss().
58
+ paragraphStyle: ctx.paragraphStyle === 'indented' ? 'indented' : 'spaced',
59
+ },
60
+ };
61
+ }
62
+ /** Filesystem-safe filename stem from a book title. */
63
+ export function safeName(title) {
64
+ return (title || 'manuscript').replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, '_').slice(0, 100);
65
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Manuscript manifest parser — pure.
3
+ *
4
+ * The engine knows nothing about books. A "manuscript" is just a doc whose body
5
+ * is an ordered set of `doc:` pointers grouped under markdown headings, plus an
6
+ * optional `{{toc}}` directive. This module turns that body into a structured
7
+ * model the assembler walks. All book meaning (beats, welds, chapters-as-idea)
8
+ * is /book-writer skill convention, never here — here it is only docs + pointers.
9
+ *
10
+ * IMPORTANT — parse what the editor actually STORES, not idealized markdown.
11
+ * OpenWriter round-trips the manifest body through TipTap, which:
12
+ * - wraps a `doc:` href in angle brackets → `[text](<doc:ID>)`
13
+ * - collapses consecutive non-blank lines into one paragraph, so several
14
+ * pointers end up on a single line
15
+ * So pointers are extracted GLOBALLY within each heading's section (any number
16
+ * per line, angle-bracket tolerant), never anchored one-per-line. Title/author
17
+ * come from the doc's frontmatter (round-trip-safe), not a body block.
18
+ *
19
+ * adr: adr/manuscript-engine.md
20
+ */
21
+ const HEADING_RE = /^(#{1,6})\s+(.*\S)\s*$/;
22
+ // A pointer (`[text](doc:ID)`) OR the `{{toc}}` directive, matched anywhere.
23
+ // Tolerates the editor's angle-bracketed href and an optional #node/?query suffix.
24
+ const TOKEN_RE = /\[([^\]]+)\]\(\s*<?doc:([0-9a-f]{8})[^)>]*>?\s*\)|\{\{\s*toc\s*\}\}/gi;
25
+ // A legacy `::: meta … :::` block — stripped so it never renders as stray prose.
26
+ const META_BLOCK_RE = /:::\s*meta[\s\S]*?:::/gi;
27
+ export function parseManifest(body) {
28
+ const cleaned = body.replace(META_BLOCK_RE, '');
29
+ const lines = cleaned.split('\n');
30
+ // Headings are reliably block-level (their own line), so they define section
31
+ // boundaries; everything between two headings is that section's raw text.
32
+ const raw = [
33
+ { heading: null, level: 0, text: '' },
34
+ ];
35
+ for (const line of lines) {
36
+ const h = line.match(HEADING_RE);
37
+ if (h)
38
+ raw.push({ heading: h[2].trim(), level: h[1].length, text: '' });
39
+ else
40
+ raw[raw.length - 1].text += line + '\n';
41
+ }
42
+ const sections = [];
43
+ for (const s of raw) {
44
+ const items = [];
45
+ TOKEN_RE.lastIndex = 0;
46
+ let m;
47
+ while ((m = TOKEN_RE.exec(s.text)) !== null) {
48
+ if (m[2])
49
+ items.push({ kind: 'doc', text: m[1].trim(), docId: m[2].toLowerCase() });
50
+ else
51
+ items.push({ kind: 'toc' });
52
+ }
53
+ if (s.heading !== null || items.length > 0) {
54
+ sections.push({ heading: s.heading, level: s.level, items });
55
+ }
56
+ }
57
+ // Meta is supplied by the caller from frontmatter (see compileManuscript).
58
+ return { meta: {}, sections, warnings: [] };
59
+ }
@@ -0,0 +1,41 @@
1
+ function paragraphRules(style) {
2
+ if (style === 'indented') {
3
+ // Indented = first-line indent on EVERY paragraph (including the first) AND
4
+ // keep the inter-paragraph gap. Both cues, always separated — never a wall.
5
+ return `p { margin: 0 0 1.1em; text-indent: 1.5em; }
6
+ blockquote p, li p { text-indent: 0; }`;
7
+ }
8
+ // 'spaced' — the default: gap between paragraphs, no indent anywhere.
9
+ return `p { margin: 0 0 1.1em; text-indent: 0; }`;
10
+ }
11
+ export function bookCss(paragraphStyle = 'spaced') {
12
+ return `
13
+ body {
14
+ font-family: Georgia, 'Times New Roman', serif;
15
+ font-size: 1em;
16
+ line-height: 1.6;
17
+ color: #1a1a1a;
18
+ }
19
+ /* Headings never inherit/carry a text-indent — that belongs to body prose only. */
20
+ h1, h2, h3, h4, h5, h6 { font-weight: 600; line-height: 1.25; text-indent: 0; }
21
+ h1 { font-size: 1.7em; margin: 0 0 1em; page-break-before: always; break-before: page; }
22
+ h2 { font-size: 1.35em; margin: 1.6em 0 0.5em; }
23
+ h3 { font-size: 1.15em; margin: 1.3em 0 0.4em; }
24
+ ${paragraphRules(paragraphStyle)}
25
+ blockquote p, li p { margin-bottom: 0.5em; }
26
+ blockquote { margin: 1em 1.6em; font-style: italic; color: #444; }
27
+ ul, ol { margin: 1em 0; padding-left: 1.6em; }
28
+ li { margin: 0.2em 0; }
29
+ hr { border: none; border-top: 1px solid #bbb; width: 28%; margin: 2em auto; }
30
+ a { color: inherit; text-decoration: none; }
31
+ img { max-width: 100%; height: auto; }
32
+ code { font-family: Consolas, Menlo, monospace; font-size: 0.9em; }
33
+ pre { background: #f5f5f5; padding: 0.8em 1em; overflow-x: auto; font-size: 0.85em; }
34
+ sup.footnote-ref { font-size: 0.7em; }
35
+ .footnotes {
36
+ font-size: 0.85em; color: #444;
37
+ border-top: 1px solid #ccc; margin-top: 2.5em; padding-top: 0.5em;
38
+ }
39
+ .footnotes ol { padding-left: 1.4em; }
40
+ `;
41
+ }
@@ -0,0 +1,10 @@
1
+ import { renderBodyHtml } from './md.js';
2
+ export async function renderDocx(markdown, meta) {
3
+ const bodyHtml = renderBodyHtml(markdown, false);
4
+ const { default: HtmlToDocx } = await import('@turbodocx/html-to-docx');
5
+ const docx = await HtmlToDocx(bodyHtml, undefined, {
6
+ title: meta.title || 'Untitled',
7
+ margins: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
8
+ });
9
+ return Buffer.from(docx);
10
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * EPUB3 render — pure-JS, no external binary (OpenWriter ships via npx and can't
3
+ * assume pandoc/calibre are installed). The master markdown is split by `# `
4
+ * chapter into per-chapter XHTML documents and packaged into a valid EPUB3
5
+ * container with jszip. Per-chapter splitting means footnotes resolve at the end
6
+ * of each chapter (standard ebook placement); labels were already namespaced at
7
+ * assembly so nothing collides. adr: adr/manuscript-engine.md
8
+ */
9
+ import JSZip from 'jszip';
10
+ import { renderBodyHtml } from './md.js';
11
+ import { bookCss } from './css.js';
12
+ function escapeXml(s) {
13
+ return s
14
+ .replace(/&/g, '&amp;')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;')
17
+ .replace(/"/g, '&quot;')
18
+ .replace(/'/g, '&apos;');
19
+ }
20
+ /** Split master markdown into chapters at top-level `# ` headings (fence-aware). */
21
+ function splitChapters(markdown) {
22
+ const lines = markdown.split('\n');
23
+ const chunks = [];
24
+ let cur = null;
25
+ let fence = null;
26
+ const firstHeading = (ls) => {
27
+ for (const l of ls) {
28
+ const m = l.match(/^#{1,6}\s+(.*\S)\s*$/);
29
+ if (m)
30
+ return m[1].trim();
31
+ }
32
+ return '';
33
+ };
34
+ for (const line of lines) {
35
+ const f = line.match(/^\s*(```+|~~~+)/);
36
+ if (f) {
37
+ const mark = f[1][0];
38
+ if (fence === null)
39
+ fence = mark;
40
+ else if (fence === mark)
41
+ fence = null;
42
+ }
43
+ const isChapterHeading = fence === null && /^# .+/.test(line);
44
+ if (isChapterHeading) {
45
+ if (cur)
46
+ chunks.push(cur);
47
+ cur = { title: line.replace(/^#\s+/, '').trim(), lines: [line] };
48
+ }
49
+ else {
50
+ if (!cur)
51
+ cur = { title: '', lines: [] };
52
+ cur.lines.push(line);
53
+ }
54
+ }
55
+ if (cur)
56
+ chunks.push(cur);
57
+ return chunks
58
+ .filter((c) => c.lines.some((l) => l.trim() !== ''))
59
+ .map((c) => ({ title: c.title || firstHeading(c.lines) || 'Front Matter', md: c.lines.join('\n') }));
60
+ }
61
+ function chapterXhtml(title, bodyHtml) {
62
+ return `<?xml version="1.0" encoding="utf-8"?>
63
+ <!DOCTYPE html>
64
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en">
65
+ <head>
66
+ <meta charset="utf-8"/>
67
+ <title>${escapeXml(title)}</title>
68
+ <link rel="stylesheet" type="text/css" href="style.css"/>
69
+ </head>
70
+ <body>
71
+ ${bodyHtml}
72
+ </body>
73
+ </html>`;
74
+ }
75
+ export async function renderEpub(markdown, meta) {
76
+ const title = meta.title || 'Untitled';
77
+ const author = meta.author || '';
78
+ const lang = meta.language || 'en';
79
+ const modified = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
80
+ const bookId = 'urn:uuid:' + deterministicUuid(title + '|' + author);
81
+ const chapters = splitChapters(markdown).map((c, i) => {
82
+ const n = String(i + 1).padStart(3, '0');
83
+ return {
84
+ id: `ch${n}`,
85
+ href: `ch${n}.xhtml`,
86
+ title: c.title,
87
+ xhtml: chapterXhtml(c.title, renderBodyHtml(c.md, true)),
88
+ };
89
+ });
90
+ const manifestItems = [
91
+ '<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>',
92
+ '<item id="css" href="style.css" media-type="text/css"/>',
93
+ ...chapters.map((c) => `<item id="${c.id}" href="${c.href}" media-type="application/xhtml+xml"/>`),
94
+ ].join('\n ');
95
+ const spineItems = chapters.map((c) => `<itemref idref="${c.id}"/>`).join('\n ');
96
+ const opf = `<?xml version="1.0" encoding="utf-8"?>
97
+ <package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid">
98
+ <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
99
+ <dc:identifier id="bookid">${escapeXml(bookId)}</dc:identifier>
100
+ <dc:title>${escapeXml(title)}</dc:title>
101
+ <dc:language>${escapeXml(lang)}</dc:language>
102
+ ${author ? `<dc:creator>${escapeXml(author)}</dc:creator>` : ''}
103
+ <meta property="dcterms:modified">${modified}</meta>
104
+ </metadata>
105
+ <manifest>
106
+ ${manifestItems}
107
+ </manifest>
108
+ <spine>
109
+ ${spineItems}
110
+ </spine>
111
+ </package>`;
112
+ const navList = chapters.map((c) => `<li><a href="${c.href}">${escapeXml(c.title)}</a></li>`).join('\n ');
113
+ const nav = `<?xml version="1.0" encoding="utf-8"?>
114
+ <!DOCTYPE html>
115
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en">
116
+ <head><meta charset="utf-8"/><title>Contents</title></head>
117
+ <body>
118
+ <nav epub:type="toc" id="toc">
119
+ <h1>Contents</h1>
120
+ <ol>
121
+ ${navList}
122
+ </ol>
123
+ </nav>
124
+ </body>
125
+ </html>`;
126
+ const containerXml = `<?xml version="1.0" encoding="utf-8"?>
127
+ <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
128
+ <rootfiles>
129
+ <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
130
+ </rootfiles>
131
+ </container>`;
132
+ const zip = new JSZip();
133
+ // EPUB requires `mimetype` to be the first entry and stored (uncompressed).
134
+ zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
135
+ zip.file('META-INF/container.xml', containerXml);
136
+ zip.file('OEBPS/content.opf', opf);
137
+ zip.file('OEBPS/nav.xhtml', nav);
138
+ zip.file('OEBPS/style.css', bookCss(meta.paragraphStyle));
139
+ for (const c of chapters)
140
+ zip.file(`OEBPS/${c.href}`, c.xhtml);
141
+ return zip.generateAsync({ type: 'nodebuffer', mimeType: 'application/epub+zip', compression: 'DEFLATE' });
142
+ }
143
+ /** Stable uuid-shaped string from a seed (no randomness, so identical input → identical id). */
144
+ function deterministicUuid(seed) {
145
+ let h = 0x811c9dc5;
146
+ const bytes = [];
147
+ for (let i = 0; i < 16; i++) {
148
+ for (let j = 0; j < seed.length; j++) {
149
+ h ^= seed.charCodeAt(j) + i * 131;
150
+ h = Math.imul(h, 0x01000193) >>> 0;
151
+ }
152
+ bytes.push(h & 0xff);
153
+ }
154
+ const hex = bytes.map((b) => b.toString(16).padStart(2, '0'));
155
+ return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`;
156
+ }
@@ -0,0 +1,91 @@
1
+ import { renderBodyHtml } from './md.js';
2
+ import { bookCss } from './css.js';
3
+ function escapeHtml(s) {
4
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
5
+ }
6
+ /** Screen palette for the preview surface. Light = cream paper; dark reuses the
7
+ * app's neutral dark tokens (never blue). Only the in-app preview uses dark. */
8
+ function palette(mode) {
9
+ if (mode === 'dark') {
10
+ return {
11
+ bg: '#1a1a1a', // app --bg-primary (dark)
12
+ text: '#d4d2cb',
13
+ heading: '#f0f0f0',
14
+ byline: '#9b9b95',
15
+ blockquote: '#b3b1a8',
16
+ hr: '#3a3a3a',
17
+ pre: '#242424',
18
+ footnote: '#a9a79f',
19
+ footnoteBorder: '#3a3a3a',
20
+ // App scrollbar tokens (data-mode="dark") — colors-base.css.
21
+ scrollThumb: 'rgba(255, 255, 255, 0.15)',
22
+ scrollThumbHover: 'rgba(255, 255, 255, 0.25)',
23
+ };
24
+ }
25
+ return {
26
+ bg: '#faf9f6',
27
+ text: '#1a1a1a',
28
+ heading: '#1a1a1a',
29
+ byline: '#666',
30
+ blockquote: '#444',
31
+ hr: '#bbb',
32
+ pre: '#f5f5f5',
33
+ footnote: '#444',
34
+ footnoteBorder: '#ccc',
35
+ // App scrollbar tokens (light) — colors-base.css.
36
+ scrollThumb: 'rgba(0, 0, 0, 0.2)',
37
+ scrollThumbHover: 'rgba(0, 0, 0, 0.35)',
38
+ };
39
+ }
40
+ /** Full standalone HTML document for the whole manuscript (preview / PDF). */
41
+ export function renderBookHtml(markdown, meta, mode = 'light') {
42
+ const body = renderBodyHtml(markdown, false);
43
+ const title = meta.title || 'Untitled';
44
+ const byline = meta.author ? `<p class="book-byline">${escapeHtml(meta.author)}</p>` : '';
45
+ const c = palette(mode);
46
+ return `<!DOCTYPE html>
47
+ <html lang="en">
48
+ <head>
49
+ <meta charset="UTF-8">
50
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
51
+ <title>${escapeHtml(title)}</title>
52
+ <style>
53
+ *, *::before, *::after { box-sizing: border-box; }
54
+ html, body { margin: 0; padding: 0; }
55
+ body { background: ${c.bg}; color: ${c.text}; }
56
+ /* Match the app's scrollbar convention (App.css) — thin, 8px, token-colored.
57
+ Track is the page bg (not transparent) so the iframe element behind it never
58
+ shows through as a white sliver. */
59
+ * { scrollbar-width: thin; scrollbar-color: ${c.scrollThumb} ${c.bg}; }
60
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
61
+ ::-webkit-scrollbar-track { background: ${c.bg}; }
62
+ ::-webkit-scrollbar-thumb { background: ${c.scrollThumb}; border-radius: 4px; }
63
+ ::-webkit-scrollbar-thumb:hover { background: ${c.scrollThumbHover}; }
64
+ .book-page {
65
+ max-width: 38em; margin: 0 auto; padding: 3em 1.5em 6em;
66
+ }
67
+ .book-title { text-align: center; font-size: 2em; margin: 1em 0 0.3em; page-break-before: avoid; color: ${c.heading}; }
68
+ .book-byline { text-align: center; color: ${c.byline}; font-style: italic; margin: 0 0 3.5em; }
69
+ /* Title page → first chapter needs real separation. The byline (when present)
70
+ supplies the bottom gap; with no byline the title sits right on the chapter
71
+ heading, so give that first heading its own top air. */
72
+ .book-title + h1 { margin-top: 3em; }
73
+ ${bookCss(meta.paragraphStyle)}
74
+ body { color: ${c.text}; }
75
+ h1, h2, h3, h4, h5, h6 { color: ${c.heading}; }
76
+ blockquote { color: ${c.blockquote}; }
77
+ hr { border-top-color: ${c.hr}; }
78
+ pre { background: ${c.pre}; }
79
+ .footnotes { color: ${c.footnote}; border-top-color: ${c.footnoteBorder}; }
80
+ .book-page h1:first-of-type { page-break-before: avoid; }
81
+ </style>
82
+ </head>
83
+ <body>
84
+ <div class="book-page">
85
+ <h1 class="book-title">${escapeHtml(title)}</h1>
86
+ ${byline}
87
+ ${body}
88
+ </div>
89
+ </body>
90
+ </html>`;
91
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared markdown-it instance for manuscript rendering — matches the
3
+ * configuration in export-routes.ts (the single-doc export path) so manuscript
4
+ * output is consistent with OpenWriter's existing exports: same flavor, same
5
+ * footnote plugin. `xhtml` mode self-closes void elements for EPUB's XHTML.
6
+ *
7
+ * adr: adr/manuscript-engine.md
8
+ */
9
+ import MarkdownIt from 'markdown-it';
10
+ import markdownItIns from 'markdown-it-ins';
11
+ import markdownItMark from 'markdown-it-mark';
12
+ import markdownItSub from 'markdown-it-sub';
13
+ import markdownItSup from 'markdown-it-sup';
14
+ import markdownItFootnote from 'markdown-it-footnote';
15
+ export function createMd(xhtml = false) {
16
+ const md = new MarkdownIt({ linkify: false, html: true, xhtmlOut: xhtml });
17
+ md.enable('strikethrough');
18
+ md.use(markdownItIns);
19
+ md.use(markdownItMark);
20
+ md.use(markdownItSub);
21
+ md.use(markdownItSup);
22
+ md.use(markdownItFootnote);
23
+ // Chapter anchors: every top-level heading (h1 = a chapter; beats are demoted
24
+ // to h2+) gets a stable sequential id `ch-N`. The assembler's {{toc}} links to
25
+ // these (#ch-N) and the preview's chapter tick-rail also keys off them, so the
26
+ // contents page and the side navigator point at the same targets.
27
+ md.core.ruler.push('chapter_ids', (state) => {
28
+ let n = 0;
29
+ for (const tok of state.tokens) {
30
+ if (tok.type === 'heading_open' && tok.tag === 'h1') {
31
+ n += 1;
32
+ tok.attrSet('id', `ch-${n}`);
33
+ }
34
+ }
35
+ });
36
+ return md;
37
+ }
38
+ /** Render a markdown string to an HTML fragment. */
39
+ export function renderBodyHtml(markdown, xhtml = false) {
40
+ return createMd(xhtml).render(markdown);
41
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Manuscript doc resolution — disk I/O.
3
+ *
4
+ * Maps each manifest doc-pointer (docId) to its current on-disk body. Resolution
5
+ * is by stable docId (rename-safe): filenameByDocId → readFrontmatter().content.
6
+ * The body read from disk is the canonical, ACCEPTED text — pending agent
7
+ * suggestions live in the `_pending/<docId>.json` sidecar, never in the .md body
8
+ * — so a compiled manuscript never ships an un-accepted edit. A docId resolves
9
+ * regardless of which workspace/container the doc lives in (location-independent),
10
+ * which is why a transition doc written "in some random folder" links fine.
11
+ *
12
+ * adr: adr/manuscript-engine.md
13
+ */
14
+ import { filenameByDocId } from '../documents.js';
15
+ import { readFrontmatter } from '../backlinks.js';
16
+ export function resolveManifestDocs(manifest) {
17
+ const bodyMap = new Map();
18
+ const warnings = [];
19
+ for (const section of manifest.sections) {
20
+ for (const item of section.items) {
21
+ if (item.kind !== 'doc' || !item.docId)
22
+ continue;
23
+ if (bodyMap.has(item.docId))
24
+ continue;
25
+ const filename = filenameByDocId(item.docId);
26
+ if (!filename) {
27
+ warnings.push(`docId ${item.docId} not found (${item.text ?? ''})`);
28
+ continue;
29
+ }
30
+ const fm = readFrontmatter(filename);
31
+ if (!fm) {
32
+ warnings.push(`Could not read ${filename} for docId ${item.docId}`);
33
+ continue;
34
+ }
35
+ bodyMap.set(item.docId, {
36
+ title: fm.data.title || item.text || 'Untitled',
37
+ body: fm.content,
38
+ });
39
+ }
40
+ }
41
+ return { bodyMap, warnings };
42
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Manuscript routes: compile + render a manuscript doc to a file or preview.
3
+ *
4
+ * GET /api/manuscript/preview?docId=<id> -> book HTML (iframe source)
5
+ * GET /api/manuscript/export?docId=<id>&format=epub -> epub | docx | html | md
6
+ *
7
+ * Both resolve the manifest doc by stable docId, compile it (resolve pointers +
8
+ * assemble), then render. Preview and export share the same compile() path, so
9
+ * the preview can never disagree with the exported book. adr: adr/manuscript-engine.md
10
+ */
11
+ import { Router } from 'express';
12
+ import { compileManuscript, renderBookHtml, renderEpub, renderDocx, } from './manuscript/index.js';
13
+ import { listManuscripts, loadManifest, safeName } from './manuscript/load.js';
14
+ export function createManuscriptRouter() {
15
+ const router = Router();
16
+ // Always-on launcher list for the right rail — every manuscript in the profile.
17
+ router.get('/api/manuscripts', (_req, res) => {
18
+ res.json({ manuscripts: listManuscripts() });
19
+ });
20
+ router.get('/api/manuscript/preview', (req, res) => {
21
+ const ms = loadManifest(String(req.query.docId || ''));
22
+ if (!ms)
23
+ return res.status(404).json({ error: 'manuscript doc not found' });
24
+ const { markdown, meta } = compileManuscript(ms.body, ms.meta);
25
+ // The manuscript compose view frames this preview SAME-ORIGIN. The global
26
+ // security gate sends X-Frame-Options: DENY + frame-ancestors 'none', which
27
+ // blocks the iframe entirely. Relax BOTH to same-origin for this route only —
28
+ // a self-contained book page, framed by the app itself, nothing external.
29
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
30
+ res.setHeader('Content-Security-Policy', "default-src 'self'; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-ancestors 'self'");
31
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
32
+ // Screen light/dark follows the app's Appearance setting, passed by the
33
+ // compose view. Export (below) never does — the book ships print-light.
34
+ const mode = req.query.mode === 'dark' ? 'dark' : 'light';
35
+ res.send(renderBookHtml(markdown, meta, mode));
36
+ });
37
+ router.get('/api/manuscript/export', async (req, res) => {
38
+ const ms = loadManifest(String(req.query.docId || ''));
39
+ if (!ms)
40
+ return res.status(404).json({ error: 'manuscript doc not found' });
41
+ const format = String(req.query.format || 'epub').toLowerCase();
42
+ const result = compileManuscript(ms.body, ms.meta);
43
+ const name = safeName(result.meta.title || '');
44
+ try {
45
+ switch (format) {
46
+ case 'epub': {
47
+ const buf = await renderEpub(result.markdown, result.meta);
48
+ res.setHeader('Content-Type', 'application/epub+zip');
49
+ res.setHeader('Content-Disposition', `attachment; filename="${name}.epub"`);
50
+ return res.send(buf);
51
+ }
52
+ case 'docx': {
53
+ const buf = await renderDocx(result.markdown, result.meta);
54
+ res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
55
+ res.setHeader('Content-Disposition', `attachment; filename="${name}.docx"`);
56
+ return res.send(buf);
57
+ }
58
+ case 'html': {
59
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
60
+ res.setHeader('Content-Disposition', `attachment; filename="${name}.html"`);
61
+ return res.send(renderBookHtml(result.markdown, result.meta));
62
+ }
63
+ case 'md': {
64
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
65
+ res.setHeader('Content-Disposition', `attachment; filename="${name}.md"`);
66
+ return res.send(result.markdown);
67
+ }
68
+ default:
69
+ return res.status(400).json({ error: `Unknown format: ${format}. Use epub, docx, html, or md.` });
70
+ }
71
+ }
72
+ catch (err) {
73
+ console.error('[Manuscript] export error:', err?.message);
74
+ return res.status(500).json({ error: 'Manuscript export failed' });
75
+ }
76
+ });
77
+ return router;
78
+ }