openwriter 0.37.1 → 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.
@@ -10,8 +10,8 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-BBEdpqBq.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-Dz0iuWDM.css">
13
+ <script type="module" crossorigin src="/assets/index-C_Zb7mUx.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-2miZWC8D.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -15,6 +15,7 @@ export function resolveTypeMeta(type, url) {
15
15
  case 'linkedin': return { content_type: 'linkedin', linkedinContext: { active: true } };
16
16
  case 'newsletter': return { content_type: 'newsletter', newsletterContext: { active: true } };
17
17
  case 'blog': return { content_type: 'blog', blogContext: { active: true } };
18
+ case 'manuscript': return { content_type: 'manuscript', manuscriptContext: { active: true } };
18
19
  default: return undefined;
19
20
  }
20
21
  }
@@ -128,9 +128,9 @@ export function canonicalizeIdentifier(id) {
128
128
  * Canonicalizes both sides of the comparison so that mixed separators,
129
129
  * drive-letter case, and symlink-resolved variants of the same file all
130
130
  * classify consistently. The pre-canonicalization version compared raw
131
- * strings via `startsWith`, which let `C:/Users/.../data-dir/foo.md`
131
+ * strings via `startsWith`, which let `C:/Users/me/data-dir/foo.md`
132
132
  * be classified as external on Windows because `getDataDir()` returns
133
- * `C:\Users\...\data-dir` (different separators).
133
+ * `C:\Users\me\data-dir` (different separators).
134
134
  *
135
135
  * adr: adr/path-canonicalization.md
136
136
  */
@@ -27,6 +27,7 @@ import { removeDocFromAllWorkspaces } from './workspaces.js';
27
27
  import { resolveDocPath, getActiveProfile, setActiveProfile, listProfiles, createProfile, deleteProfile, listTrashedProfiles, restoreProfile, saveConfig, readConfig } from './helpers.js';
28
28
  import { createImageRouter } from './image-upload.js';
29
29
  import { createExportRouter } from './export-routes.js';
30
+ import { createManuscriptRouter } from './manuscript-routes.js';
30
31
  import { createConnectionRouter } from './connection-routes.js';
31
32
  import { createSchedulerRouter } from './scheduler-routes.js';
32
33
  import { createBillingRouter } from './billing-routes.js';
@@ -234,6 +235,8 @@ export async function startHttpServer(options = {}) {
234
235
  // if installed). SyncButton + SyncSetupModal hit /api/sync/* unchanged.
235
236
  // Mount export routes
236
237
  app.use(createExportRouter());
238
+ // Mount manuscript compile/render routes (book binding -> epub/docx/html/md)
239
+ app.use(createManuscriptRouter());
237
240
  // Mount connection CRUD + profile binding routes
238
241
  app.use(createConnectionRouter());
239
242
  // Mount scheduler proxy routes
@@ -5,7 +5,7 @@
5
5
  * - One JSON-per-line file at `~/.openwriter/profiles/<profile>/events.log`.
6
6
  * One file (not per-category) so grep/jq covers everything in one place.
7
7
  * - Levels: error < warn < info < debug < trace. Default `error` — safe
8
- * for public installs. Travis's machine overrides to `trace` via
8
+ * for public installs. The operator's machine overrides to `trace` via
9
9
  * `~/.openwriter/log-config.json`.
10
10
  * - Document text is redacted unless `includeText: true` is set in the
11
11
  * config file. Public users never have text content land in logs.
@@ -18,7 +18,7 @@
18
18
  * restart. The current `diagnostic.log` proved this works.
19
19
  *
20
20
  * Public users see: errors only, no text, small file, share freely for
21
- * bug reports without privacy concern. Travis sees: everything, with text.
21
+ * bug reports without privacy concern. The operator sees: everything, with text.
22
22
  *
23
23
  * adr: adr/logging-system.md
24
24
  */
@@ -0,0 +1,128 @@
1
+ /** Book heading level for a manifest heading. The manifest owns the book's whole
2
+ * heading hierarchy: `## chapter` → book h1, `### section` → h2, `#### ` → h3,
3
+ * etc. (level − 1, clamped ≥1). `## = chapter` keeps the existing convention, so
4
+ * any current `##`-only manifest renders byte-identically; deeper levels are
5
+ * purely additive. A `#` and a `##` both map to h1 (clamp). */
6
+ function bookLevel(manifestLevel) {
7
+ return Math.max(1, manifestLevel - 1);
8
+ }
9
+ export function assemble(manifest, bodyMap) {
10
+ const warnings = [];
11
+ // Chapters-only navigation: TOC + tick-rail list book-h1 headings (manifest
12
+ // level ≤ 2), in document order, so they align with md.ts's `#ch-N` anchors.
13
+ // Sub-section headers still render in the book (and the EPUB's own nav), just
14
+ // not in the chapter contents list.
15
+ const tocEntries = manifest.sections
16
+ .filter((s) => s.heading && bookLevel(s.level) === 1)
17
+ .map((s) => s.heading);
18
+ let ordinal = 0; // per-doc footnote namespace counter
19
+ const out = [];
20
+ for (const section of manifest.sections) {
21
+ // Emit EVERY manifest heading at its mapped book level — including a
22
+ // structural divider with no beats under it (a "Part" line, a section title
23
+ // between beats). The book's heading structure is whatever the manifest says.
24
+ if (section.heading) {
25
+ const lvl = bookLevel(section.level);
26
+ out.push(`${'#'.repeat(lvl)} ${section.heading}`, '');
27
+ }
28
+ for (const item of section.items) {
29
+ if (item.kind === 'toc') {
30
+ const toc = renderToc(tocEntries);
31
+ if (toc)
32
+ out.push(toc, '');
33
+ continue;
34
+ }
35
+ const resolved = item.docId ? bodyMap.get(item.docId) : undefined;
36
+ if (!resolved) {
37
+ warnings.push(`Unresolved docId ${item.docId} (${item.text ?? ''}) — not in workspace?`);
38
+ out.push(`> **[unresolved: ${item.text ?? item.docId}]**`, '');
39
+ continue;
40
+ }
41
+ ordinal += 1;
42
+ let body = resolved.body.trim();
43
+ body = namespaceFootnotes(body, ordinal);
44
+ // Nest a beat's own headings just below its enclosing manifest heading:
45
+ // under a book-h{N} section, the beat's internal h1 becomes h{N+1}.
46
+ // Pre-heading / front-matter beats (no enclosing heading) keep their structure.
47
+ if (section.heading)
48
+ body = demoteHeadings(body, bookLevel(section.level));
49
+ out.push(body, '');
50
+ }
51
+ }
52
+ const markdown = out.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
53
+ return { markdown, warnings };
54
+ }
55
+ function renderToc(entries) {
56
+ if (entries.length === 0)
57
+ return '';
58
+ // Link each entry to its chapter anchor (#ch-N). md.ts stamps the matching id
59
+ // on the Nth h1, in the same order chapters are emitted here, so they align.
60
+ return ['## Contents', '', ...entries.map((e, i) => `- [${e}](#ch-${i + 1})`)].join('\n');
61
+ }
62
+ /**
63
+ * Make a doc's footnote labels globally unique by namespacing them with an
64
+ * ordinal. Handles both references `[^x]` and definitions `[^x]:`, numeric or
65
+ * mnemonic. Only labels that are actually DEFINED in this doc are remapped, so
66
+ * stray `[^...]`-looking text isn't touched. Fenced code blocks are skipped.
67
+ */
68
+ function namespaceFootnotes(body, ordinal) {
69
+ const defRe = /^\s*\[\^([^\]]+)\]:/;
70
+ const labels = new Set();
71
+ forEachLineOutsideFence(body, (line) => {
72
+ const m = line.match(defRe);
73
+ if (m)
74
+ labels.add(m[1]);
75
+ });
76
+ if (labels.size === 0)
77
+ return body;
78
+ return mapLinesOutsideFence(body, (line) => line.replace(/\[\^([^\]]+)\]/g, (full, label) => labels.has(label) ? `[^fn${ordinal}-${label}]` : full));
79
+ }
80
+ /** Shift ATX heading levels down by `by`, clamped at h6, skipping code fences. */
81
+ function demoteHeadings(body, by) {
82
+ if (by <= 0)
83
+ return body;
84
+ return mapLinesOutsideFence(body, (line) => {
85
+ const h = line.match(/^(#{1,6})(\s+)(.*)$/);
86
+ if (!h)
87
+ return line;
88
+ const newLevel = Math.min(6, h[1].length + by);
89
+ return '#'.repeat(newLevel) + h[2] + h[3];
90
+ });
91
+ }
92
+ // ── fenced-code-aware line helpers ──────────────────────────────────────────
93
+ function isFenceLine(line) {
94
+ const m = line.match(/^\s*(```+|~~~+)/);
95
+ return m ? m[1][0] : null;
96
+ }
97
+ function forEachLineOutsideFence(body, fn) {
98
+ let fence = null;
99
+ for (const line of body.split('\n')) {
100
+ const f = isFenceLine(line);
101
+ if (f) {
102
+ if (fence === null)
103
+ fence = f;
104
+ else if (fence === f)
105
+ fence = null;
106
+ continue;
107
+ }
108
+ if (fence === null)
109
+ fn(line);
110
+ }
111
+ }
112
+ function mapLinesOutsideFence(body, fn) {
113
+ let fence = null;
114
+ const lines = body.split('\n');
115
+ for (let i = 0; i < lines.length; i++) {
116
+ const f = isFenceLine(lines[i]);
117
+ if (f) {
118
+ if (fence === null)
119
+ fence = f;
120
+ else if (fence === f)
121
+ fence = null;
122
+ continue;
123
+ }
124
+ if (fence === null)
125
+ lines[i] = fn(lines[i]);
126
+ }
127
+ return lines.join('\n');
128
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Manuscript compiler — orchestrator.
3
+ *
4
+ * compileManuscript(manifestBody) → one assembled master markdown document +
5
+ * any warnings (unresolved pointers, unrecognized manifest lines). Rendering to
6
+ * EPUB / DOCX / PDF / HTML is a separate adapter layer (later phase); every
7
+ * render target consumes this single master markdown, so preview and export are
8
+ * the same pipeline differing only in the final step.
9
+ *
10
+ * adr: adr/manuscript-engine.md
11
+ */
12
+ import { parseManifest } from './parse.js';
13
+ import { resolveManifestDocs } from './resolve.js';
14
+ import { assemble } from './assemble.js';
15
+ export function compileManuscript(manifestBody, meta = {}) {
16
+ const manifest = parseManifest(manifestBody);
17
+ const { bodyMap, warnings: resolveWarnings } = resolveManifestDocs(manifest);
18
+ const { markdown, warnings: assembleWarnings } = assemble(manifest, bodyMap);
19
+ return {
20
+ markdown,
21
+ // Caller meta (from the doc's frontmatter / manuscriptContext) wins; the body
22
+ // no longer carries a meta block (round-trip-fragile).
23
+ meta: { ...manifest.meta, ...meta },
24
+ warnings: [...manifest.warnings, ...resolveWarnings, ...assembleWarnings],
25
+ };
26
+ }
27
+ export { parseManifest } from './parse.js';
28
+ export { assemble } from './assemble.js';
29
+ // Render adapters — every target consumes the one master markdown compileManuscript
30
+ // produces, so preview (html) and export (epub/docx) are the same pipeline.
31
+ export { bookCss } from './render/css.js';
32
+ export { renderBodyHtml } from './render/md.js';
33
+ export { renderBookHtml } from './render/html.js';
34
+ export { renderEpub } from './render/epub.js';
35
+ export { renderDocx } from './render/docx.js';
@@ -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
+ }