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.
@@ -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>
@@ -4,13 +4,45 @@
4
4
  */
5
5
  import { readConfig, getActiveProfile } from './helpers.js';
6
6
  const DEFAULT_API_URL = 'https://publish.openwriter.io';
7
+ // MCP-6: the platform Bearer key is attached to every request sent to
8
+ // `api-url`. Because plugin config is writable (and was writable unauth before
9
+ // the MCP-5 gate), a hijacked `api-url` could redirect the next authenticated
10
+ // call to an attacker host and exfiltrate the key. We PIN the destination: the
11
+ // Bearer key only ever ships to an OpenWriter-controlled host (or loopback for
12
+ // local platform dev). Anything else falls back to the default host.
13
+ const ALLOWED_PUBLISH_HOSTS = new Set(['publish.openwriter.io', 'openwriter.io']);
14
+ /** True when `url` is a safe destination for the platform Bearer key. */
15
+ export function isAllowedPublishApiUrl(url) {
16
+ let u;
17
+ try {
18
+ u = new URL(url);
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ const host = u.hostname.toLowerCase();
24
+ // Local platform development over loopback (either scheme).
25
+ if (host === 'localhost' || host === '127.0.0.1' || host === '::1')
26
+ return true;
27
+ // Production: HTTPS only, OpenWriter-controlled host (incl. subdomains).
28
+ if (u.protocol !== 'https:')
29
+ return false;
30
+ return ALLOWED_PUBLISH_HOSTS.has(host) || host.endsWith('.openwriter.io');
31
+ }
7
32
  /** Get API key and URL from plugin config */
8
33
  function getPublishConfig() {
9
34
  const config = readConfig();
10
35
  const publishConfig = config.plugins?.['@openwriter/plugin-publish']?.config || {};
36
+ const rawUrl = publishConfig['api-url'] || DEFAULT_API_URL;
37
+ // Pin to an allowed destination — never let a redirected api-url carry the
38
+ // Bearer key off-host. MCP-6.
39
+ const apiUrl = isAllowedPublishApiUrl(rawUrl) ? rawUrl : DEFAULT_API_URL;
40
+ if (apiUrl !== rawUrl) {
41
+ console.warn('[connections] publish api-url not on allowlist — pinned to default host');
42
+ }
11
43
  return {
12
44
  apiKey: publishConfig['api-key'] || '',
13
- apiUrl: publishConfig['api-url'] || DEFAULT_API_URL,
45
+ apiUrl,
14
46
  };
15
47
  }
16
48
  /** Authenticated fetch to the platform API */
@@ -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
  */
@@ -7,9 +7,151 @@ import multer from 'multer';
7
7
  import { existsSync, mkdirSync } from 'fs';
8
8
  import { join, extname } from 'path';
9
9
  import { randomUUID } from 'crypto';
10
+ import { isIP } from 'net';
11
+ import { lookup } from 'dns/promises';
10
12
  import { getDataDir, ensureDataDir } from './helpers.js';
11
13
  import express from 'express';
12
14
  function getImagesDir() { return join(getDataDir(), '_images'); }
15
+ // ── SSRF guard for /api/download-image (MCP-8) ──────────────────────────────
16
+ // The endpoint server-side-fetches a caller-supplied URL. Without a guard an
17
+ // attacker can point it at internal services, the cloud metadata endpoint
18
+ // (169.254.169.254), or loopback — a classic SSRF. We allow only https, block
19
+ // every private / loopback / link-local / reserved IP range (resolving DNS
20
+ // first), follow redirects manually with re-validation at each hop, and cap
21
+ // both response size and request time.
22
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB, matches the upload limit
23
+ const FETCH_TIMEOUT_MS = 15_000;
24
+ const MAX_REDIRECTS = 3;
25
+ function isBlockedIpv4(ip) {
26
+ const parts = ip.split('.').map(Number);
27
+ if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255))
28
+ return true;
29
+ const [a, b] = parts;
30
+ if (a === 0)
31
+ return true; // 0.0.0.0/8 "this host"
32
+ if (a === 10)
33
+ return true; // 10.0.0.0/8 private
34
+ if (a === 127)
35
+ return true; // 127.0.0.0/8 loopback
36
+ if (a === 169 && b === 254)
37
+ return true; // 169.254.0.0/16 link-local (incl. metadata 169.254.169.254)
38
+ if (a === 172 && b >= 16 && b <= 31)
39
+ return true; // 172.16.0.0/12 private
40
+ if (a === 192 && b === 168)
41
+ return true; // 192.168.0.0/16 private
42
+ if (a === 100 && b >= 64 && b <= 127)
43
+ return true; // 100.64.0.0/10 CGNAT
44
+ if (a >= 224)
45
+ return true; // 224.0.0.0/4 multicast + 240/4 reserved + 255 broadcast
46
+ return false;
47
+ }
48
+ function isBlockedIpv6(ip) {
49
+ const s = ip.toLowerCase().replace(/^\[|\]$/g, '');
50
+ // IPv4-mapped (::ffff:a.b.c.d) — classify on the embedded v4 address.
51
+ const mapped = s.match(/(?:^|:)((?:\d{1,3}\.){3}\d{1,3})$/);
52
+ if (mapped)
53
+ return isBlockedIpv4(mapped[1]);
54
+ if (s === '::1' || s === '::')
55
+ return true; // loopback / unspecified
56
+ if (s.startsWith('fe8') || s.startsWith('fe9') || s.startsWith('fea') || s.startsWith('feb'))
57
+ return true; // fe80::/10 link-local
58
+ if (s.startsWith('fc') || s.startsWith('fd'))
59
+ return true; // fc00::/7 unique-local
60
+ if (s.startsWith('ff'))
61
+ return true; // ff00::/8 multicast
62
+ return false;
63
+ }
64
+ function isBlockedAddress(ip) {
65
+ const fam = isIP(ip);
66
+ if (fam === 4)
67
+ return isBlockedIpv4(ip);
68
+ if (fam === 6)
69
+ return isBlockedIpv6(ip);
70
+ return true; // not a recognizable IP → refuse
71
+ }
72
+ /** Throw unless `rawUrl` is an https URL whose host resolves only to public addresses. */
73
+ async function assertSafeImageUrl(rawUrl) {
74
+ let parsed;
75
+ try {
76
+ parsed = new URL(rawUrl);
77
+ }
78
+ catch {
79
+ throw new Error('blocked');
80
+ }
81
+ if (parsed.protocol !== 'https:')
82
+ throw new Error('blocked');
83
+ const host = parsed.hostname.replace(/^\[|\]$/g, '');
84
+ if (isIP(host)) {
85
+ if (isBlockedAddress(host))
86
+ throw new Error('blocked');
87
+ return;
88
+ }
89
+ let addrs;
90
+ try {
91
+ addrs = await lookup(host, { all: true });
92
+ }
93
+ catch {
94
+ throw new Error('blocked');
95
+ }
96
+ if (addrs.length === 0)
97
+ throw new Error('blocked');
98
+ for (const a of addrs) {
99
+ if (isBlockedAddress(a.address))
100
+ throw new Error('blocked');
101
+ }
102
+ }
103
+ /** Fetch an image with SSRF guards: https-only, public-IP-only, manual
104
+ * redirect re-validation, size cap, and timeout. Returns the response for a
105
+ * caller to read (the caller still enforces the byte cap while streaming). */
106
+ async function safeImageFetch(initialUrl) {
107
+ let current = initialUrl;
108
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
109
+ await assertSafeImageUrl(current);
110
+ const controller = new AbortController();
111
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
112
+ let response;
113
+ try {
114
+ response = await fetch(current, { redirect: 'manual', signal: controller.signal });
115
+ }
116
+ finally {
117
+ clearTimeout(timer);
118
+ }
119
+ if (response.status >= 300 && response.status < 400) {
120
+ const location = response.headers.get('location');
121
+ if (!location)
122
+ return response;
123
+ current = new URL(location, current).toString(); // re-validated at loop top
124
+ continue;
125
+ }
126
+ return response;
127
+ }
128
+ throw new Error('blocked');
129
+ }
130
+ /** Read a response body into a Buffer, aborting (returns null) once `max` bytes
131
+ * are exceeded so a server that lies about content-length can't blow past the cap. */
132
+ async function readCapped(response, max) {
133
+ if (!response.body) {
134
+ const buf = Buffer.from(await response.arrayBuffer());
135
+ return buf.length > max ? null : buf;
136
+ }
137
+ const reader = response.body.getReader();
138
+ const chunks = [];
139
+ let total = 0;
140
+ while (true) {
141
+ const { done, value } = await reader.read();
142
+ if (done)
143
+ break;
144
+ if (value) {
145
+ total += value.length;
146
+ if (total > max) {
147
+ await reader.cancel().catch(() => { });
148
+ return null;
149
+ }
150
+ chunks.push(value);
151
+ }
152
+ }
153
+ return Buffer.concat(chunks);
154
+ }
13
155
  function ensureImagesDir() {
14
156
  ensureDataDir();
15
157
  const dir = getImagesDir();
@@ -61,8 +203,17 @@ export function createImageRouter() {
61
203
  res.status(400).json({ error: 'No URL provided' });
62
204
  return;
63
205
  }
206
+ let response;
207
+ try {
208
+ response = await safeImageFetch(url);
209
+ }
210
+ catch {
211
+ // SSRF guard rejected the URL (bad scheme, private/blocked host, too many
212
+ // redirects). Generic message — never echo the resolved host or reason.
213
+ res.status(400).json({ error: 'URL not allowed' });
214
+ return;
215
+ }
64
216
  try {
65
- const response = await fetch(url);
66
217
  if (!response.ok) {
67
218
  res.status(400).json({ error: 'Failed to fetch image' });
68
219
  return;
@@ -72,14 +223,25 @@ export function createImageRouter() {
72
223
  res.status(400).json({ error: 'URL is not an image' });
73
224
  return;
74
225
  }
226
+ // Reject early if the server declares an over-limit size.
227
+ const declaredLen = Number(response.headers.get('content-length'));
228
+ if (Number.isFinite(declaredLen) && declaredLen > MAX_IMAGE_BYTES) {
229
+ res.status(400).json({ error: 'Image too large' });
230
+ return;
231
+ }
75
232
  const ext = contentType.includes('jpeg') || contentType.includes('jpg') ? '.jpg'
76
233
  : contentType.includes('gif') ? '.gif'
77
234
  : contentType.includes('webp') ? '.webp'
78
235
  : '.png';
236
+ // Stream with a hard byte cap so a lying/streaming server can't exhaust disk.
237
+ const buffer = await readCapped(response, MAX_IMAGE_BYTES);
238
+ if (!buffer) {
239
+ res.status(400).json({ error: 'Image too large' });
240
+ return;
241
+ }
79
242
  ensureImagesDir();
80
243
  const filename = `${randomUUID().slice(0, 8)}${ext}`;
81
244
  const filePath = join(getImagesDir(), filename);
82
- const buffer = Buffer.from(await response.arrayBuffer());
83
245
  const { writeFileSync } = await import('fs');
84
246
  writeFileSync(filePath, buffer);
85
247
  const src = `/_images/${filename}`;
@@ -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';
@@ -45,6 +46,109 @@ const __dirname = dirname(__filename);
45
46
  let runtimePort = 5050;
46
47
  export function getRuntimePort() { return runtimePort; }
47
48
  export function getBaseUrl() { return `http://localhost:${runtimePort}`; }
49
+ // ---- Trust boundary (anti-DNS-rebinding + CSRF) — MCP-5 ----
50
+ // The HTTP API binds to loopback and historically trusted "localhost = the
51
+ // user." That is false: with no Host/Origin validation a remote website can
52
+ // reach this API via DNS rebinding (its page rebinds a hostname to 127.0.0.1,
53
+ // the browser keeps sending the attacker's Host/Origin) and drive every
54
+ // mutating route — switch profiles, enable plugins, rewrite plugin config.
55
+ // The middleware below is the single gate for ALL routes. adr: see MCP-5.
56
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
57
+ /** Split a Host header into hostname + optional port, handling [::1]:port. */
58
+ function splitHostHeader(hostHeader) {
59
+ if (hostHeader.startsWith('[')) {
60
+ const close = hostHeader.indexOf(']');
61
+ if (close === -1)
62
+ return { host: hostHeader };
63
+ const host = hostHeader.slice(1, close);
64
+ const rest = hostHeader.slice(close + 1);
65
+ return { host, port: rest.startsWith(':') ? rest.slice(1) : undefined };
66
+ }
67
+ const colon = hostHeader.lastIndexOf(':');
68
+ if (colon === -1)
69
+ return { host: hostHeader };
70
+ return { host: hostHeader.slice(0, colon), port: hostHeader.slice(colon + 1) };
71
+ }
72
+ /** True when the Host header names loopback on the port we are serving. */
73
+ function isAllowedHost(hostHeader, port) {
74
+ if (!hostHeader)
75
+ return false;
76
+ const { host, port: p } = splitHostHeader(hostHeader.trim());
77
+ if (!LOOPBACK_HOSTS.has(host.toLowerCase()))
78
+ return false;
79
+ if (p !== undefined && p !== String(port))
80
+ return false;
81
+ return true;
82
+ }
83
+ /** True when an Origin/Referer URL is same-origin (loopback on our port). */
84
+ function isAllowedOrigin(value, port) {
85
+ if (!value)
86
+ return false;
87
+ try {
88
+ const u = new URL(value);
89
+ if (!LOOPBACK_HOSTS.has(u.hostname.toLowerCase()))
90
+ return false;
91
+ if (u.port && u.port !== String(port))
92
+ return false;
93
+ return true;
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }
99
+ const STATE_CHANGING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
100
+ /**
101
+ * Global security gate, applied before any route or body parsing.
102
+ * (a) Host allowlist — the anti-DNS-rebinding control.
103
+ * (b) Origin/Referer same-origin check on state-changing methods (CSRF).
104
+ * Browsers always send Origin on cross-origin POST, so a rebound page is
105
+ * rejected here too. A *missing* Origin is allowed only for state-changing
106
+ * requests from non-browser local clients (the client-mode MCP-over-HTTP
107
+ * proxy uses curl-style requests with no Origin); the Host gate still
108
+ * bounds those to loopback.
109
+ * (c) Restrictive security headers on every response.
110
+ */
111
+ function securityGate(port) {
112
+ return (req, res, next) => {
113
+ res.setHeader('X-Content-Type-Options', 'nosniff');
114
+ res.setHeader('X-Frame-Options', 'DENY');
115
+ res.setHeader('Referrer-Policy', 'no-referrer');
116
+ res.setHeader('Content-Security-Policy', [
117
+ "default-src 'self'",
118
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
119
+ "style-src 'self' 'unsafe-inline'",
120
+ "img-src 'self' data: blob: https:",
121
+ "font-src 'self' data:",
122
+ "connect-src 'self' ws: wss:",
123
+ "frame-ancestors 'none'",
124
+ "base-uri 'self'",
125
+ "object-src 'none'",
126
+ ].join('; '));
127
+ if (!isAllowedHost(req.headers.host, port)) {
128
+ res.status(403).json({ error: 'Forbidden' });
129
+ return;
130
+ }
131
+ if (STATE_CHANGING.has(req.method)) {
132
+ const origin = req.headers.origin;
133
+ const referer = req.headers.referer;
134
+ if (origin) {
135
+ if (!isAllowedOrigin(origin, port)) {
136
+ res.status(403).json({ error: 'Forbidden' });
137
+ return;
138
+ }
139
+ }
140
+ else if (referer) {
141
+ if (!isAllowedOrigin(referer, port)) {
142
+ res.status(403).json({ error: 'Forbidden' });
143
+ return;
144
+ }
145
+ }
146
+ // No Origin and no Referer: non-browser local client; Host gate above
147
+ // already constrained it to loopback. Allow.
148
+ }
149
+ next();
150
+ };
151
+ }
48
152
  export async function startHttpServer(options = {}) {
49
153
  const port = options.port || 5050;
50
154
  runtimePort = port;
@@ -55,6 +159,11 @@ export async function startHttpServer(options = {}) {
55
159
  initLogger();
56
160
  logger.info('state', 'server-boot', `OpenWriter starting on port ${port}`, { port });
57
161
  const app = express();
162
+ // Trust boundary FIRST — reject cross-host / cross-origin before body
163
+ // parsing or any route handler runs. Covers every route, including the
164
+ // unauth state-changers /api/profiles/switch, /api/plugins/enable,
165
+ // /api/plugins/config, and the universal /api/mcp-call dispatcher. MCP-5.
166
+ app.use(securityGate(port));
58
167
  app.use(express.json({ limit: '10mb' }));
59
168
  // API routes for direct HTTP access (fallback if WS not available)
60
169
  app.get('/api/status', (_req, res) => {
@@ -69,7 +178,13 @@ export async function startHttpServer(options = {}) {
69
178
  }));
70
179
  res.json({ tools });
71
180
  });
72
- // MCP-over-HTTP: allows client-mode terminals to proxy tool calls
181
+ // MCP-over-HTTP: allows client-mode terminals to proxy tool calls.
182
+ // MCP-4: this dispatches ANY MCP tool with the user's platform credentials,
183
+ // so it MUST never be reachable cross-origin. That guarantee is provided by
184
+ // the global securityGate above (Host allowlist + Origin/Referer check on
185
+ // POST) — a DNS-rebound page cannot satisfy either. No tool is intentionally
186
+ // exposed beyond same-origin/local callers here; any future tool that must
187
+ // never be driven over HTTP should be denylisted at this entry point.
73
188
  app.post('/api/mcp-call', async (req, res) => {
74
189
  const { tool: toolName, arguments: args } = req.body;
75
190
  // Wrap the call in a request ID scope so every event logged during
@@ -120,6 +235,8 @@ export async function startHttpServer(options = {}) {
120
235
  // if installed). SyncButton + SyncSetupModal hit /api/sync/* unchanged.
121
236
  // Mount export routes
122
237
  app.use(createExportRouter());
238
+ // Mount manuscript compile/render routes (book binding -> epub/docx/html/md)
239
+ app.use(createManuscriptRouter());
123
240
  // Mount connection CRUD + profile binding routes
124
241
  app.use(createConnectionRouter());
125
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';