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.
- package/dist/client/assets/{index-Dz0iuWDM.css → index-2miZWC8D.css} +1 -1
- package/dist/client/assets/index-C_Zb7mUx.js +215 -0
- package/dist/client/index.html +2 -2
- package/dist/server/content-type-meta.js +1 -0
- package/dist/server/helpers.js +2 -2
- package/dist/server/index.js +3 -0
- package/dist/server/logger.js +2 -2
- package/dist/server/manuscript/assemble.js +128 -0
- package/dist/server/manuscript/index.js +35 -0
- package/dist/server/manuscript/load.js +65 -0
- package/dist/server/manuscript/parse.js +59 -0
- package/dist/server/manuscript/render/css.js +41 -0
- package/dist/server/manuscript/render/docx.js +10 -0
- package/dist/server/manuscript/render/epub.js +156 -0
- package/dist/server/manuscript/render/html.js +91 -0
- package/dist/server/manuscript/render/md.js +41 -0
- package/dist/server/manuscript/resolve.js +42 -0
- package/dist/server/manuscript-routes.js +78 -0
- package/dist/server/mcp.js +78 -4
- package/dist/server/state.js +1 -1
- package/package.json +2 -1
- package/dist/client/assets/index-BBEdpqBq.js +0 -215
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
}
|
package/dist/server/helpers.js
CHANGED
|
@@ -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
|
|
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
|
|
133
|
+
* `C:\Users\me\data-dir` (different separators).
|
|
134
134
|
*
|
|
135
135
|
* adr: adr/path-canonicalization.md
|
|
136
136
|
*/
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
package/dist/server/logger.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/'/g, ''');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
+
}
|