mosage 0.1.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/cli.js ADDED
@@ -0,0 +1,4545 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/shared/config.ts
13
+ function defaultExport(type) {
14
+ return structuredClone(type === "thesis" ? THESIS_EXPORT : BOOK_EXPORT);
15
+ }
16
+ function isObject(value) {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+ function str(value, fallback) {
20
+ return typeof value === "string" ? value : typeof value === "number" ? String(value) : fallback;
21
+ }
22
+ function num(value, fallback) {
23
+ const n = typeof value === "string" ? Number(value) : value;
24
+ return typeof n === "number" && Number.isFinite(n) ? n : fallback;
25
+ }
26
+ function bool(value, fallback) {
27
+ return typeof value === "boolean" ? value : fallback;
28
+ }
29
+ function oneOf(value, options, fallback) {
30
+ return typeof value === "string" && options.includes(value) ? value : fallback;
31
+ }
32
+ function resolvePageSize(value, fallback) {
33
+ if (typeof value === "string") {
34
+ const name = Object.keys(PAGE_SIZES).find((k) => k.toLowerCase() === value.toLowerCase());
35
+ if (name) return name;
36
+ }
37
+ if (isObject(value)) {
38
+ const width = num(value.width, 0);
39
+ const height = num(value.height, 0);
40
+ if (width > 0 && height > 0) return { width, height };
41
+ }
42
+ return fallback;
43
+ }
44
+ function mergeConfig(base, override) {
45
+ const out = isObject(base) ? { ...base } : {};
46
+ if (!isObject(override)) return out;
47
+ for (const [key, value] of Object.entries(override)) {
48
+ if (value === null || value === void 0 || value === "") continue;
49
+ out[key] = isObject(value) && isObject(out[key]) ? mergeConfig(out[key], value) : value;
50
+ }
51
+ return out;
52
+ }
53
+ function resolveConfig(raw) {
54
+ const r = isObject(raw) ? raw : {};
55
+ const type = oneOf(r.type, ["book", "thesis", "other"], "book");
56
+ const title = str(r.title, "").trim();
57
+ const d = defaultExport(type);
58
+ const e = isObject(r.export) ? r.export : {};
59
+ const margins = isObject(e.margins) ? e.margins : {};
60
+ const fonts = isObject(e.fonts) ? e.fonts : {};
61
+ const ai = isObject(r.ai) ? r.ai : {};
62
+ return {
63
+ type,
64
+ stage: oneOf(r.stage, STAGES, "kickoff"),
65
+ title,
66
+ subtitle: str(r.subtitle, ""),
67
+ author: str(r.author, ""),
68
+ language: str(r.language, "zh-TW"),
69
+ targetWords: Math.max(0, num(r.targetWords, 0)),
70
+ chapters: Array.isArray(r.chapters) ? r.chapters.filter((c) => typeof c === "string" && c.trim() !== "") : [],
71
+ ai: { editMode: oneOf(ai.editMode, ["suggest", "direct"], "suggest") },
72
+ export: {
73
+ fileName: str(e.fileName, "") || title || "manuscript",
74
+ pageSize: resolvePageSize(e.pageSize, d.pageSize),
75
+ margins: {
76
+ top: num(margins.top, d.margins.top),
77
+ bottom: num(margins.bottom, d.margins.bottom),
78
+ left: num(margins.left, d.margins.left),
79
+ right: num(margins.right, d.margins.right)
80
+ },
81
+ fonts: {
82
+ body: str(fonts.body, d.fonts.body),
83
+ latin: str(fonts.latin, d.fonts.latin),
84
+ heading: str(fonts.heading, d.fonts.heading),
85
+ code: str(fonts.code, d.fonts.code)
86
+ },
87
+ fontSize: num(e.fontSize, d.fontSize),
88
+ lineSpacing: num(e.lineSpacing, d.lineSpacing),
89
+ firstLineIndent: num(e.firstLineIndent, d.firstLineIndent),
90
+ paragraphSpacing: num(e.paragraphSpacing, d.paragraphSpacing),
91
+ titlePage: bool(e.titlePage, d.titlePage),
92
+ toc: bool(e.toc, d.toc),
93
+ chapterPageBreak: bool(e.chapterPageBreak, d.chapterPageBreak),
94
+ pageNumbers: bool(e.pageNumbers, d.pageNumbers),
95
+ header: oneOf(e.header, ["none", "title", "chapter"], d.header),
96
+ sceneBreak: str(e.sceneBreak, d.sceneBreak)
97
+ }
98
+ };
99
+ }
100
+ function pageSizeMm(size) {
101
+ return typeof size === "string" ? PAGE_SIZES[size] : size;
102
+ }
103
+ var STAGES, CHAPTER_STATUSES, PAGE_SIZES, BOOK_EXPORT, THESIS_EXPORT;
104
+ var init_config = __esm({
105
+ "src/shared/config.ts"() {
106
+ "use strict";
107
+ STAGES = ["kickoff", "outline", "writing", "revising", "done"];
108
+ CHAPTER_STATUSES = ["idea", "draft", "revising", "done"];
109
+ PAGE_SIZES = {
110
+ A4: { width: 210, height: 297 },
111
+ A5: { width: 148, height: 210 },
112
+ // JIS B5, the size Taiwanese and Japanese printers mean by "B5".
113
+ B5: { width: 182, height: 257 },
114
+ Letter: { width: 216, height: 279 }
115
+ };
116
+ BOOK_EXPORT = {
117
+ fileName: "",
118
+ pageSize: "A4",
119
+ margins: { top: 25, bottom: 25, left: 25, right: 25 },
120
+ fonts: {
121
+ body: "\u65B0\u7D30\u660E\u9AD4",
122
+ latin: "Times New Roman",
123
+ heading: "\u5FAE\u8EDF\u6B63\u9ED1\u9AD4",
124
+ code: "Consolas"
125
+ },
126
+ fontSize: 12,
127
+ lineSpacing: 1.5,
128
+ firstLineIndent: 2,
129
+ paragraphSpacing: 6,
130
+ titlePage: true,
131
+ toc: true,
132
+ chapterPageBreak: true,
133
+ pageNumbers: true,
134
+ header: "title",
135
+ sceneBreak: "\uFF0A\u3000\uFF0A\u3000\uFF0A"
136
+ };
137
+ THESIS_EXPORT = {
138
+ ...BOOK_EXPORT,
139
+ margins: { top: 25, bottom: 25, left: 30, right: 25 },
140
+ fonts: {
141
+ body: "\u6A19\u6977\u9AD4",
142
+ latin: "Times New Roman",
143
+ heading: "\u6A19\u6977\u9AD4",
144
+ code: "Consolas"
145
+ },
146
+ paragraphSpacing: 0,
147
+ header: "none"
148
+ };
149
+ }
150
+ });
151
+
152
+ // src/shared/annotations.ts
153
+ function isMarker(html) {
154
+ return MARKER_RE.test(html.trim());
155
+ }
156
+ function unquote(value) {
157
+ if (!value.startsWith('"')) return value;
158
+ return value.slice(1, -1).replace(/\\(.)/g, (_, ch) => ch === "n" ? "\n" : ch);
159
+ }
160
+ function parseMarker(html) {
161
+ const match = MARKER_RE.exec(html.trim());
162
+ if (!match) return null;
163
+ const kind = match[1];
164
+ const rest = match[2];
165
+ const newline = rest.indexOf("\n");
166
+ let header = newline === -1 ? rest : rest.slice(0, newline);
167
+ const tail = newline === -1 ? "" : rest.slice(newline + 1);
168
+ const attrs = {};
169
+ for (; ; ) {
170
+ const attr = ATTR_RE.exec(header);
171
+ if (!attr) break;
172
+ attrs[attr[1]] = unquote(attr[2]);
173
+ header = header.slice(attr[0].length);
174
+ }
175
+ const body = [header.trim(), tail].filter((s) => s.trim() !== "").join("\n");
176
+ return { kind, attrs, body: body.replace(/^\s*\n/, "").replace(/\s+$/, "") };
177
+ }
178
+ function quoteAttr(value) {
179
+ if (value !== "" && /^[^\s"=<>`'\\]+$/.test(value) && !value.includes("--")) return value;
180
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/--/g, "-\\-")}"`;
181
+ }
182
+ function formatMarker(kind, attrs, body) {
183
+ const head = Object.entries(attrs).filter(([, v]) => v !== void 0 && v !== "").map(([k, v]) => `${k}=${quoteAttr(v)}`).join(" ");
184
+ const safeBody = body.replace(/-->/g, "-- >").trim();
185
+ return `<!-- mosage:${kind}${head ? ` ${head}` : ""}
186
+ ${safeBody}
187
+ -->`;
188
+ }
189
+ function hash(text) {
190
+ let h = 2166136261;
191
+ for (let i = 0; i < text.length; i++) {
192
+ h ^= text.charCodeAt(i);
193
+ h = Math.imul(h, 16777619);
194
+ }
195
+ return (h >>> 0).toString(16).padStart(8, "0");
196
+ }
197
+ function newAnnotationId(kind) {
198
+ const random = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().replace(/-/g, "") : Math.random().toString(16).slice(2) + Date.now().toString(16);
199
+ return `${kind === "comment" ? "c" : "s"}-${random.slice(0, 8)}`;
200
+ }
201
+ function toAnnotation(data, position, seen) {
202
+ let id = data.attrs.id;
203
+ if (!id) {
204
+ const base = `${data.kind === "comment" ? "c" : "s"}-${hash(`${data.kind}
205
+ ${data.attrs.quote ?? ""}
206
+ ${data.body}`)}`;
207
+ id = base;
208
+ for (let n = 2; seen.has(id); n++) id = `${base}-${n}`;
209
+ }
210
+ seen.add(id);
211
+ const span = Number.parseInt(data.attrs.span ?? "", 10);
212
+ return {
213
+ ...data,
214
+ id,
215
+ by: (data.attrs.by ?? (data.kind === "suggest" ? "ai" : "human")).toLowerCase(),
216
+ at: data.attrs.at,
217
+ quote: data.attrs.quote,
218
+ note: data.attrs.note,
219
+ span: Number.isFinite(span) && span >= 0 ? span : 1,
220
+ ...position
221
+ };
222
+ }
223
+ var MARKER_RE, ATTR_RE;
224
+ var init_annotations = __esm({
225
+ "src/shared/annotations.ts"() {
226
+ "use strict";
227
+ MARKER_RE = /^<!--[ \t]*mosage:(comment|suggest)\b([\s\S]*?)-->[ \t]*$/;
228
+ ATTR_RE = /^[ \t]*([A-Za-z][\w-]*)=("(?:[^"\\\n]|\\.)*"|[^\s"]+)/;
229
+ }
230
+ });
231
+
232
+ // src/shared/wordcount.ts
233
+ function countWords(text) {
234
+ let count = 0;
235
+ for (const _ of text.matchAll(TOKEN)) count++;
236
+ return count;
237
+ }
238
+ function formatCount(n, locale = "zh-TW") {
239
+ return new Intl.NumberFormat(locale).format(n);
240
+ }
241
+ var CJK, LATIN, TOKEN;
242
+ var init_wordcount = __esm({
243
+ "src/shared/wordcount.ts"() {
244
+ "use strict";
245
+ CJK = "\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}";
246
+ LATIN = `(?:(?![${CJK}])[\\p{L}\\p{N}])+`;
247
+ TOKEN = new RegExp(`[${CJK}]|${LATIN}(?:['\u2019.-]${LATIN})*`, "gu");
248
+ }
249
+ });
250
+
251
+ // src/shared/markdown.ts
252
+ import remarkFrontmatter from "remark-frontmatter";
253
+ import remarkGfm from "remark-gfm";
254
+ import remarkParse from "remark-parse";
255
+ import { unified } from "unified";
256
+ import { parse as parseYaml } from "yaml";
257
+ function parseMarkdown(source) {
258
+ return processor.parse(source);
259
+ }
260
+ function textOf(node) {
261
+ if (!node || typeof node !== "object") return "";
262
+ const n = node;
263
+ if (n.type === "html" || n.type === "yaml" || n.type === "footnoteDefinition") return "";
264
+ if (n.type === "break") return "\n";
265
+ if (typeof n.value === "string") return n.value;
266
+ if (n.type === "image") return n.alt ?? "";
267
+ if (Array.isArray(n.children)) return n.children.map(textOf).join("");
268
+ return "";
269
+ }
270
+ function isPageBreak(node) {
271
+ return node.type === "html" && /^<!--\s*(pagebreak|page-break|分頁)\s*-->$/i.test(node.value.trim());
272
+ }
273
+ function lineAt(source, offset) {
274
+ let line = 1;
275
+ for (let i = 0; i < offset && i < source.length; i++) if (source.charCodeAt(i) === 10) line++;
276
+ return line;
277
+ }
278
+ function readFrontmatter(value) {
279
+ try {
280
+ const data = parseYaml(value);
281
+ return data && typeof data === "object" && !Array.isArray(data) ? data : {};
282
+ } catch {
283
+ return {};
284
+ }
285
+ }
286
+ function wordsOf(tree) {
287
+ let total = 0;
288
+ const walk2 = (node) => {
289
+ const n = node;
290
+ if (!n || n.type === "html" || n.type === "yaml" || n.type === "code") return;
291
+ if (typeof n.value === "string") total += countWords(n.value);
292
+ if (Array.isArray(n.children)) n.children.forEach(walk2);
293
+ };
294
+ walk2(tree);
295
+ return total;
296
+ }
297
+ function parseChapter(source) {
298
+ const tree = parseMarkdown(source);
299
+ let frontmatter = {};
300
+ let frontmatterRange = null;
301
+ const blocks = [];
302
+ const annotations = [];
303
+ const seen = /* @__PURE__ */ new Set();
304
+ let pending = [];
305
+ for (const node of tree.children) {
306
+ const start = node.position?.start.offset ?? 0;
307
+ const end = node.position?.end.offset ?? start;
308
+ if (node.type === "yaml") {
309
+ frontmatter = readFrontmatter(node.value);
310
+ frontmatterRange = [start, end];
311
+ continue;
312
+ }
313
+ if (node.type === "html") {
314
+ const marker = parseMarker(node.value);
315
+ if (marker) {
316
+ const annotation = toAnnotation(marker, { start, end, blockIndex: null }, seen);
317
+ pending.push(annotation);
318
+ annotations.push(annotation);
319
+ continue;
320
+ }
321
+ }
322
+ const index = blocks.length;
323
+ for (const a of pending) a.blockIndex = index;
324
+ blocks.push({ index, node, start, end, annotations: pending });
325
+ pending = [];
326
+ }
327
+ const headings = [];
328
+ for (const block of blocks) {
329
+ if (block.node.type !== "heading") continue;
330
+ headings.push({
331
+ depth: block.node.depth,
332
+ text: textOf(block.node).trim(),
333
+ blockIndex: block.index,
334
+ line: lineAt(source, block.start)
335
+ });
336
+ }
337
+ const h1 = headings.find((h) => h.depth === 1);
338
+ return {
339
+ source,
340
+ tree,
341
+ frontmatter,
342
+ frontmatterRange,
343
+ title: h1 ? h1.text : null,
344
+ blocks,
345
+ annotations,
346
+ headings,
347
+ words: wordsOf(tree)
348
+ };
349
+ }
350
+ function stripMarkers(source) {
351
+ const { annotations } = parseChapter(source);
352
+ let out = source;
353
+ for (const a of [...annotations].reverse()) {
354
+ let end = a.end;
355
+ if (out[end] === "\n") end++;
356
+ out = out.slice(0, a.start) + out.slice(end);
357
+ }
358
+ return out;
359
+ }
360
+ var processor;
361
+ var init_markdown = __esm({
362
+ "src/shared/markdown.ts"() {
363
+ "use strict";
364
+ init_annotations();
365
+ init_wordcount();
366
+ processor = unified().use(remarkParse).use(remarkGfm).use(remarkFrontmatter, ["yaml"]);
367
+ }
368
+ });
369
+
370
+ // src/node/export/common.ts
371
+ import { existsSync as existsSync4, statSync } from "fs";
372
+ import { readFile as readFile4 } from "fs/promises";
373
+ import { basename, extname as extname2, isAbsolute, join as join4 } from "path";
374
+ import { fileURLToPath } from "url";
375
+ import { imageSize } from "image-size";
376
+ function isEnglish(language) {
377
+ return /^en\b/i.test(language.trim());
378
+ }
379
+ function isCjkLanguage(language) {
380
+ return /^(zh|ja|ko)\b/i.test(language.trim());
381
+ }
382
+ function labelsFor(language) {
383
+ return isEnglish(language) ? EN : ZH;
384
+ }
385
+ function imagePlaceholder(alt, labels) {
386
+ const text = alt.trim();
387
+ if (!text) return `[${labels.image}]`;
388
+ return labels === EN ? `[${labels.image}: ${text}]` : `[${labels.image}\uFF1A${text}]`;
389
+ }
390
+ function walk(node, visit) {
391
+ if (!("children" in node)) return;
392
+ const parent = node;
393
+ for (let i = 0; i < parent.children.length; i++) {
394
+ const child = parent.children[i];
395
+ visit(child, parent, i);
396
+ walk(child, visit);
397
+ }
398
+ }
399
+ function prepareChapter(source) {
400
+ const tree = parseMarkdown(stripMarkers(source));
401
+ const prune = (node) => {
402
+ node.children = node.children.filter(
403
+ (child) => !(child.type === "yaml" || child.type === "html" && isMarker(child.value))
404
+ );
405
+ for (const child of node.children) if ("children" in child) prune(child);
406
+ };
407
+ prune(tree);
408
+ return tree;
409
+ }
410
+ function collectDefinitions(tree) {
411
+ const map = /* @__PURE__ */ new Map();
412
+ walk(tree, (node) => {
413
+ if (node.type === "definition" && !map.has(node.identifier)) map.set(node.identifier, node);
414
+ });
415
+ return map;
416
+ }
417
+ function collectFootnotes(tree) {
418
+ const map = /* @__PURE__ */ new Map();
419
+ walk(tree, (node) => {
420
+ if (node.type === "footnoteDefinition" && !map.has(node.identifier)) {
421
+ map.set(node.identifier, node);
422
+ }
423
+ });
424
+ return map;
425
+ }
426
+ function readAlert(node) {
427
+ const first = node.children[0];
428
+ if (first?.type !== "paragraph") return null;
429
+ const head = first.children[0];
430
+ if (head?.type !== "text") return null;
431
+ const match = ALERT_RE.exec(head.value);
432
+ if (!match) return null;
433
+ const rest = head.value.slice(match[0].length);
434
+ const inline = [...first.children.slice(1)];
435
+ if (rest) inline.unshift({ ...head, value: rest });
436
+ else if (inline[0]?.type === "break") inline.shift();
437
+ const paragraph = { ...first, children: inline };
438
+ const hasText = inline.some((c) => c.type !== "text" || c.value.trim() !== "");
439
+ return {
440
+ type: match[1].toLowerCase(),
441
+ children: hasText ? [paragraph, ...node.children.slice(1)] : node.children.slice(1)
442
+ };
443
+ }
444
+ function charBefore(s, i) {
445
+ const code = s.charCodeAt(i - 1);
446
+ return code >= 56320 && code <= 57343 && i >= 2 ? s.slice(i - 2, i) : s.charAt(i - 1);
447
+ }
448
+ function charAt(s, i) {
449
+ const cp3 = s.codePointAt(i);
450
+ return cp3 === void 0 ? "" : String.fromCodePoint(cp3);
451
+ }
452
+ function joinSoftBreaks(text) {
453
+ if (!text.includes("\n")) return text;
454
+ return text.replace(/[ \t]*\r?\n[ \t]*/g, (match, offset, s) => {
455
+ const before = charBefore(s, offset);
456
+ const after = charAt(s, offset + match.length);
457
+ return CJK_RE.test(before) && CJK_RE.test(after) ? "" : " ";
458
+ });
459
+ }
460
+ function sanitizeFileName(name) {
461
+ const clean = name.replace(/[/\\:*?"<>|]/g, "").replace(/[\u0000-\u001f\u007f]/g, "").replace(/\s+/g, " ").trim().replace(/^\.+/, "").replace(/[. ]+$/, "").slice(0, 120).trim();
462
+ return clean || "manuscript";
463
+ }
464
+ function isExternalUrl(url) {
465
+ return SCHEME_RE.test(url) && !WINDOWS_PATH_RE.test(url) && !/^file:/i.test(url);
466
+ }
467
+ function safeDecode(value) {
468
+ try {
469
+ return decodeURIComponent(value);
470
+ } catch {
471
+ return value;
472
+ }
473
+ }
474
+ function imageCandidates(url, root) {
475
+ let path = url.trim();
476
+ if (!path || isExternalUrl(path)) return [];
477
+ if (/^file:/i.test(path)) {
478
+ try {
479
+ return [fileURLToPath(path)];
480
+ } catch {
481
+ return [];
482
+ }
483
+ }
484
+ path = path.replace(/[?#].*$/, "");
485
+ const decoded = safeDecode(path);
486
+ const variants = decoded === path ? [path] : [decoded, path];
487
+ const out = [];
488
+ for (const p of variants) {
489
+ if (isAbsolute(p) || WINDOWS_PATH_RE.test(p)) {
490
+ out.push(p, join4(root, p));
491
+ } else {
492
+ out.push(join4(root, "chapters", p), join4(root, p));
493
+ }
494
+ }
495
+ return [...new Set(out)];
496
+ }
497
+ function isFile(path) {
498
+ try {
499
+ return existsSync4(path) && statSync(path).isFile();
500
+ } catch {
501
+ return false;
502
+ }
503
+ }
504
+ function resolveLocalFile(url, root) {
505
+ return imageCandidates(url, root).find(isFile) ?? null;
506
+ }
507
+ function describe(data, name) {
508
+ let type = EXT_TYPE[extname2(name).toLowerCase()] ?? "";
509
+ let width = 0;
510
+ let height = 0;
511
+ try {
512
+ const size = imageSize(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
513
+ width = size.width ?? 0;
514
+ height = size.height ?? 0;
515
+ if (size.type) type = size.type === "jpeg" ? "jpg" : size.type;
516
+ } catch {
517
+ if (type !== "svg") return null;
518
+ }
519
+ if (!type) return null;
520
+ return { data, type, mime: MIME[type] ?? `image/${type}`, width, height, name };
521
+ }
522
+ async function loadImage(url, root) {
523
+ const dataUri = /^data:([^;,]*)((?:;[^;,]*)*?)(;base64)?,(.*)$/is.exec(url.trim());
524
+ if (dataUri) {
525
+ try {
526
+ const payload = dataUri[3] ? Buffer.from(dataUri[4], "base64") : Buffer.from(safeDecode(dataUri[4]), "utf8");
527
+ const ext = Object.entries(MIME).find(([, m]) => m === dataUri[1].toLowerCase())?.[0];
528
+ return describe(payload, `image.${ext ?? "bin"}`);
529
+ } catch {
530
+ return null;
531
+ }
532
+ }
533
+ const file = resolveLocalFile(url, root);
534
+ if (!file) return null;
535
+ try {
536
+ return describe(await readFile4(file), basename(file));
537
+ } catch {
538
+ return null;
539
+ }
540
+ }
541
+ async function loadImages(urls, root) {
542
+ const unique = [...new Set(urls)];
543
+ const loaded = await Promise.all(unique.map((url) => loadImage(url, root)));
544
+ return new Map(unique.map((url, i) => [url, loaded[i]]));
545
+ }
546
+ function imageUrls(tree) {
547
+ const definitions = collectDefinitions(tree);
548
+ const urls = [];
549
+ walk(tree, (node) => {
550
+ if (node.type === "image") urls.push(node.url);
551
+ else if (node.type === "imageReference") {
552
+ const def = definitions.get(node.identifier);
553
+ if (def) urls.push(def.url);
554
+ }
555
+ });
556
+ return urls;
557
+ }
558
+ function applyEdits(source, edits) {
559
+ const sorted = [...edits].sort((a, b) => a.start - b.start || a.end - b.end);
560
+ let out = "";
561
+ let pos = 0;
562
+ for (const edit of sorted) {
563
+ if (edit.start < pos) continue;
564
+ out += source.slice(pos, edit.start) + edit.text;
565
+ pos = edit.end;
566
+ }
567
+ return out + source.slice(pos);
568
+ }
569
+ function markdownUrl(url) {
570
+ return /[\s()<>]/.test(url) ? `<${url.replace(/</g, "%3C").replace(/>/g, "%3E")}>` : url;
571
+ }
572
+ function markdownTitle(title) {
573
+ return title ? ` "${title.replace(/["\\]/g, "\\$&")}"` : "";
574
+ }
575
+ function markdownAlt(alt) {
576
+ return alt.replace(/[\\[\]]/g, "\\$&").replace(/\s*\n\s*/g, " ");
577
+ }
578
+ var ALERT_TYPES, ALERT_COLORS, ZH, EN, ALERT_RE, CJK_RE, MIME, EXT_TYPE, SCHEME_RE, WINDOWS_PATH_RE;
579
+ var init_common = __esm({
580
+ "src/node/export/common.ts"() {
581
+ "use strict";
582
+ init_annotations();
583
+ init_markdown();
584
+ ALERT_TYPES = ["note", "tip", "important", "warning", "caution"];
585
+ ALERT_COLORS = {
586
+ note: "0969DA",
587
+ tip: "1A7F37",
588
+ important: "8250DF",
589
+ warning: "9A6700",
590
+ caution: "CF222E"
591
+ };
592
+ ZH = {
593
+ contents: "\u76EE\u9304",
594
+ untitled: "\u672A\u547D\u540D",
595
+ image: "\u5716",
596
+ footnotes: "\u8A3B\u91CB",
597
+ backToText: "\u56DE\u5230\u5167\u6587",
598
+ frontMatter: "\u524D\u8A00\u7D20\u6750",
599
+ alerts: { note: "\u6CE8\u610F", tip: "\u63D0\u793A", important: "\u91CD\u8981", warning: "\u8B66\u544A", caution: "\u5C0F\u5FC3" }
600
+ };
601
+ EN = {
602
+ contents: "Contents",
603
+ untitled: "Untitled",
604
+ image: "Image",
605
+ footnotes: "Notes",
606
+ backToText: "Back to text",
607
+ frontMatter: "Front matter",
608
+ alerts: {
609
+ note: "Note",
610
+ tip: "Tip",
611
+ important: "Important",
612
+ warning: "Warning",
613
+ caution: "Caution"
614
+ }
615
+ };
616
+ ALERT_RE = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\r?\n[ \t]*|$)/i;
617
+ CJK_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul} -〿＀-￯⺀-⿟]/u;
618
+ MIME = {
619
+ png: "image/png",
620
+ jpg: "image/jpeg",
621
+ gif: "image/gif",
622
+ bmp: "image/bmp",
623
+ svg: "image/svg+xml",
624
+ webp: "image/webp",
625
+ avif: "image/avif",
626
+ ico: "image/x-icon",
627
+ tiff: "image/tiff"
628
+ };
629
+ EXT_TYPE = {
630
+ ".png": "png",
631
+ ".jpg": "jpg",
632
+ ".jpeg": "jpg",
633
+ ".gif": "gif",
634
+ ".bmp": "bmp",
635
+ ".svg": "svg",
636
+ ".webp": "webp",
637
+ ".avif": "avif",
638
+ ".ico": "ico",
639
+ ".tif": "tiff",
640
+ ".tiff": "tiff"
641
+ };
642
+ SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
643
+ WINDOWS_PATH_RE = /^[a-z]:[\\/]/i;
644
+ }
645
+ });
646
+
647
+ // src/node/export/docx.ts
648
+ import {
649
+ AlignmentType,
650
+ Bookmark,
651
+ BookmarkEnd,
652
+ BookmarkStart,
653
+ BorderStyle,
654
+ Document,
655
+ DocumentDefaults,
656
+ ExternalHyperlink,
657
+ Footer,
658
+ FootnoteReferenceRun,
659
+ Header,
660
+ ImageRun,
661
+ InternalHyperlink,
662
+ LevelFormat,
663
+ LineRuleType,
664
+ NumberFormat,
665
+ Packer,
666
+ PageNumber,
667
+ PageOrientation,
668
+ Paragraph,
669
+ SectionType,
670
+ ShadingType,
671
+ StyleForCharacter,
672
+ StyleForParagraph,
673
+ Table,
674
+ TableCell,
675
+ TableLayoutType,
676
+ TableOfContents,
677
+ TableRow,
678
+ TextRun,
679
+ VerticalAlignTable,
680
+ WidthType,
681
+ XmlAttributeComponent
682
+ } from "docx";
683
+ function xmlText(text) {
684
+ return text.replace(INVALID_XML_RE, "");
685
+ }
686
+ function bookmark(name, id, children) {
687
+ const mark = new Bookmark({ id: name, children });
688
+ Object.assign(mark, { start: new BookmarkStart(name, id), end: new BookmarkEnd(id) });
689
+ return mark;
690
+ }
691
+ function columnWidths(table, cols, available) {
692
+ const weights = Array.from({ length: cols }, () => 4);
693
+ for (const row of table.children) {
694
+ row.children.forEach((cell, c) => {
695
+ let width = 0;
696
+ for (const char of textOf(cell)) width += char.charCodeAt(0) > 11903 ? 2 : 1;
697
+ weights[c] = Math.max(weights[c], Math.min(width, 40));
698
+ });
699
+ }
700
+ const total = weights.reduce((a, b) => a + b, 0);
701
+ const minimum = available / cols / 3;
702
+ const raw = weights.map((w) => Math.max(minimum, available * w / total));
703
+ const scale = available / raw.reduce((a, b) => a + b, 0);
704
+ const widths = raw.map((w) => Math.floor(w * scale));
705
+ widths[cols - 1] += available - widths.reduce((a, b) => a + b, 0);
706
+ return widths;
707
+ }
708
+ async function buildDocx(input) {
709
+ return new DocxBuilder(input).build();
710
+ }
711
+ var TWIPS_PER_MM, PX_PER_MM, TWIPS_PER_PX, DOCX_IMAGE_TYPES, BULLETS, ORDERED_FORMATS, NO_SPACE_BEFORE, twips, halfPoints, INVALID_XML_RE, DefaultStyleAttributes, DefaultParagraphStyle, HTML_FORMAT, DocxBuilder;
712
+ var init_docx = __esm({
713
+ "src/node/export/docx.ts"() {
714
+ "use strict";
715
+ init_config();
716
+ init_markdown();
717
+ init_common();
718
+ TWIPS_PER_MM = 1440 / 25.4;
719
+ PX_PER_MM = 96 / 25.4;
720
+ TWIPS_PER_PX = 15;
721
+ DOCX_IMAGE_TYPES = /* @__PURE__ */ new Set(["png", "jpg", "gif", "bmp"]);
722
+ BULLETS = ["\u2022", "\u25E6", "\u25AA"];
723
+ ORDERED_FORMATS = [LevelFormat.DECIMAL, LevelFormat.LOWER_LETTER, LevelFormat.LOWER_ROMAN];
724
+ NO_SPACE_BEFORE = /* @__PURE__ */ new Set(["BodyText", "ListParagraph", "ListContinue", "Quote", "Callout"]);
725
+ twips = (mm) => Math.round(mm * TWIPS_PER_MM);
726
+ halfPoints = (pt) => Math.max(2, Math.round(pt * 2));
727
+ INVALID_XML_RE = // biome-ignore lint/suspicious/noControlCharactersInRegex: these are exactly the characters to drop
728
+ /[\u0000-\u0008\u000B\u000C\u000E-\u001F￾￿]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
729
+ DefaultStyleAttributes = class extends XmlAttributeComponent {
730
+ xmlKeys = { type: "w:type", styleId: "w:styleId", default: "w:default" };
731
+ };
732
+ DefaultParagraphStyle = class extends StyleForParagraph {
733
+ constructor(options) {
734
+ super(options);
735
+ this.root[0] = new DefaultStyleAttributes({
736
+ type: "paragraph",
737
+ styleId: options.id,
738
+ default: true
739
+ });
740
+ }
741
+ };
742
+ HTML_FORMAT = {
743
+ b: "bold",
744
+ strong: "bold",
745
+ i: "italics",
746
+ em: "italics",
747
+ u: "underline",
748
+ ins: "underline",
749
+ s: "strike",
750
+ del: "strike",
751
+ strike: "strike",
752
+ sup: "superScript",
753
+ sub: "subScript",
754
+ mark: "highlight",
755
+ code: "code",
756
+ kbd: "code"
757
+ };
758
+ DocxBuilder = class {
759
+ constructor(input) {
760
+ this.input = input;
761
+ const config = input.config;
762
+ this.e = config.export;
763
+ this.labels = labelsFor(config.language);
764
+ const size = pageSizeMm(this.e.pageSize);
765
+ const m = this.e.margins;
766
+ const widthMm = Math.max(20, size.width - m.left - m.right);
767
+ const heightMm = Math.max(20, size.height - m.top - m.bottom);
768
+ this.charTwips = Math.round(this.e.fontSize * 20);
769
+ this.contentWidth = twips(widthMm);
770
+ this.contentHeight = twips(heightMm);
771
+ this.contentWidthPx = widthMm * PX_PER_MM;
772
+ this.contentHeightPx = heightMm * PX_PER_MM;
773
+ this.quoteIndent = this.charTwips * 2;
774
+ this.calloutIndent = Math.round(this.charTwips / 2);
775
+ }
776
+ input;
777
+ e;
778
+ labels;
779
+ charTwips;
780
+ contentWidth;
781
+ contentHeight;
782
+ contentWidthPx;
783
+ contentHeightPx;
784
+ quoteIndent;
785
+ calloutIndent;
786
+ images = /* @__PURE__ */ new Map();
787
+ definitions = /* @__PURE__ */ new Map();
788
+ footnoteDefs = /* @__PURE__ */ new Map();
789
+ footnotes = {};
790
+ footnoteCount = 0;
791
+ orderedLists = [];
792
+ toc = [];
793
+ bookmarkCount = 0;
794
+ /** A page break is due before the next body paragraph. */
795
+ breakNext = false;
796
+ /** The last body block was a table: the next paragraph needs air above it. */
797
+ afterTable = false;
798
+ get config() {
799
+ return this.input.config;
800
+ }
801
+ async build() {
802
+ const trees = this.input.chapters.map((c) => prepareChapter(c.source));
803
+ this.images = await loadImages(
804
+ trees.flatMap((t) => imageUrls(t)),
805
+ this.input.root
806
+ );
807
+ const perChapter = this.e.header === "chapter";
808
+ const bodyCtx = {
809
+ style: "BodyText",
810
+ indent: 0,
811
+ listLevel: 0,
812
+ body: true,
813
+ inFootnote: false
814
+ };
815
+ const chapters = [];
816
+ trees.forEach((tree, i) => {
817
+ this.definitions = collectDefinitions(tree);
818
+ this.footnoteDefs = collectFootnotes(tree);
819
+ if (i > 0 && this.e.chapterPageBreak) this.breakNext = !perChapter;
820
+ chapters.push({
821
+ title: this.chapterTitle(tree, this.input.chapters[i].id),
822
+ children: this.blocks(tree.children, bodyCtx)
823
+ });
824
+ });
825
+ this.breakNext = false;
826
+ const doc = new Document({
827
+ title: this.config.title || void 0,
828
+ subject: this.config.subtitle || void 0,
829
+ creator: this.config.author || "MoSage",
830
+ lastModifiedBy: "MoSage",
831
+ description: "Exported by MoSage",
832
+ features: this.e.toc ? { updateFields: true } : void 0,
833
+ // A justified line that ends in a manual line break is not stretched.
834
+ compatibility: { doNotExpandShiftReturn: true },
835
+ styles: { importedStyles: this.styles() },
836
+ numbering: { config: this.numbering() },
837
+ footnotes: this.footnotes,
838
+ sections: this.sections(chapters)
839
+ });
840
+ return Packer.toBuffer(doc);
841
+ }
842
+ // -------------------------------------------------------------------------
843
+ // Sections
844
+ sections(chapters) {
845
+ const size = pageSizeMm(this.e.pageSize);
846
+ const m = this.e.margins;
847
+ const page = {
848
+ size: {
849
+ width: twips(size.width),
850
+ height: twips(size.height),
851
+ orientation: PageOrientation.PORTRAIT
852
+ },
853
+ margin: {
854
+ top: twips(m.top),
855
+ bottom: twips(m.bottom),
856
+ left: twips(m.left),
857
+ right: twips(m.right),
858
+ header: twips(Math.min(12.5, m.top / 2)),
859
+ footer: twips(Math.min(12.5, m.bottom / 2))
860
+ }
861
+ };
862
+ const sections = [];
863
+ if (this.e.titlePage) {
864
+ sections.push({
865
+ properties: { page },
866
+ children: this.titlePage()
867
+ });
868
+ }
869
+ if (this.e.toc) {
870
+ sections.push({
871
+ properties: { page, type: SectionType.NEXT_PAGE },
872
+ children: this.tableOfContents()
873
+ });
874
+ }
875
+ const footers = this.e.pageNumbers ? {
876
+ default: new Footer({
877
+ children: [
878
+ new Paragraph({
879
+ style: "Footer",
880
+ children: [new TextRun({ children: [PageNumber.CURRENT] })]
881
+ })
882
+ ]
883
+ })
884
+ } : void 0;
885
+ const header = (text) => text.trim() ? {
886
+ default: new Header({
887
+ children: [
888
+ new Paragraph({ style: "Header", children: [new TextRun(xmlText(text))] })
889
+ ]
890
+ })
891
+ } : void 0;
892
+ const firstPage = { ...page, pageNumbers: { start: 1, formatType: NumberFormat.DECIMAL } };
893
+ const nonEmpty = (children) => children.length ? children : [new Paragraph({})];
894
+ if (this.e.header === "chapter") {
895
+ chapters.forEach((chapter, i) => {
896
+ sections.push({
897
+ properties: {
898
+ page: i === 0 ? firstPage : page,
899
+ type: i === 0 || this.e.chapterPageBreak ? SectionType.NEXT_PAGE : SectionType.CONTINUOUS
900
+ },
901
+ headers: header(chapter.title),
902
+ footers,
903
+ children: nonEmpty(chapter.children)
904
+ });
905
+ });
906
+ } else if (chapters.length || !sections.length) {
907
+ sections.push({
908
+ properties: { page: firstPage, type: SectionType.NEXT_PAGE },
909
+ headers: this.e.header === "title" ? header(this.config.title) : void 0,
910
+ footers,
911
+ children: nonEmpty(chapters.flatMap((c) => c.children))
912
+ });
913
+ }
914
+ return sections;
915
+ }
916
+ titlePage() {
917
+ const c = this.config;
918
+ const out = [
919
+ new Paragraph({
920
+ style: "Title",
921
+ children: [new TextRun(xmlText(c.title || this.labels.untitled))]
922
+ })
923
+ ];
924
+ if (c.subtitle.trim()) {
925
+ out.push(new Paragraph({ style: "Subtitle", children: [new TextRun(xmlText(c.subtitle))] }));
926
+ }
927
+ if (c.author.trim()) {
928
+ out.push(new Paragraph({ style: "Author", children: [new TextRun(xmlText(c.author))] }));
929
+ }
930
+ return out;
931
+ }
932
+ tableOfContents() {
933
+ const entries = this.toc.map(
934
+ (entry) => new Paragraph({
935
+ style: `TOC${entry.level}`,
936
+ children: [
937
+ new InternalHyperlink({
938
+ anchor: entry.anchor,
939
+ children: [new TextRun(xmlText(entry.title))]
940
+ })
941
+ ]
942
+ })
943
+ );
944
+ return [
945
+ new Paragraph({ style: "TOCHeading", children: [new TextRun(this.labels.contents)] }),
946
+ new TableOfContents(this.labels.contents, {
947
+ hyperlink: true,
948
+ headingStyleRange: "1-3",
949
+ hideTabAndPageNumbersInWebView: true,
950
+ useAppliedParagraphOutlineLevel: true,
951
+ contentChildren: entries
952
+ })
953
+ ];
954
+ }
955
+ chapterTitle(tree, id) {
956
+ const h1 = tree.children.find((n) => n.type === "heading" && n.depth === 1);
957
+ const title = h1 ? joinSoftBreaks(textOf(h1)).trim() : "";
958
+ return title || id.replace(/\.md$/i, "");
959
+ }
960
+ // -------------------------------------------------------------------------
961
+ // Blocks
962
+ paragraph(ctx, options) {
963
+ let extra = {};
964
+ if (ctx.body && this.breakNext) {
965
+ extra = { ...extra, pageBreakBefore: true };
966
+ this.breakNext = false;
967
+ }
968
+ if (ctx.body && this.afterTable) {
969
+ this.afterTable = false;
970
+ if (!options.spacing && NO_SPACE_BEFORE.has(options.style)) {
971
+ extra = { ...extra, spacing: { before: Math.round(this.charTwips * 0.75) } };
972
+ }
973
+ }
974
+ if (!options.numbering && !options.indent && ctx.indent > 0) {
975
+ extra = { ...extra, indent: { left: ctx.indent + this.styleIndent(options.style) } };
976
+ }
977
+ if (!options.numbering && ctx.border && options.style === "Callout") {
978
+ extra = { ...extra, border: { left: ctx.border } };
979
+ }
980
+ return new Paragraph({ ...options, ...extra });
981
+ }
982
+ styleIndent(style) {
983
+ if (style === "Quote") return this.quoteIndent;
984
+ if (style === "Callout") return this.calloutIndent;
985
+ return 0;
986
+ }
987
+ blocks(nodes, ctx) {
988
+ const out = [];
989
+ for (const node of nodes) {
990
+ for (const child of this.block(node, ctx)) {
991
+ if (child instanceof Table && out.at(-1) instanceof Table) {
992
+ out.push(new Paragraph({ style: ctx.style }));
993
+ }
994
+ out.push(child);
995
+ }
996
+ }
997
+ return out;
998
+ }
999
+ block(node, ctx) {
1000
+ switch (node.type) {
1001
+ case "paragraph":
1002
+ return this.paragraphBlock(node, ctx);
1003
+ case "heading":
1004
+ return [this.heading(node, ctx)];
1005
+ case "thematicBreak":
1006
+ return [
1007
+ this.paragraph(ctx, {
1008
+ style: ctx.body ? "SceneBreak" : ctx.style,
1009
+ alignment: ctx.body ? void 0 : AlignmentType.CENTER,
1010
+ children: [this.textRun(this.e.sceneBreak, {})]
1011
+ })
1012
+ ];
1013
+ case "blockquote": {
1014
+ const alert = readAlert(node);
1015
+ if (alert) return this.callout(alert.type, alert.children, ctx);
1016
+ if (!ctx.body) return this.blocks(node.children, ctx);
1017
+ const quoteCtx = ctx.style === "Quote" ? { ...ctx, indent: ctx.indent + this.quoteIndent } : { ...ctx, style: "Quote", border: void 0 };
1018
+ return this.blocks(node.children, quoteCtx);
1019
+ }
1020
+ case "list":
1021
+ return this.list(node, ctx);
1022
+ case "code":
1023
+ return [this.codeBlock(node, ctx)];
1024
+ case "table":
1025
+ return this.table(node, ctx);
1026
+ case "html":
1027
+ if (ctx.body && isPageBreak(node)) this.breakNext = true;
1028
+ return [];
1029
+ case "definition":
1030
+ case "footnoteDefinition":
1031
+ case "yaml":
1032
+ return [];
1033
+ default: {
1034
+ const text = joinSoftBreaks(textOf(node)).trim();
1035
+ return text ? [this.paragraph(ctx, { style: ctx.style, children: [this.textRun(text, {})] })] : [];
1036
+ }
1037
+ }
1038
+ }
1039
+ paragraphBlock(node, ctx) {
1040
+ const figure = this.figureOf(node);
1041
+ if (figure && ctx.body) return this.figure(figure, ctx);
1042
+ return [
1043
+ this.paragraph(ctx, { style: ctx.style, children: this.inlines(node.children, {}, ctx) })
1044
+ ];
1045
+ }
1046
+ /** The image of a paragraph that holds nothing else. */
1047
+ figureOf(node) {
1048
+ const content = node.children.filter((c) => !(c.type === "text" && !c.value.trim()));
1049
+ if (content.length !== 1) return null;
1050
+ const only = content[0];
1051
+ if (only.type === "image") {
1052
+ return { url: only.url, alt: only.alt ?? "", title: only.title ?? void 0 };
1053
+ }
1054
+ if (only.type === "imageReference") {
1055
+ const def = this.definitions.get(only.identifier);
1056
+ if (def) return { url: def.url, alt: only.alt ?? "", title: def.title ?? void 0 };
1057
+ }
1058
+ return null;
1059
+ }
1060
+ figure(img, ctx) {
1061
+ const alt = joinSoftBreaks(img.alt).trim();
1062
+ const run2 = this.imageRun(img.url, alt, img.title, ctx);
1063
+ if (!run2) {
1064
+ return [
1065
+ this.paragraph(ctx, {
1066
+ style: "Figure",
1067
+ children: [this.textRun(imagePlaceholder(alt, this.labels), {})]
1068
+ })
1069
+ ];
1070
+ }
1071
+ const out = [
1072
+ this.paragraph(ctx, { style: "Figure", keepNext: alt ? true : void 0, children: [run2] })
1073
+ ];
1074
+ if (alt) out.push(this.paragraph(ctx, { style: "Caption", children: [this.textRun(alt, {})] }));
1075
+ return out;
1076
+ }
1077
+ heading(node, ctx) {
1078
+ if (!ctx.body) {
1079
+ return this.paragraph(ctx, {
1080
+ style: ctx.style,
1081
+ children: this.inlines(node.children, { bold: true }, ctx)
1082
+ });
1083
+ }
1084
+ let children = this.inlines(node.children, {}, ctx);
1085
+ const title = joinSoftBreaks(textOf(node)).trim();
1086
+ if (node.depth <= 3 && title) {
1087
+ const id = ++this.bookmarkCount;
1088
+ const anchor = `_Toc${String(id).padStart(8, "0")}`;
1089
+ this.toc.push({ level: node.depth, title, anchor });
1090
+ children = [bookmark(anchor, id, children)];
1091
+ }
1092
+ return this.paragraph(
1093
+ { ...ctx, border: void 0 },
1094
+ { style: `Heading${Math.min(node.depth, 4)}`, children }
1095
+ );
1096
+ }
1097
+ callout(type, children, ctx) {
1098
+ if (!ctx.body) {
1099
+ return [
1100
+ this.paragraph(ctx, {
1101
+ style: ctx.style,
1102
+ children: [this.textRun(this.labels.alerts[type], { bold: true })]
1103
+ }),
1104
+ ...this.blocks(children, ctx)
1105
+ ];
1106
+ }
1107
+ const border = {
1108
+ style: BorderStyle.SINGLE,
1109
+ size: 24,
1110
+ color: ALERT_COLORS[type],
1111
+ space: 8
1112
+ };
1113
+ const inner = { ...ctx, style: "Callout", border };
1114
+ return [
1115
+ this.paragraph(inner, {
1116
+ style: "Callout",
1117
+ keepNext: true,
1118
+ children: [this.textRun(this.labels.alerts[type], { bold: true })]
1119
+ }),
1120
+ ...this.blocks(children, inner)
1121
+ ];
1122
+ }
1123
+ codeBlock(node, ctx) {
1124
+ const lines = node.value.replace(/\t/g, " ").split(/\r?\n/);
1125
+ const code = !ctx.body;
1126
+ const children = lines.map(
1127
+ (line, i) => new TextRun({
1128
+ text: xmlText(line),
1129
+ break: i > 0 ? 1 : void 0,
1130
+ style: code ? "InlineCode" : void 0
1131
+ })
1132
+ );
1133
+ return this.paragraph(ctx, { style: ctx.body ? "Code" : ctx.style, children });
1134
+ }
1135
+ listLeft(level) {
1136
+ return this.charTwips * 2 * (level + 1);
1137
+ }
1138
+ get hanging() {
1139
+ return Math.round(this.charTwips * 1.5);
1140
+ }
1141
+ list(node, ctx) {
1142
+ const level = Math.min(ctx.listLevel, 8);
1143
+ let reference = "bullet";
1144
+ if (node.ordered) {
1145
+ reference = `ordered-${this.orderedLists.length + 1}`;
1146
+ this.orderedLists.push({ reference, level, start: node.start ?? 1 });
1147
+ }
1148
+ const style = ctx.body ? "ListParagraph" : ctx.style;
1149
+ const itemCtx = {
1150
+ ...ctx,
1151
+ // Later paragraphs of an item: no bullet, aligned with the item's text.
1152
+ style: ctx.body ? "ListContinue" : ctx.style,
1153
+ listLevel: level + 1,
1154
+ indent: this.listLeft(level),
1155
+ border: void 0
1156
+ };
1157
+ const numbering = { reference, level };
1158
+ const out = [];
1159
+ for (const item of node.children) {
1160
+ const box = item.checked === true ? "\u2611 " : item.checked === false ? "\u2610 " : "";
1161
+ const lead = box ? [this.textRun(box, {})] : [];
1162
+ let numbered = false;
1163
+ for (const child of item.children) {
1164
+ if (!numbered) {
1165
+ numbered = true;
1166
+ if (child.type === "paragraph") {
1167
+ out.push(
1168
+ this.paragraph(ctx, {
1169
+ style,
1170
+ numbering,
1171
+ children: [...lead, ...this.inlines(child.children, {}, itemCtx)]
1172
+ })
1173
+ );
1174
+ continue;
1175
+ }
1176
+ out.push(this.paragraph(ctx, { style, numbering, children: lead }));
1177
+ }
1178
+ out.push(...this.blocks([child], itemCtx));
1179
+ }
1180
+ if (!numbered) out.push(this.paragraph(ctx, { style, numbering, children: lead }));
1181
+ }
1182
+ return out;
1183
+ }
1184
+ table(node, ctx) {
1185
+ const rows = node.children;
1186
+ if (!rows.length) return [];
1187
+ const out = [];
1188
+ if (ctx.body && this.breakNext) out.push(this.paragraph(ctx, { style: ctx.style }));
1189
+ const cols = Math.max(1, ...rows.map((r) => r.children.length));
1190
+ const available = Math.max(this.contentWidth - ctx.indent, 1440);
1191
+ const widths = columnWidths(node, cols, available);
1192
+ const cellCtx = {
1193
+ style: "TableText",
1194
+ indent: 0,
1195
+ listLevel: 0,
1196
+ body: false,
1197
+ inFootnote: ctx.inFootnote,
1198
+ maxImagePx: available / TWIPS_PER_PX / cols * 0.9
1199
+ };
1200
+ const thin = { style: BorderStyle.SINGLE, size: 4, color: "808080" };
1201
+ const tableRows = rows.map(
1202
+ (row, r) => new TableRow({
1203
+ tableHeader: r === 0 ? true : void 0,
1204
+ children: Array.from({ length: cols }, (_, c) => {
1205
+ const cell = row.children[c];
1206
+ const align = node.align?.[c];
1207
+ return new TableCell({
1208
+ width: { size: widths[c], type: WidthType.DXA },
1209
+ verticalAlign: VerticalAlignTable.CENTER,
1210
+ shading: r === 0 ? { type: ShadingType.CLEAR, color: "auto", fill: "F2F2F2" } : void 0,
1211
+ children: [
1212
+ new Paragraph({
1213
+ style: "TableText",
1214
+ alignment: align === "center" ? AlignmentType.CENTER : align === "right" ? AlignmentType.RIGHT : align === "left" ? AlignmentType.LEFT : void 0,
1215
+ children: cell ? this.inlines(cell.children, r === 0 ? { bold: true } : {}, cellCtx) : []
1216
+ })
1217
+ ]
1218
+ });
1219
+ })
1220
+ })
1221
+ );
1222
+ out.push(
1223
+ new Table({
1224
+ rows: tableRows,
1225
+ width: { size: widths.reduce((a, b) => a + b, 0), type: WidthType.DXA },
1226
+ columnWidths: widths,
1227
+ layout: TableLayoutType.FIXED,
1228
+ indent: ctx.indent > 0 ? { size: ctx.indent, type: WidthType.DXA } : void 0,
1229
+ margins: { top: 40, bottom: 40, left: 100, right: 100 },
1230
+ borders: {
1231
+ top: thin,
1232
+ bottom: thin,
1233
+ left: thin,
1234
+ right: thin,
1235
+ insideHorizontal: thin,
1236
+ insideVertical: thin
1237
+ }
1238
+ })
1239
+ );
1240
+ if (ctx.body) this.afterTable = true;
1241
+ return out;
1242
+ }
1243
+ // -------------------------------------------------------------------------
1244
+ // Inline content
1245
+ textRun(text, fmt) {
1246
+ return new TextRun({
1247
+ text: xmlText(text),
1248
+ bold: fmt.bold || void 0,
1249
+ italics: fmt.italics || void 0,
1250
+ strike: fmt.strike || void 0,
1251
+ superScript: fmt.superScript || void 0,
1252
+ subScript: fmt.subScript || void 0,
1253
+ underline: fmt.underline ? {} : void 0,
1254
+ highlight: fmt.highlight ? "yellow" : void 0,
1255
+ style: fmt.code ? "InlineCode" : fmt.link ? "Hyperlink" : void 0
1256
+ });
1257
+ }
1258
+ inlines(nodes, fmt, ctx) {
1259
+ const out = [];
1260
+ let local = { ...fmt };
1261
+ for (const node of nodes) {
1262
+ switch (node.type) {
1263
+ case "text": {
1264
+ const text = joinSoftBreaks(node.value);
1265
+ if (text) out.push(this.textRun(text, local));
1266
+ break;
1267
+ }
1268
+ case "emphasis":
1269
+ out.push(...this.inlines(node.children, { ...local, italics: true }, ctx));
1270
+ break;
1271
+ case "strong":
1272
+ out.push(...this.inlines(node.children, { ...local, bold: true }, ctx));
1273
+ break;
1274
+ case "delete":
1275
+ out.push(...this.inlines(node.children, { ...local, strike: true }, ctx));
1276
+ break;
1277
+ case "inlineCode":
1278
+ out.push(this.textRun(node.value, { ...local, code: true }));
1279
+ break;
1280
+ case "break":
1281
+ out.push(new TextRun({ break: 1 }));
1282
+ break;
1283
+ case "link":
1284
+ out.push(...this.link(node.url, node.children, local, ctx));
1285
+ break;
1286
+ case "linkReference": {
1287
+ const def = this.definitions.get(node.identifier);
1288
+ if (def) out.push(...this.link(def.url, node.children, local, ctx));
1289
+ else out.push(...this.inlines(node.children, local, ctx));
1290
+ break;
1291
+ }
1292
+ case "image":
1293
+ out.push(this.inlineImage(node.url, node.alt ?? "", node.title ?? void 0, local, ctx));
1294
+ break;
1295
+ case "imageReference": {
1296
+ const def = this.definitions.get(node.identifier);
1297
+ if (def) {
1298
+ out.push(this.inlineImage(def.url, node.alt ?? "", def.title ?? void 0, local, ctx));
1299
+ } else if (node.alt) {
1300
+ out.push(this.textRun(node.alt, local));
1301
+ }
1302
+ break;
1303
+ }
1304
+ case "footnoteReference":
1305
+ out.push(this.footnoteRef(node, ctx));
1306
+ break;
1307
+ case "html":
1308
+ local = this.inlineHtml(node.value, local, fmt, out);
1309
+ break;
1310
+ default: {
1311
+ const text = joinSoftBreaks(textOf(node));
1312
+ if (text) out.push(this.textRun(text, local));
1313
+ }
1314
+ }
1315
+ }
1316
+ return out;
1317
+ }
1318
+ /** `<br>` becomes a line break; `<sup>`, `<u>`, `<mark>`… toggle formatting; other tags vanish. */
1319
+ inlineHtml(value, local, parent, out) {
1320
+ const tag = /^<\s*(\/)?\s*([a-z][a-z0-9]*)\b[^>]*>$/i.exec(value.trim());
1321
+ if (!tag) return local;
1322
+ const name = tag[2].toLowerCase();
1323
+ if (name === "br") {
1324
+ out.push(new TextRun({ break: 1 }));
1325
+ return local;
1326
+ }
1327
+ const key = HTML_FORMAT[name];
1328
+ if (!key) return local;
1329
+ return { ...local, [key]: tag[1] ? parent[key] : true };
1330
+ }
1331
+ link(url, children, fmt, ctx) {
1332
+ const href = url.trim();
1333
+ if (!/^https?:\/\//i.test(href)) return this.inlines(children, fmt, ctx);
1334
+ const inner = this.inlines(children, { ...fmt, link: true }, ctx);
1335
+ return [
1336
+ new ExternalHyperlink({
1337
+ link: href,
1338
+ children: inner.length ? inner : [this.textRun(href, { ...fmt, link: true })]
1339
+ })
1340
+ ];
1341
+ }
1342
+ inlineImage(url, alt, title, fmt, ctx) {
1343
+ const text = joinSoftBreaks(alt).trim();
1344
+ return this.imageRun(url, text, title, ctx) ?? this.textRun(imagePlaceholder(text, this.labels), fmt);
1345
+ }
1346
+ imageRun(url, alt, title, ctx) {
1347
+ const image = this.images.get(url);
1348
+ if (!image || !DOCX_IMAGE_TYPES.has(image.type) || !image.width || !image.height) return null;
1349
+ const maxWidth = Math.max(
1350
+ 24,
1351
+ Math.min(
1352
+ ctx.maxImagePx ?? Number.POSITIVE_INFINITY,
1353
+ this.contentWidthPx - ctx.indent / TWIPS_PER_PX
1354
+ )
1355
+ );
1356
+ const maxHeight = this.contentHeightPx * 0.8;
1357
+ const scale = Math.min(1, maxWidth / image.width, maxHeight / image.height);
1358
+ return new ImageRun({
1359
+ type: image.type,
1360
+ data: image.data,
1361
+ transformation: {
1362
+ width: Math.max(1, Math.round(image.width * scale)),
1363
+ height: Math.max(1, Math.round(image.height * scale))
1364
+ },
1365
+ altText: {
1366
+ name: xmlText(image.name),
1367
+ description: xmlText(alt),
1368
+ title: xmlText(title ?? alt)
1369
+ }
1370
+ });
1371
+ }
1372
+ footnoteRef(node, ctx) {
1373
+ const def = this.footnoteDefs.get(node.identifier);
1374
+ if (!def || ctx.inFootnote) {
1375
+ return this.textRun(
1376
+ def ? node.label ?? node.identifier : `[^${node.label ?? node.identifier}]`,
1377
+ {
1378
+ superScript: Boolean(def)
1379
+ }
1380
+ );
1381
+ }
1382
+ const id = ++this.footnoteCount;
1383
+ const noteCtx = {
1384
+ style: "FootnoteText",
1385
+ indent: 0,
1386
+ listLevel: 0,
1387
+ body: false,
1388
+ inFootnote: true
1389
+ };
1390
+ const paragraphs = this.blocks(def.children, noteCtx).filter(
1391
+ (b) => b instanceof Paragraph
1392
+ );
1393
+ if (!paragraphs.length) paragraphs.push(new Paragraph({ style: "FootnoteText" }));
1394
+ paragraphs[0].addRunToFront(new TextRun(" "));
1395
+ this.footnotes[id] = { children: paragraphs };
1396
+ return new FootnoteReferenceRun(id);
1397
+ }
1398
+ // -------------------------------------------------------------------------
1399
+ // Styles and numbering
1400
+ numbering() {
1401
+ const indent = (level) => ({
1402
+ paragraph: { indent: { left: this.listLeft(level), hanging: this.hanging } }
1403
+ });
1404
+ const levels = Array.from({ length: 9 }, (_, level) => level);
1405
+ return [
1406
+ {
1407
+ reference: "bullet",
1408
+ levels: levels.map((level) => ({
1409
+ level,
1410
+ format: LevelFormat.BULLET,
1411
+ text: BULLETS[level % BULLETS.length],
1412
+ alignment: AlignmentType.LEFT,
1413
+ style: indent(level)
1414
+ }))
1415
+ },
1416
+ ...this.orderedLists.map((list) => ({
1417
+ reference: list.reference,
1418
+ levels: levels.map((level) => ({
1419
+ level,
1420
+ format: ORDERED_FORMATS[level % ORDERED_FORMATS.length],
1421
+ text: `%${level + 1}.`,
1422
+ start: level === list.level ? list.start : 1,
1423
+ alignment: AlignmentType.LEFT,
1424
+ style: indent(level)
1425
+ }))
1426
+ }))
1427
+ ];
1428
+ }
1429
+ styles() {
1430
+ const e = this.e;
1431
+ const ch = this.charTwips;
1432
+ const size = halfPoints(e.fontSize);
1433
+ const after = Math.round(e.paragraphSpacing * 20);
1434
+ const cjk = isCjkLanguage(this.config.language);
1435
+ const hint = cjk ? "eastAsia" : void 0;
1436
+ const font = (eastAsia, latin = e.fonts.latin) => ({
1437
+ ascii: latin,
1438
+ hAnsi: latin,
1439
+ cs: latin,
1440
+ eastAsia,
1441
+ hint
1442
+ });
1443
+ const bodyFont = font(e.fonts.body);
1444
+ const headingFont = font(e.fonts.heading);
1445
+ const codeFont = font(e.fonts.body, e.fonts.code);
1446
+ const language = cjk ? { value: "en-US", eastAsia: this.config.language } : { value: this.config.language || "en-US", eastAsia: "zh-TW" };
1447
+ const single = { line: 240, lineRule: LineRuleType.AUTO };
1448
+ const headingLine = { line: 288, lineRule: LineRuleType.AUTO };
1449
+ const style = (id, name, options) => new StyleForParagraph({ id, name, basedOn: "Normal", quickFormat: true, ...options });
1450
+ const heading = (level, factor, before, afterSpace) => style(`Heading${level}`, `heading ${level}`, {
1451
+ next: "BodyText",
1452
+ run: { font: headingFont, size: halfPoints(e.fontSize * factor), bold: true },
1453
+ paragraph: {
1454
+ keepNext: true,
1455
+ keepLines: true,
1456
+ outlineLevel: level - 1,
1457
+ alignment: level === 1 ? AlignmentType.CENTER : AlignmentType.LEFT,
1458
+ spacing: { ...headingLine, before: Math.round(before), after: Math.round(afterSpace) }
1459
+ }
1460
+ });
1461
+ const toc = (level) => style(`TOC${level}`, `toc ${level}`, {
1462
+ next: "Normal",
1463
+ quickFormat: false,
1464
+ paragraph: {
1465
+ indent: { left: ch * 2 * (level - 1) },
1466
+ spacing: { before: level === 1 ? Math.round(ch / 2) : 0, after: 0 }
1467
+ }
1468
+ });
1469
+ return [
1470
+ new DocumentDefaults({
1471
+ run: { font: bodyFont, size, sizeComplexScript: size, language },
1472
+ paragraph: {
1473
+ spacing: { line: Math.round(240 * e.lineSpacing), lineRule: LineRuleType.AUTO }
1474
+ }
1475
+ }),
1476
+ new DefaultParagraphStyle({
1477
+ id: "Normal",
1478
+ name: "Normal",
1479
+ quickFormat: true,
1480
+ run: { font: bodyFont, size, sizeComplexScript: size, language },
1481
+ paragraph: {
1482
+ spacing: {
1483
+ line: Math.round(240 * e.lineSpacing),
1484
+ lineRule: LineRuleType.AUTO,
1485
+ before: 0,
1486
+ after: 0
1487
+ }
1488
+ }
1489
+ }),
1490
+ style("BodyText", "Body Text", {
1491
+ next: "BodyText",
1492
+ paragraph: {
1493
+ alignment: AlignmentType.BOTH,
1494
+ indent: { firstLine: Math.round(e.firstLineIndent * ch) },
1495
+ spacing: { after }
1496
+ }
1497
+ }),
1498
+ heading(1, 1.75, ch * 2, ch * 1.5),
1499
+ heading(2, 1.4, ch * 1.5, ch * 0.5),
1500
+ heading(3, 1.2, ch, ch * 0.5),
1501
+ heading(4, 1.05, ch * 0.75, ch * 0.25),
1502
+ style("Title", "Title", {
1503
+ next: "Subtitle",
1504
+ run: { font: headingFont, size: halfPoints(e.fontSize * 2.4), bold: true },
1505
+ paragraph: {
1506
+ alignment: AlignmentType.CENTER,
1507
+ // About a quarter down the page; section vAlign is ignored by LibreOffice.
1508
+ spacing: { ...headingLine, before: Math.round(this.contentHeight * 0.25), after: ch * 2 }
1509
+ }
1510
+ }),
1511
+ style("Subtitle", "Subtitle", {
1512
+ next: "Author",
1513
+ run: { font: headingFont, size: halfPoints(e.fontSize * 1.5) },
1514
+ paragraph: { alignment: AlignmentType.CENTER, spacing: { ...headingLine, after: ch } }
1515
+ }),
1516
+ style("Author", "Author", {
1517
+ run: { size: halfPoints(e.fontSize * 1.25) },
1518
+ paragraph: { alignment: AlignmentType.CENTER, spacing: { before: ch * 4, after: ch } }
1519
+ }),
1520
+ style("TOCHeading", "TOC Heading", {
1521
+ next: "Normal",
1522
+ run: { font: headingFont, size: halfPoints(e.fontSize * 1.75), bold: true },
1523
+ paragraph: {
1524
+ alignment: AlignmentType.CENTER,
1525
+ keepNext: true,
1526
+ spacing: { ...headingLine, before: ch * 2, after: ch * 1.5 }
1527
+ }
1528
+ }),
1529
+ toc(1),
1530
+ toc(2),
1531
+ toc(3),
1532
+ style("ListParagraph", "List Paragraph", {
1533
+ paragraph: { alignment: AlignmentType.BOTH, contextualSpacing: true, spacing: { after } }
1534
+ }),
1535
+ style("ListContinue", "List Continue", {
1536
+ paragraph: {
1537
+ alignment: AlignmentType.BOTH,
1538
+ indent: { left: this.listLeft(0) },
1539
+ spacing: { after }
1540
+ }
1541
+ }),
1542
+ style("Quote", "Quote", {
1543
+ next: "BodyText",
1544
+ run: { color: "404040" },
1545
+ paragraph: {
1546
+ alignment: AlignmentType.BOTH,
1547
+ indent: { left: this.quoteIndent, right: this.quoteIndent },
1548
+ spacing: { after: Math.max(after, 120) },
1549
+ border: { left: { style: BorderStyle.SINGLE, size: 12, color: "A6A6A6", space: 8 } }
1550
+ }
1551
+ }),
1552
+ style("Callout", "Callout", {
1553
+ next: "BodyText",
1554
+ paragraph: {
1555
+ alignment: AlignmentType.BOTH,
1556
+ contextualSpacing: true,
1557
+ indent: { left: this.calloutIndent, right: this.calloutIndent },
1558
+ spacing: { after: Math.max(after, 120) },
1559
+ shading: { type: ShadingType.CLEAR, color: "auto", fill: "F3F5F8" },
1560
+ border: { left: { style: BorderStyle.SINGLE, size: 24, color: "7F8C9A", space: 8 } }
1561
+ }
1562
+ }),
1563
+ style("Code", "Code", {
1564
+ next: "BodyText",
1565
+ run: { font: codeFont, size: halfPoints(e.fontSize * 0.85), noProof: true },
1566
+ paragraph: {
1567
+ alignment: AlignmentType.LEFT,
1568
+ spacing: { ...single, before: 60, after: Math.max(after, 120) },
1569
+ shading: { type: ShadingType.CLEAR, color: "auto", fill: "F2F2F2" }
1570
+ }
1571
+ }),
1572
+ style("Figure", "Figure", {
1573
+ next: "Caption",
1574
+ paragraph: {
1575
+ alignment: AlignmentType.CENTER,
1576
+ spacing: { before: 120, after: 60 }
1577
+ }
1578
+ }),
1579
+ style("Caption", "caption", {
1580
+ next: "BodyText",
1581
+ run: { size: halfPoints(e.fontSize * 0.85), color: "404040" },
1582
+ paragraph: {
1583
+ alignment: AlignmentType.CENTER,
1584
+ keepLines: true,
1585
+ spacing: { ...single, after: Math.max(after, 120) }
1586
+ }
1587
+ }),
1588
+ style("SceneBreak", "Scene Break", {
1589
+ next: "BodyText",
1590
+ paragraph: {
1591
+ alignment: AlignmentType.CENTER,
1592
+ spacing: { before: ch, after: ch + after }
1593
+ }
1594
+ }),
1595
+ style("TableText", "Table Text", {
1596
+ quickFormat: false,
1597
+ paragraph: { spacing: { ...single, before: 40, after: 40 } }
1598
+ }),
1599
+ style("FootnoteText", "footnote text", {
1600
+ quickFormat: false,
1601
+ run: { size: halfPoints(Math.max(8, e.fontSize - 2)) },
1602
+ paragraph: { alignment: AlignmentType.BOTH, spacing: { ...single, after: 0 } }
1603
+ }),
1604
+ style("Header", "header", {
1605
+ quickFormat: false,
1606
+ run: { size: halfPoints(e.fontSize * 0.75), color: "595959" },
1607
+ paragraph: { alignment: AlignmentType.CENTER, spacing: { ...single, after: 0 } }
1608
+ }),
1609
+ style("Footer", "footer", {
1610
+ quickFormat: false,
1611
+ run: { size: halfPoints(e.fontSize * 0.75) },
1612
+ paragraph: { alignment: AlignmentType.CENTER, spacing: { ...single, after: 0 } }
1613
+ }),
1614
+ new StyleForCharacter({
1615
+ id: "Hyperlink",
1616
+ name: "Hyperlink",
1617
+ basedOn: "DefaultParagraphFont",
1618
+ run: { color: "0563C1", underline: {} }
1619
+ }),
1620
+ new StyleForCharacter({
1621
+ id: "FootnoteReference",
1622
+ name: "footnote reference",
1623
+ basedOn: "DefaultParagraphFont",
1624
+ run: { superScript: true }
1625
+ }),
1626
+ new StyleForCharacter({
1627
+ id: "InlineCode",
1628
+ name: "Inline Code",
1629
+ basedOn: "DefaultParagraphFont",
1630
+ run: {
1631
+ font: codeFont,
1632
+ size: halfPoints(e.fontSize * 0.9),
1633
+ noProof: true,
1634
+ shading: { type: ShadingType.CLEAR, color: "auto", fill: "F2F2F2" }
1635
+ }
1636
+ })
1637
+ ];
1638
+ }
1639
+ };
1640
+ }
1641
+ });
1642
+
1643
+ // src/node/export/html.ts
1644
+ import rehypeStringify from "rehype-stringify";
1645
+ import remarkRehype from "remark-rehype";
1646
+ import { unified as unified2 } from "unified";
1647
+ function escapeHtml(text) {
1648
+ return text.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]);
1649
+ }
1650
+ function cssString(text) {
1651
+ return `"${text.replace(/[\r\n\f]/g, " ").replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/</g, "\\3c ")}"`;
1652
+ }
1653
+ function wrapInlineHtml(parent) {
1654
+ const kids = parent.children;
1655
+ for (let i = 0; i < kids.length; i++) {
1656
+ const node = kids[i];
1657
+ if (node.type !== "html") continue;
1658
+ const open = /^<([a-z]+)(?:\s[^<>]*)?>$/i.exec(node.value.trim());
1659
+ const tag = open?.[1].toLowerCase();
1660
+ if (!tag || !INLINE_TAGS.has(tag)) continue;
1661
+ const closing = new RegExp(`^</${tag}\\s*>$`, "i");
1662
+ const close = kids.findIndex(
1663
+ (n, j) => j > i && n.type === "html" && closing.test(n.value.trim())
1664
+ );
1665
+ if (close === -1) continue;
1666
+ const wrapped = {
1667
+ type: "emphasis",
1668
+ data: { hName: tag },
1669
+ children: kids.slice(i + 1, close)
1670
+ };
1671
+ kids.splice(i, close - i + 1, wrapped);
1672
+ }
1673
+ }
1674
+ function prefixFootnoteLabel(node, prefix) {
1675
+ if (node.type === "element") {
1676
+ const props = node.properties;
1677
+ if (props.id === "footnote-label") props.id = `${prefix}footnote-label`;
1678
+ if (Array.isArray(props.ariaDescribedBy)) {
1679
+ props.ariaDescribedBy = props.ariaDescribedBy.map(
1680
+ (v) => v === "footnote-label" ? `${prefix}footnote-label` : v
1681
+ );
1682
+ }
1683
+ }
1684
+ if ("children" in node) for (const child of node.children) prefixFootnoteLabel(child, prefix);
1685
+ }
1686
+ function element(tagName, className, children) {
1687
+ return {
1688
+ type: "element",
1689
+ tagName,
1690
+ properties: className ? { className: [className] } : {},
1691
+ children
1692
+ };
1693
+ }
1694
+ async function buildHtml(input) {
1695
+ return new HtmlBuilder(input).build();
1696
+ }
1697
+ var HTML_ESCAPES, round, INLINE_TAGS, HtmlBuilder;
1698
+ var init_html = __esm({
1699
+ "src/node/export/html.ts"() {
1700
+ "use strict";
1701
+ init_config();
1702
+ init_markdown();
1703
+ init_common();
1704
+ HTML_ESCAPES = {
1705
+ "&": "&amp;",
1706
+ "<": "&lt;",
1707
+ ">": "&gt;",
1708
+ '"': "&quot;",
1709
+ "'": "&#39;"
1710
+ };
1711
+ round = (n) => Math.round(n * 100) / 100;
1712
+ INLINE_TAGS = /* @__PURE__ */ new Set([
1713
+ "sup",
1714
+ "sub",
1715
+ "u",
1716
+ "ins",
1717
+ "mark",
1718
+ "s",
1719
+ "del",
1720
+ "b",
1721
+ "strong",
1722
+ "i",
1723
+ "em",
1724
+ "kbd",
1725
+ "small"
1726
+ ]);
1727
+ HtmlBuilder = class {
1728
+ constructor(input) {
1729
+ this.input = input;
1730
+ this.labels = labelsFor(input.config.language);
1731
+ }
1732
+ input;
1733
+ labels;
1734
+ images = /* @__PURE__ */ new Map();
1735
+ toc = [];
1736
+ async build() {
1737
+ const trees = this.input.chapters.map((c) => prepareChapter(c.source));
1738
+ this.images = await loadImages(
1739
+ trees.flatMap((t) => imageUrls(t)),
1740
+ this.input.root
1741
+ );
1742
+ const sections = [];
1743
+ for (const [i, tree] of trees.entries()) {
1744
+ const prefix = `ch${i + 1}-`;
1745
+ this.transform(tree, prefix);
1746
+ const processor2 = unified2().use(remarkRehype, {
1747
+ allowDangerousHtml: false,
1748
+ clobberPrefix: prefix,
1749
+ footnoteLabel: this.labels.footnotes,
1750
+ footnoteLabelTagName: "h2",
1751
+ footnoteLabelProperties: { className: ["footnotes-title"] },
1752
+ footnoteBackLabel: (n) => `${this.labels.backToText} ${n + 1}`
1753
+ }).use(rehypeStringify);
1754
+ const hast = await processor2.run(tree);
1755
+ prefixFootnoteLabel(hast, prefix);
1756
+ const body = processor2.stringify(hast);
1757
+ sections.push(
1758
+ `<section class="chapter" id="${prefix}start" data-chapter="${escapeHtml(this.input.chapters[i].id)}">
1759
+ ${body}
1760
+ </section>`
1761
+ );
1762
+ }
1763
+ const { config } = this.input;
1764
+ const title = config.title || this.labels.untitled;
1765
+ return [
1766
+ "<!DOCTYPE html>",
1767
+ `<html lang="${escapeHtml(config.language || "zh-TW")}">`,
1768
+ "<head>",
1769
+ '<meta charset="utf-8">',
1770
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
1771
+ '<meta name="generator" content="MoSage">',
1772
+ config.author ? `<meta name="author" content="${escapeHtml(config.author)}">` : "",
1773
+ `<title>${escapeHtml(title)}</title>`,
1774
+ `<style>
1775
+ ${this.css()}
1776
+ </style>`,
1777
+ "</head>",
1778
+ "<body>",
1779
+ config.export.titlePage ? this.titlePage() : "",
1780
+ config.export.toc ? this.contents() : "",
1781
+ "<main>",
1782
+ sections.join("\n"),
1783
+ "</main>",
1784
+ "</body>",
1785
+ "</html>",
1786
+ ""
1787
+ ].filter((line) => line !== "").join("\n");
1788
+ }
1789
+ titlePage() {
1790
+ const c = this.input.config;
1791
+ return [
1792
+ '<header class="title-page">',
1793
+ `<h1 class="book-title">${escapeHtml(c.title || this.labels.untitled)}</h1>`,
1794
+ c.subtitle ? `<p class="book-subtitle">${escapeHtml(c.subtitle)}</p>` : "",
1795
+ c.author ? `<p class="book-author">${escapeHtml(c.author)}</p>` : "",
1796
+ "</header>"
1797
+ ].filter(Boolean).join("\n");
1798
+ }
1799
+ contents() {
1800
+ const items = this.toc.map(
1801
+ (e) => `<li class="toc-${e.level}"><a href="#${escapeHtml(e.id)}">${escapeHtml(e.text)}</a></li>`
1802
+ ).join("\n");
1803
+ return `<nav class="toc" aria-labelledby="toc-title">
1804
+ <h2 id="toc-title" class="toc-title">${escapeHtml(this.labels.contents)}</h2>
1805
+ <ol class="toc-list">
1806
+ ${items}
1807
+ </ol>
1808
+ </nav>`;
1809
+ }
1810
+ /** Rewrite the mdast in place: ids, alerts, scene and page breaks, images, soft breaks. */
1811
+ transform(tree, prefix) {
1812
+ const definitions = collectDefinitions(tree);
1813
+ let headings = 0;
1814
+ const visit = (parent) => {
1815
+ wrapInlineHtml(parent);
1816
+ const children = parent.children;
1817
+ for (let i = 0; i < children.length; i++) {
1818
+ const node = children[i];
1819
+ switch (node.type) {
1820
+ case "heading": {
1821
+ const id = `${prefix}h${++headings}`;
1822
+ node.data = { ...node.data, hProperties: { ...node.data?.hProperties, id } };
1823
+ const text = joinSoftBreaks(textOf(node)).trim();
1824
+ if (node.depth <= 3 && text && parent.type === "root") {
1825
+ this.toc.push({ level: node.depth, text, id });
1826
+ }
1827
+ break;
1828
+ }
1829
+ case "blockquote": {
1830
+ const alert = readAlert(node);
1831
+ if (alert) {
1832
+ node.children = [
1833
+ {
1834
+ type: "paragraph",
1835
+ data: { hProperties: { className: ["callout-title"] } },
1836
+ children: [{ type: "text", value: this.labels.alerts[alert.type] }]
1837
+ },
1838
+ ...alert.children
1839
+ ];
1840
+ node.data = {
1841
+ ...node.data,
1842
+ hName: "aside",
1843
+ hProperties: { className: ["callout", `callout-${alert.type}`] }
1844
+ };
1845
+ }
1846
+ break;
1847
+ }
1848
+ case "thematicBreak":
1849
+ children[i] = {
1850
+ type: "paragraph",
1851
+ data: { hProperties: { className: ["scene-break"] } },
1852
+ children: [{ type: "text", value: this.input.config.export.sceneBreak }]
1853
+ };
1854
+ continue;
1855
+ case "html":
1856
+ if (isPageBreak(node)) {
1857
+ children[i] = {
1858
+ type: "paragraph",
1859
+ data: { hName: "div", hProperties: { className: ["page-break"] } },
1860
+ children: []
1861
+ };
1862
+ } else if (/^<br\s*\/?>$/i.test(node.value.trim())) {
1863
+ children[i] = { type: "break" };
1864
+ }
1865
+ continue;
1866
+ case "text":
1867
+ node.value = joinSoftBreaks(node.value);
1868
+ continue;
1869
+ case "paragraph": {
1870
+ const figure = this.figureOf(node.children, definitions);
1871
+ if (figure) {
1872
+ node.data = { ...node.data, hName: "figure", hChildren: this.figure(figure) };
1873
+ continue;
1874
+ }
1875
+ break;
1876
+ }
1877
+ case "image":
1878
+ case "imageReference": {
1879
+ const def = node.type === "imageReference" ? definitions.get(node.identifier) : null;
1880
+ const url = node.type === "image" ? node.url : def?.url;
1881
+ const src = url === void 0 ? null : this.source(url);
1882
+ if (src === null) {
1883
+ children[i] = {
1884
+ type: "text",
1885
+ value: imagePlaceholder(joinSoftBreaks(node.alt ?? ""), this.labels)
1886
+ };
1887
+ } else if (node.type === "image") {
1888
+ node.url = src;
1889
+ } else if (def) {
1890
+ children[i] = { type: "image", url: src, alt: node.alt, title: def.title };
1891
+ }
1892
+ continue;
1893
+ }
1894
+ }
1895
+ if ("children" in node) visit(node);
1896
+ }
1897
+ };
1898
+ visit(tree);
1899
+ }
1900
+ /** Data URI of a local image, the URL itself for a remote one, null when it cannot be shown. */
1901
+ source(url) {
1902
+ if (/^https?:\/\//i.test(url.trim())) return url.trim();
1903
+ const image = this.images.get(url);
1904
+ return image ? `data:${image.mime};base64,${image.data.toString("base64")}` : null;
1905
+ }
1906
+ figureOf(children, definitions) {
1907
+ const content = children.filter((c) => !(c.type === "text" && !c.value.trim()));
1908
+ if (content.length !== 1) return null;
1909
+ const only = content[0];
1910
+ let url;
1911
+ let title;
1912
+ if (only.type === "image") {
1913
+ url = only.url;
1914
+ title = only.title ?? void 0;
1915
+ } else if (only.type === "imageReference") {
1916
+ const def = definitions.get(only.identifier);
1917
+ url = def?.url;
1918
+ title = def?.title ?? void 0;
1919
+ }
1920
+ if (url === void 0 || only.type !== "image" && only.type !== "imageReference") return null;
1921
+ const alt = joinSoftBreaks(only.alt ?? "").trim();
1922
+ return { src: this.source(url), alt, title };
1923
+ }
1924
+ figure(figure) {
1925
+ if (figure.src === null) {
1926
+ return [
1927
+ element("p", "image-missing", [
1928
+ { type: "text", value: imagePlaceholder(figure.alt, this.labels) }
1929
+ ])
1930
+ ];
1931
+ }
1932
+ const img = {
1933
+ type: "element",
1934
+ tagName: "img",
1935
+ properties: {
1936
+ src: figure.src,
1937
+ alt: figure.alt,
1938
+ ...figure.title ? { title: figure.title } : {}
1939
+ },
1940
+ children: []
1941
+ };
1942
+ if (!figure.alt) return [img];
1943
+ return [img, element("figcaption", null, [{ type: "text", value: figure.alt }])];
1944
+ }
1945
+ css() {
1946
+ const { config } = this.input;
1947
+ const e = config.export;
1948
+ const size = pageSizeMm(e.pageSize);
1949
+ const m = e.margins;
1950
+ const contentHeight = Math.max(20, size.height - m.top - m.bottom);
1951
+ const bodyFonts = `${cssString(e.fonts.latin)}, ${cssString(e.fonts.body)}, serif`;
1952
+ const headingFonts = `${cssString(e.fonts.latin)}, ${cssString(e.fonts.heading)}, serif`;
1953
+ const codeFonts = `${cssString(e.fonts.code)}, ${cssString(e.fonts.body)}, monospace`;
1954
+ const lineHeight = round(Math.max(1, e.lineSpacing) * 1.2);
1955
+ const pageBox = [];
1956
+ if (e.pageNumbers) pageBox.push(" @bottom-center { content: counter(page); font-size: 9pt; }");
1957
+ if (e.header === "title" && config.title) {
1958
+ pageBox.push(
1959
+ ` @top-center { content: ${cssString(config.title)}; font-size: 9pt; color: #595959; }`
1960
+ );
1961
+ }
1962
+ const alerts = ALERT_TYPES.map(
1963
+ (type) => `.callout-${type} { border-left-color: #${ALERT_COLORS[type]}; }
1964
+ .callout-${type} .callout-title { color: #${ALERT_COLORS[type]}; }`
1965
+ ).join("\n");
1966
+ return `@page {
1967
+ size: ${size.width}mm ${size.height}mm;
1968
+ margin: ${m.top}mm ${m.right}mm ${m.bottom}mm ${m.left}mm;
1969
+ ${pageBox.join("\n")}
1970
+ }
1971
+ @page front {
1972
+ @top-center { content: none; }
1973
+ @bottom-center { content: none; }
1974
+ }
1975
+ :root { color-scheme: light; }
1976
+ html { background: #fff; color: #000; }
1977
+ body {
1978
+ font-family: ${bodyFonts};
1979
+ font-size: ${e.fontSize}pt;
1980
+ line-height: ${lineHeight};
1981
+ margin: 0;
1982
+ text-rendering: optimizeLegibility;
1983
+ font-kerning: normal;
1984
+ }
1985
+ @media screen {
1986
+ body { max-width: ${round(size.width - m.left - m.right)}mm; margin: 2em auto 6em; padding: 0 1.5em; }
1987
+ .title-page, .toc, .chapter { border-bottom: 1px dashed #ccc; padding-bottom: 2em; margin-bottom: 2em; }
1988
+ }
1989
+ h1, h2, h3, h4, h5, h6 {
1990
+ font-family: ${headingFonts};
1991
+ font-weight: bold;
1992
+ line-height: 1.3;
1993
+ break-after: avoid;
1994
+ page-break-after: avoid;
1995
+ }
1996
+ .chapter h1 { font-size: 1.75em; text-align: center; margin: 2em 0 1.5em; }
1997
+ .chapter h2 { font-size: 1.4em; margin: 1.5em 0 0.5em; }
1998
+ .chapter h3 { font-size: 1.2em; margin: 1em 0 0.5em; }
1999
+ .chapter h4, .chapter h5, .chapter h6 { font-size: 1.05em; margin: 0.75em 0 0.25em; }
2000
+ p { margin: 0 0 ${e.paragraphSpacing}pt; text-align: justify; orphans: 2; widows: 2; }
2001
+ .chapter p { text-indent: ${e.firstLineIndent}em; }
2002
+ .chapter li p, .chapter blockquote p, .chapter td p, .chapter th p, .chapter .footnotes p,
2003
+ .chapter figure p, .chapter p.scene-break, .chapter p.callout-title { text-indent: 0; }
2004
+ .title-page {
2005
+ page: front;
2006
+ break-after: page;
2007
+ page-break-after: always;
2008
+ min-height: calc(${contentHeight}mm - 2mm);
2009
+ display: flex;
2010
+ flex-direction: column;
2011
+ justify-content: center;
2012
+ text-align: center;
2013
+ }
2014
+ .book-title { font-size: 2.4em; margin: 0 0 1em; }
2015
+ .book-subtitle { font-size: 1.5em; margin: 0 0 1em; text-indent: 0; text-align: center; }
2016
+ .book-author { font-size: 1.25em; margin: 3em 0 0; text-indent: 0; text-align: center; }
2017
+ .toc { page: front; break-after: page; page-break-after: always; }
2018
+ .toc-title { text-align: center; font-size: 1.75em; margin: 2em 0 1.5em; }
2019
+ .toc-list { list-style: none; margin: 0; padding: 0; }
2020
+ .toc-list a { color: inherit; text-decoration: none; }
2021
+ .toc-1 { margin-top: 0.5em; }
2022
+ .toc-2 { padding-left: 2em; }
2023
+ .toc-3 { padding-left: 4em; }
2024
+ ${e.chapterPageBreak ? ".chapter { break-before: page; page-break-before: always; }" : ".chapter + .chapter { margin-top: 3em; }"}
2025
+ .page-break { break-after: page; page-break-after: always; height: 0; }
2026
+ .scene-break { text-align: center; margin: 1em 0; }
2027
+ blockquote {
2028
+ margin: 0 2em ${Math.max(e.paragraphSpacing, 6)}pt;
2029
+ padding-left: 0.8em;
2030
+ border-left: 2px solid #a6a6a6;
2031
+ color: #404040;
2032
+ }
2033
+ .callout {
2034
+ margin: 1em 0.5em;
2035
+ padding: 0.5em 1em;
2036
+ background: #f3f5f8;
2037
+ border-left: 4px solid #7f8c9a;
2038
+ break-inside: avoid;
2039
+ }
2040
+ .callout > :last-child { margin-bottom: 0; }
2041
+ .callout-title { font-weight: bold; margin-bottom: 0.25em; }
2042
+ ${alerts}
2043
+ pre {
2044
+ font-family: ${codeFonts};
2045
+ font-size: 0.85em;
2046
+ line-height: 1.35;
2047
+ background: #f2f2f2;
2048
+ padding: 0.6em 0.8em;
2049
+ white-space: pre-wrap;
2050
+ overflow-wrap: anywhere;
2051
+ margin: 0.25em 0 1em;
2052
+ }
2053
+ code { font-family: ${codeFonts}; font-size: 0.9em; }
2054
+ :not(pre) > code { background: #f2f2f2; padding: 0 0.2em; border-radius: 2px; }
2055
+ ul, ol { margin: 0 0 ${e.paragraphSpacing}pt; padding-left: 2em; }
2056
+ li > p { margin: 0; }
2057
+ table { border-collapse: collapse; width: 100%; margin: 0.5em 0 1em; font-size: 0.95em; line-height: 1.4; }
2058
+ th, td { border: 1px solid #808080; padding: 0.25em 0.5em; vertical-align: middle; }
2059
+ th { background: #f2f2f2; font-weight: bold; }
2060
+ thead { display: table-header-group; }
2061
+ tr { break-inside: avoid; }
2062
+ figure { margin: 1em 0; text-align: center; break-inside: avoid; }
2063
+ figure img { max-width: 100%; max-height: ${round(contentHeight * 0.8)}mm; }
2064
+ figcaption { font-size: 0.85em; color: #404040; margin-top: 0.3em; text-indent: 0; }
2065
+ img { max-width: 100%; }
2066
+ a { color: #0563c1; }
2067
+ .footnotes { margin-top: 2em; border-top: 1px solid #999; font-size: 0.85em; }
2068
+ .footnotes-title { font-size: 1em; margin: 0.5em 0; }
2069
+ .footnotes ol { padding-left: 1.5em; }
2070
+ sup a[data-footnote-ref] { text-decoration: none; }
2071
+ @media print {
2072
+ a { color: inherit; text-decoration: none; }
2073
+ a[data-footnote-backref] { display: none; }
2074
+ }`;
2075
+ }
2076
+ };
2077
+ }
2078
+ });
2079
+
2080
+ // src/node/export/markdown.ts
2081
+ import { isAbsolute as isAbsolute2, join as join5, relative as relative3, sep as sep3 } from "path";
2082
+ function rewriteUrl(url, root, outputDir) {
2083
+ const trimmed = url.trim();
2084
+ if (!trimmed || trimmed.startsWith("#") || isExternalUrl(trimmed) || isAbsolute2(trimmed)) {
2085
+ return url;
2086
+ }
2087
+ const file = resolveLocalFile(trimmed, root) ?? imageCandidates(trimmed, root)[0];
2088
+ if (!file) return url;
2089
+ return relative3(outputDir, file).split(sep3).join("/");
2090
+ }
2091
+ function chapterMarkdown(source, index, root, outputDir) {
2092
+ const clean = stripMarkers(source);
2093
+ const tree = parseMarkdown(clean);
2094
+ const prefix = `ch${index + 1}-`;
2095
+ const imageRefs = /* @__PURE__ */ new Set();
2096
+ walk(tree, (node) => {
2097
+ if (node.type === "imageReference") imageRefs.add(node.identifier);
2098
+ });
2099
+ const edits = [];
2100
+ const visit = (node) => {
2101
+ const start = node.position?.start.offset;
2102
+ const end = node.position?.end.offset;
2103
+ if (start === void 0 || end === void 0) return;
2104
+ switch (node.type) {
2105
+ case "yaml":
2106
+ edits.push({ start, end, text: "" });
2107
+ return;
2108
+ case "html":
2109
+ if (isMarker(node.value)) {
2110
+ edits.push({ start, end: clean[end] === "\n" ? end + 1 : end, text: "" });
2111
+ }
2112
+ return;
2113
+ case "footnoteReference":
2114
+ edits.push({ start, end, text: `[^${prefix}${node.label ?? node.identifier}]` });
2115
+ return;
2116
+ case "footnoteDefinition": {
2117
+ const label = /^\[\^([^\]]+)\]:/.exec(clean.slice(start, end));
2118
+ if (label) {
2119
+ edits.push({ start, end: start + label[0].length, text: `[^${prefix}${label[1]}]:` });
2120
+ }
2121
+ break;
2122
+ }
2123
+ case "image": {
2124
+ const url = rewriteUrl(node.url, root, outputDir);
2125
+ if (url !== node.url) {
2126
+ edits.push({
2127
+ start,
2128
+ end,
2129
+ text: `![${markdownAlt(node.alt ?? "")}](${markdownUrl(url)}${markdownTitle(node.title)})`
2130
+ });
2131
+ }
2132
+ return;
2133
+ }
2134
+ case "definition": {
2135
+ if (!imageRefs.has(node.identifier)) return;
2136
+ const url = rewriteUrl(node.url, root, outputDir);
2137
+ if (url !== node.url) {
2138
+ edits.push({
2139
+ start,
2140
+ end,
2141
+ text: `[${node.label ?? node.identifier}]: ${markdownUrl(url)}${markdownTitle(node.title)}`
2142
+ });
2143
+ }
2144
+ return;
2145
+ }
2146
+ }
2147
+ if ("children" in node) for (const child of node.children) visit(child);
2148
+ };
2149
+ visit(tree);
2150
+ return applyEdits(clean, edits).trim();
2151
+ }
2152
+ function buildMarkdown(input) {
2153
+ const outputDir = input.outputDir ?? join5(input.root, "output");
2154
+ const parts = input.chapters.map((c, i) => chapterMarkdown(c.source, i, input.root, outputDir)).filter((part) => part !== "");
2155
+ return parts.length ? `${parts.join("\n\n")}
2156
+ ` : "";
2157
+ }
2158
+ var init_markdown2 = __esm({
2159
+ "src/node/export/markdown.ts"() {
2160
+ "use strict";
2161
+ init_annotations();
2162
+ init_markdown();
2163
+ init_common();
2164
+ }
2165
+ });
2166
+
2167
+ // src/node/export/index.ts
2168
+ var export_exports = {};
2169
+ __export(export_exports, {
2170
+ EXPORT_FORMATS: () => EXPORT_FORMATS,
2171
+ buildDocx: () => buildDocx,
2172
+ buildHtml: () => buildHtml,
2173
+ buildMarkdown: () => buildMarkdown,
2174
+ exportManuscript: () => exportManuscript,
2175
+ renderManuscript: () => renderManuscript
2176
+ });
2177
+ import { mkdir as mkdir4, writeFile as writeFile4 } from "fs/promises";
2178
+ import { dirname as dirname3, join as join6, resolve as resolve2 } from "path";
2179
+ async function render(book, format, outputDir) {
2180
+ if (!EXPORT_FORMATS.includes(format)) throw new Error(`\u4E0D\u652F\u63F4\u7684\u532F\u51FA\u683C\u5F0F\uFF1A${format}`);
2181
+ const { config, chapters } = await book.loadManuscript();
2182
+ const input = { config, chapters, root: book.root, outputDir };
2183
+ let data;
2184
+ if (format === "docx") data = await buildDocx(input);
2185
+ else if (format === "html") data = Buffer.from(await buildHtml(input), "utf8");
2186
+ else data = Buffer.from(buildMarkdown(input), "utf8");
2187
+ return {
2188
+ data,
2189
+ fileName: `${sanitizeFileName(config.export.fileName || config.title)}.${format}`,
2190
+ contentType: CONTENT_TYPES[format]
2191
+ };
2192
+ }
2193
+ async function renderManuscript(book, format) {
2194
+ return render(book, format, book.outputDir);
2195
+ }
2196
+ async function exportManuscript(book, format, outFile) {
2197
+ const target = outFile ? resolve2(outFile) : null;
2198
+ const file = await render(book, format, target ? dirname3(target) : book.outputDir);
2199
+ const path = target ?? join6(book.outputDir, file.fileName);
2200
+ await mkdir4(dirname3(path), { recursive: true });
2201
+ await writeFile4(path, file.data);
2202
+ return { path, bytes: file.data.length };
2203
+ }
2204
+ var EXPORT_FORMATS, CONTENT_TYPES;
2205
+ var init_export = __esm({
2206
+ "src/node/export/index.ts"() {
2207
+ "use strict";
2208
+ init_common();
2209
+ init_docx();
2210
+ init_html();
2211
+ init_markdown2();
2212
+ EXPORT_FORMATS = ["docx", "html", "md"];
2213
+ CONTENT_TYPES = {
2214
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
2215
+ html: "text/html; charset=utf-8",
2216
+ md: "text/markdown; charset=utf-8"
2217
+ };
2218
+ }
2219
+ });
2220
+
2221
+ // src/node/import/markdown.ts
2222
+ import { createHash as createHash2 } from "crypto";
2223
+ import { existsSync as existsSync9, statSync as statSync2 } from "fs";
2224
+ import { copyFile, mkdir as mkdir6, readFile as readFile7 } from "fs/promises";
2225
+ import { basename as basename4, dirname as dirname6, extname as extname4, isAbsolute as isAbsolute3, join as join10, relative as relative6, resolve as resolve6, sep as sep5 } from "path";
2226
+ import { fileURLToPath as fileURLToPath4 } from "url";
2227
+ function posix(path) {
2228
+ return path.split(sep5).join("/");
2229
+ }
2230
+ function safeName(name) {
2231
+ return name.normalize("NFC").replace(/[\s()[\]<>{}#%?*:|"'\\/]+/g, "-").replace(/-+/g, "-").replace(/^[-.]+|-+$/g, "").slice(0, 60) || "image";
2232
+ }
2233
+ async function sameContent(a, b) {
2234
+ if (statSync2(a).size !== statSync2(b).size) return false;
2235
+ const hash2 = async (p) => createHash2("sha1").update(await readFile7(p)).digest("hex");
2236
+ return await hash2(a) === await hash2(b);
2237
+ }
2238
+ async function copyInto(file, dir) {
2239
+ await mkdir6(dir, { recursive: true });
2240
+ const ext = extname4(file).toLowerCase();
2241
+ const stem = safeName(basename4(file, extname4(file)));
2242
+ for (let n = 1; ; n++) {
2243
+ const dest = join10(dir, `${stem}${n > 1 ? `-${n}` : ""}${ext}`);
2244
+ if (!existsSync9(dest)) {
2245
+ await copyFile(file, dest);
2246
+ return { path: dest, copied: true };
2247
+ }
2248
+ if (await sameContent(file, dest)) return { path: dest, copied: false };
2249
+ }
2250
+ }
2251
+ function localPath(url, baseDir) {
2252
+ const trimmed = url.trim();
2253
+ if (!trimmed || trimmed.startsWith("#")) return null;
2254
+ if (/^file:/i.test(trimmed)) {
2255
+ try {
2256
+ return fileURLToPath4(trimmed);
2257
+ } catch {
2258
+ return null;
2259
+ }
2260
+ }
2261
+ if (isExternalUrl(trimmed)) return null;
2262
+ let path = trimmed.replace(/[?#].*$/, "");
2263
+ try {
2264
+ path = decodeURIComponent(path);
2265
+ } catch {
2266
+ }
2267
+ return isAbsolute3(path) ? path : resolve6(baseDir, path);
2268
+ }
2269
+ async function readMarkdownFile(file, target, log) {
2270
+ const source = (await readFile7(file, "utf8")).replace(/^/, "").replace(/\r\n?/g, "\n");
2271
+ const tree = parseMarkdown(source);
2272
+ const baseDir = dirname6(file);
2273
+ const assetsDir = join10(target.root, "assets", "imported");
2274
+ const imageRefs = /* @__PURE__ */ new Set();
2275
+ walk(tree, (node) => {
2276
+ if (node.type === "imageReference") imageRefs.add(node.identifier);
2277
+ });
2278
+ const copies = /* @__PURE__ */ new Map();
2279
+ const relink = async (url) => {
2280
+ const path = localPath(url, baseDir);
2281
+ if (!path) return null;
2282
+ if (copies.has(path)) return copies.get(path) ?? null;
2283
+ let link = null;
2284
+ if (existsSync9(path) && statSync2(path).isFile()) {
2285
+ const { path: dest, copied } = await copyInto(path, assetsDir);
2286
+ if (copied) log.assets.push(posix(relative6(target.root, dest)));
2287
+ link = posix(relative6(target.chaptersDir, dest));
2288
+ } else {
2289
+ log.warnings.push(`\u627E\u4E0D\u5230\u5716\u7247\uFF1A${url}\uFF08\u9023\u7D50\u4FDD\u6301\u539F\u6A23\uFF09`);
2290
+ }
2291
+ copies.set(path, link);
2292
+ return link;
2293
+ };
2294
+ const edits = [];
2295
+ const links = [];
2296
+ const visit = (node) => {
2297
+ const start = node.position?.start.offset;
2298
+ const end = node.position?.end.offset;
2299
+ if (start === void 0 || end === void 0) return;
2300
+ if (node.type === "yaml") {
2301
+ edits.push({ start, end, text: "" });
2302
+ } else if (node.type === "image") {
2303
+ links.push({
2304
+ url: node.url,
2305
+ edit: (url) => ({
2306
+ start,
2307
+ end,
2308
+ text: `![${markdownAlt(node.alt ?? "")}](${markdownUrl(url)}${markdownTitle(node.title)})`
2309
+ })
2310
+ });
2311
+ } else if (node.type === "definition") {
2312
+ if (!imageRefs.has(node.identifier)) return;
2313
+ links.push({
2314
+ url: node.url,
2315
+ edit: (url) => ({
2316
+ start,
2317
+ end,
2318
+ text: `[${node.label ?? node.identifier}]: ${markdownUrl(url)}${markdownTitle(node.title)}`
2319
+ })
2320
+ });
2321
+ } else if ("children" in node) {
2322
+ for (const child of node.children) visit(child);
2323
+ }
2324
+ };
2325
+ visit(tree);
2326
+ for (const link of links) {
2327
+ const url = await relink(link.url);
2328
+ if (url !== null) edits.push(link.edit(url));
2329
+ }
2330
+ return applyEdits(source, edits);
2331
+ }
2332
+ var init_markdown3 = __esm({
2333
+ "src/node/import/markdown.ts"() {
2334
+ "use strict";
2335
+ init_markdown();
2336
+ init_common();
2337
+ }
2338
+ });
2339
+
2340
+ // src/node/import/docx.ts
2341
+ import { existsSync as existsSync10 } from "fs";
2342
+ import { mkdir as mkdir7, writeFile as writeFile7 } from "fs/promises";
2343
+ import { basename as basename5, extname as extname5, join as join11, relative as relative7, sep as sep6 } from "path";
2344
+ import mammoth from "mammoth";
2345
+ import TurndownService from "turndown";
2346
+ import { gfm } from "turndown-plugin-gfm";
2347
+ function noteLabel(kind, n) {
2348
+ return kind === "endnote" ? `e${n}` : n;
2349
+ }
2350
+ function closest(node, name) {
2351
+ let current = node.parentNode;
2352
+ while (current && current.nodeName !== name) current = current.parentNode;
2353
+ return current;
2354
+ }
2355
+ function preText(node) {
2356
+ let out = "";
2357
+ for (const child of Array.from(node.childNodes)) {
2358
+ if (child.nodeType === 3) out += child.nodeValue ?? "";
2359
+ else if (child.nodeName === "BR") out += "\n";
2360
+ else out += preText(child);
2361
+ }
2362
+ return out;
2363
+ }
2364
+ function tableCell(content, node) {
2365
+ let text = content.trim().replace(/\s*\n+\s*/g, " ").replace(/\|/g, "\\|");
2366
+ if (node.nodeName === "TH") text = text.replace(/^\*\*(.*)\*\*$/, "$1");
2367
+ const span = Number(node.getAttribute("colspan") ?? "1");
2368
+ const cell = ` ${text} |`;
2369
+ return cell + " |".repeat(Number.isFinite(span) && span > 1 ? Math.min(span, 50) - 1 : 0);
2370
+ }
2371
+ function htmlToMarkdown(html) {
2372
+ const service = new TurndownService({
2373
+ headingStyle: "atx",
2374
+ codeBlockStyle: "fenced",
2375
+ bulletListMarker: "-",
2376
+ emDelimiter: "*",
2377
+ strongDelimiter: "**",
2378
+ hr: "---",
2379
+ fence: "```"
2380
+ });
2381
+ service.use(gfm);
2382
+ service.addRule("strikethrough", {
2383
+ filter: ["del", "s", "strike"],
2384
+ replacement: (content) => content.trim() ? `~~${content}~~` : content
2385
+ });
2386
+ service.addRule("listItem", {
2387
+ filter: "li",
2388
+ replacement: (content, node, options) => {
2389
+ const li = node;
2390
+ const parent = li.parentNode;
2391
+ let prefix = `${options.bulletListMarker} `;
2392
+ if (parent?.nodeName === "OL") {
2393
+ const start = Number(parent.getAttribute("start") ?? "1");
2394
+ const index = Array.from(parent.children).indexOf(li);
2395
+ prefix = `${(Number.isFinite(start) ? start : 1) + index}. `;
2396
+ }
2397
+ const body = content.replace(/^\n+/, "").replace(/\n+$/, "\n").replace(/\n(?!$)/gm, `
2398
+ ${" ".repeat(prefix.length)}`);
2399
+ return prefix + body + (li.nextSibling && !/\n$/.test(body) ? "\n" : "");
2400
+ }
2401
+ });
2402
+ service.addRule("preformatted", {
2403
+ filter: (node) => node.nodeName === "PRE" && node.firstChild?.nodeName !== "CODE",
2404
+ replacement: (_content, node) => {
2405
+ const text = preText(node).replace(/\n+$/, "");
2406
+ const longest = Math.max(2, ...(text.match(/`+/g) ?? []).map((m) => m.length));
2407
+ const fence = "`".repeat(longest + 1);
2408
+ return `
2409
+
2410
+ ${fence}
2411
+ ${text}
2412
+ ${fence}
2413
+
2414
+ `;
2415
+ }
2416
+ });
2417
+ service.addRule("sceneBreak", {
2418
+ filter: (node) => node.nodeName === "P" && /\bscene-break\b/.test(node.getAttribute("class") ?? ""),
2419
+ replacement: () => "\n\n---\n\n"
2420
+ });
2421
+ service.addRule("supSub", {
2422
+ filter: ["sup", "sub"],
2423
+ replacement: (content, node) => {
2424
+ const el = node;
2425
+ const only = Array.from(el.children);
2426
+ if (only.length === 1 && only[0].nodeName === "A" && FOOTNOTE_REF_RE.test(only[0].getAttribute("href") ?? "")) {
2427
+ return content;
2428
+ }
2429
+ if (!content.trim()) return content;
2430
+ const tag = el.nodeName.toLowerCase();
2431
+ return `<${tag}>${content}</${tag}>`;
2432
+ }
2433
+ });
2434
+ service.addRule("tableCell", {
2435
+ filter: ["th", "td"],
2436
+ replacement: (content, node) => {
2437
+ const el = node;
2438
+ const first = el.parentNode ? Array.from(el.parentNode.children).indexOf(el) === 0 : true;
2439
+ return (first ? "|" : "") + tableCell(content, el);
2440
+ }
2441
+ });
2442
+ service.addRule("tableRow", {
2443
+ filter: "tr",
2444
+ replacement: (content, node) => {
2445
+ const tr = node;
2446
+ const table = closest(tr, "TABLE");
2447
+ const isFirst = table?.rows ? table.rows[0] === tr : false;
2448
+ if (!isFirst) return `
2449
+ ${content}`;
2450
+ let cols = 0;
2451
+ for (const cell of Array.from(tr.children)) {
2452
+ const span = Number(cell.getAttribute("colspan") ?? "1");
2453
+ cols += Number.isFinite(span) && span > 1 ? Math.min(span, 50) : 1;
2454
+ }
2455
+ return `
2456
+ ${content}
2457
+ |${" --- |".repeat(Math.max(1, cols))}`;
2458
+ }
2459
+ });
2460
+ service.addRule("tableSection", {
2461
+ filter: ["thead", "tbody", "tfoot"],
2462
+ replacement: (content) => content
2463
+ });
2464
+ service.addRule("table", {
2465
+ filter: "table",
2466
+ replacement: (content) => `
2467
+
2468
+ ${content.replace(/\n+/g, "\n").trim()}
2469
+
2470
+ `
2471
+ });
2472
+ service.addRule("internalLink", {
2473
+ filter: (node) => node.nodeName === "A" && (node.getAttribute("href") ?? "").startsWith("#"),
2474
+ replacement: (content) => content
2475
+ });
2476
+ service.addRule("autolink", {
2477
+ filter: (node) => {
2478
+ const href = node.getAttribute("href") ?? "";
2479
+ return node.nodeName === "A" && /^https?:\/\//i.test(href) && node.textContent?.trim() === href;
2480
+ },
2481
+ replacement: (_content, node) => `<${node.getAttribute("href")}>`
2482
+ });
2483
+ service.addRule("footnoteReference", {
2484
+ filter: (node) => node.nodeName === "A" && FOOTNOTE_REF_RE.test(node.getAttribute("href") ?? ""),
2485
+ replacement: (_content, node) => {
2486
+ const [, kind, n] = FOOTNOTE_REF_RE.exec(node.getAttribute("href") ?? "") ?? [];
2487
+ return `[^${noteLabel(kind, n)}]`;
2488
+ }
2489
+ });
2490
+ service.addRule("footnoteBackLink", {
2491
+ filter: (node) => node.nodeName === "A" && FOOTNOTE_BACK_RE.test(node.getAttribute("href") ?? ""),
2492
+ replacement: () => ""
2493
+ });
2494
+ service.addRule("footnoteItem", {
2495
+ filter: (node) => node.nodeName === "LI" && FOOTNOTE_ITEM_RE.test(node.getAttribute("id") ?? ""),
2496
+ replacement: (content, node) => {
2497
+ const [, kind, n] = FOOTNOTE_ITEM_RE.exec(node.getAttribute("id") ?? "") ?? [];
2498
+ const text = content.trim().split("\n").map((line, i) => i === 0 || !line.trim() ? line.trimEnd() : ` ${line}`).join("\n");
2499
+ return `
2500
+
2501
+ [^${noteLabel(kind, n)}]: ${text}
2502
+
2503
+ `;
2504
+ }
2505
+ });
2506
+ service.addRule("footnoteList", {
2507
+ filter: (node) => {
2508
+ if (node.nodeName !== "OL") return false;
2509
+ const items = Array.from(node.children);
2510
+ return items.length > 0 && items.every((li) => FOOTNOTE_ITEM_RE.test(li.getAttribute("id") ?? ""));
2511
+ },
2512
+ replacement: (content) => `
2513
+
2514
+ ${content.trim()}
2515
+
2516
+ `
2517
+ });
2518
+ let markdown = service.turndown(html);
2519
+ markdown = markdown.replace(
2520
+ /^> \*\*([^*\n]+)\*\*[ \t]*\n>[ \t]*\n/gm,
2521
+ (match, label) => {
2522
+ const type = ALERT_LABELS[label.trim().toLowerCase()] ?? ALERT_LABELS[label.trim()];
2523
+ return type ? `> [!${type}]
2524
+ ` : match;
2525
+ }
2526
+ );
2527
+ markdown = markdown.replace(
2528
+ /^(!\[([^\]\n]+)\]\([^)\n]*\))\n\n([^\n]+)\n(?=\n|$)/gm,
2529
+ (match, image, alt, caption) => caption.replace(/\\(.)/g, "$1").trim() === alt.trim() ? `${image}
2530
+ ` : match
2531
+ );
2532
+ return `${markdown.replace(/\n{3,}/g, "\n\n").trim()}
2533
+ `;
2534
+ }
2535
+ function translateMessage(message) {
2536
+ const style = /^Unrecognised (paragraph|run) style: '(.*)' \(Style ID: (.*)\)$/.exec(message);
2537
+ if (style) {
2538
+ return `Word \u6A23\u5F0F\u300C${style[2]}\u300D\u6C92\u6709\u5C0D\u61C9\u7684 Markdown \u683C\u5F0F\uFF0C\u5DF2\u7576\u6210\u4E00\u822C${style[1] === "run" ? "\u6587\u5B57" : "\u6BB5\u843D"}\u532F\u5165\u3002`;
2539
+ }
2540
+ return `Word \u8F49\u63DB\uFF1A${message}`;
2541
+ }
2542
+ async function readDocxFile(file, target, log) {
2543
+ const stem = safeName(basename5(file, extname5(file)));
2544
+ const dir = join11(target.root, "assets", "imported");
2545
+ let count = 0;
2546
+ let metafiles = 0;
2547
+ const convertImage = mammoth.images.imgElement(async (image) => {
2548
+ const ext = IMAGE_EXT[image.contentType?.toLowerCase() ?? ""] ?? "bin";
2549
+ let dest;
2550
+ do {
2551
+ count++;
2552
+ dest = join11(dir, `${stem}-${count}.${ext}`);
2553
+ } while (existsSync10(dest));
2554
+ const data = await image.readAsBuffer();
2555
+ await mkdir7(dir, { recursive: true });
2556
+ await writeFile7(dest, data);
2557
+ log.assets.push(relative7(target.root, dest).split(sep6).join("/"));
2558
+ if (ext === "emf" || ext === "wmf") metafiles++;
2559
+ return { src: relative7(target.chaptersDir, dest).split(sep6).join("/") };
2560
+ });
2561
+ const result = await mammoth.convertToHtml({ path: file }, { styleMap: STYLE_MAP, convertImage });
2562
+ const seen = /* @__PURE__ */ new Set();
2563
+ for (const message of result.messages) {
2564
+ const text = translateMessage(message.message);
2565
+ if (!seen.has(text)) {
2566
+ seen.add(text);
2567
+ log.warnings.push(text);
2568
+ }
2569
+ }
2570
+ if (metafiles) {
2571
+ log.warnings.push(
2572
+ `\u6709 ${metafiles} \u5F35\u5716\u7247\u662F Windows \u5716\u5143\u6A94\uFF08EMF/WMF\uFF09\uFF0C\u700F\u89BD\u5668\u548C\u532F\u51FA\u90FD\u7121\u6CD5\u986F\u793A\uFF0C\u5EFA\u8B70\u5728 Word \u88E1\u53E6\u5B58\u6210 PNG \u5F8C\u66FF\u63DB\u3002`
2573
+ );
2574
+ }
2575
+ return htmlToMarkdown(result.value);
2576
+ }
2577
+ var STYLE_MAP, IMAGE_EXT, ALERT_LABELS, FOOTNOTE_REF_RE, FOOTNOTE_BACK_RE, FOOTNOTE_ITEM_RE;
2578
+ var init_docx2 = __esm({
2579
+ "src/node/import/docx.ts"() {
2580
+ "use strict";
2581
+ init_markdown3();
2582
+ STYLE_MAP = [
2583
+ // The table of contents is rebuilt on export; its entries are just noise here.
2584
+ "p[style-name='TOC Heading'] => !",
2585
+ ...Array.from({ length: 9 }, (_, i) => `p[style-name='toc ${i + 1}'] => !`),
2586
+ "p[style-name='Title'] => p:fresh",
2587
+ "p[style-name='Subtitle'] => p:fresh",
2588
+ "p[style-name='Author'] => p:fresh",
2589
+ "p[style-name='Body Text'] => p:fresh",
2590
+ "p[style-name='Body Text Indent'] => p:fresh",
2591
+ "p[style-name='Body Text First Indent'] => p:fresh",
2592
+ "p[style-name='First Paragraph'] => p:fresh",
2593
+ "p[style-name='Compact'] => p:fresh",
2594
+ "p[style-name='Figure'] => p:fresh",
2595
+ "p[style-name='Captioned Figure'] => p:fresh",
2596
+ "p[style-name='caption'] => p:fresh",
2597
+ "p[style-name='Caption'] => p:fresh",
2598
+ "p[style-name='Image Caption'] => p:fresh",
2599
+ "p[style-name='Table Text'] => p:fresh",
2600
+ "p[style-name='List Continue'] => p:fresh",
2601
+ "p[style-name='Quote'] => blockquote > p:fresh",
2602
+ "p[style-name='Intense Quote'] => blockquote > p:fresh",
2603
+ "p[style-name='Block Text'] => blockquote > p:fresh",
2604
+ "p[style-name='Callout'] => blockquote.callout > p:fresh",
2605
+ "p[style-name='Code'] => pre:separator('\\n')",
2606
+ "p[style-name='Source Code'] => pre:separator('\\n')",
2607
+ "p[style-name='HTML Preformatted'] => pre:separator('\\n')",
2608
+ "p[style-name='Scene Break'] => p.scene-break:fresh",
2609
+ "r[style-name='Inline Code'] => code",
2610
+ "r[style-name='Verbatim Char'] => code",
2611
+ "r[style-name='HTML Code'] => code"
2612
+ ];
2613
+ IMAGE_EXT = {
2614
+ "image/png": "png",
2615
+ "image/jpeg": "jpg",
2616
+ "image/jpg": "jpg",
2617
+ "image/gif": "gif",
2618
+ "image/bmp": "bmp",
2619
+ "image/tiff": "tiff",
2620
+ "image/svg+xml": "svg",
2621
+ "image/webp": "webp",
2622
+ "image/x-emf": "emf",
2623
+ "image/emf": "emf",
2624
+ "image/x-wmf": "wmf",
2625
+ "image/wmf": "wmf"
2626
+ };
2627
+ ALERT_LABELS = {
2628
+ \u6CE8\u610F: "NOTE",
2629
+ \u63D0\u793A: "TIP",
2630
+ \u91CD\u8981: "IMPORTANT",
2631
+ \u8B66\u544A: "WARNING",
2632
+ \u5C0F\u5FC3: "CAUTION",
2633
+ note: "NOTE",
2634
+ tip: "TIP",
2635
+ important: "IMPORTANT",
2636
+ warning: "WARNING",
2637
+ caution: "CAUTION"
2638
+ };
2639
+ FOOTNOTE_REF_RE = /^#(footnote|endnote)-(\d+)$/;
2640
+ FOOTNOTE_BACK_RE = /^#(footnote|endnote)-ref-\d+$/;
2641
+ FOOTNOTE_ITEM_RE = /^(footnote|endnote)-(\d+)$/;
2642
+ }
2643
+ });
2644
+
2645
+ // src/node/import/split.ts
2646
+ function oneLine(text) {
2647
+ return text.replace(/[ \t\r\n]+/g, " ").replace(/^[ \t]+|[ \t]+$/g, "");
2648
+ }
2649
+ function reheading(source, node, depth) {
2650
+ const first = node.children[0];
2651
+ const last = node.children.at(-1);
2652
+ const inline = first && last ? oneLine(source.slice(off(first, "start"), off(last, "end"))) : "";
2653
+ const hashes = "#".repeat(Math.min(6, Math.max(1, depth)));
2654
+ return {
2655
+ start: off(node, "start"),
2656
+ end: off(node, "end"),
2657
+ text: inline ? `${hashes} ${inline}` : hashes
2658
+ };
2659
+ }
2660
+ function splitManuscript(source, opts) {
2661
+ const tree = parseMarkdown(source);
2662
+ const cuts = [];
2663
+ const defs = [];
2664
+ const refs = [];
2665
+ for (const node of tree.children) {
2666
+ if (node.type === "yaml") {
2667
+ cuts.push({ start: off(node, "start"), end: off(node, "end"), text: "" });
2668
+ } else if (node.type === "footnoteDefinition" || node.type === "definition") {
2669
+ const start = off(node, "start");
2670
+ let end = off(node, "end");
2671
+ while (source[end] === "\n" || source[end] === "\r") end++;
2672
+ const kind = node.type === "footnoteDefinition" ? "footnote" : "link";
2673
+ defs.push({
2674
+ key: `${kind}:${node.identifier}`,
2675
+ start,
2676
+ end,
2677
+ text: source.slice(start, off(node, "end")).trim()
2678
+ });
2679
+ cuts.push({ start, end, text: "" });
2680
+ }
2681
+ }
2682
+ const headings = [];
2683
+ walk(tree, (node) => {
2684
+ if (node.type === "heading") headings.push(node);
2685
+ else if (node.type === "footnoteReference") {
2686
+ refs.push({ key: `footnote:${node.identifier}`, offset: off(node, "start") });
2687
+ } else if (node.type === "linkReference" || node.type === "imageReference") {
2688
+ refs.push({ key: `link:${node.identifier}`, offset: off(node, "start") });
2689
+ }
2690
+ });
2691
+ const referenced = new Set(refs.map((r) => r.key));
2692
+ const orphansIn = (start, end) => defs.filter((d) => d.start >= start && d.start < end && !referenced.has(d.key));
2693
+ const body = (start, end, shift2, extra) => {
2694
+ const edits = [];
2695
+ for (const c of cuts) {
2696
+ if (c.start >= start && c.start < end) {
2697
+ edits.push({ start: c.start - start, end: Math.min(c.end, end) - start, text: "" });
2698
+ }
2699
+ }
2700
+ if (shift2 !== 0) {
2701
+ for (const h of headings) {
2702
+ const depth = h.depth + shift2;
2703
+ if (off(h, "start") < start || off(h, "end") > end || depth < 2) continue;
2704
+ const edit = reheading(source, h, depth);
2705
+ edits.push({ ...edit, start: edit.start - start, end: edit.end - start });
2706
+ }
2707
+ }
2708
+ const text = applyEdits(source.slice(start, end), edits).trim();
2709
+ const used = [];
2710
+ for (const ref of refs) {
2711
+ if (ref.offset < start || ref.offset >= end) continue;
2712
+ const def = defs.find((d) => d.key === ref.key);
2713
+ if (def && !used.includes(def)) used.push(def);
2714
+ }
2715
+ for (const def of extra) if (!used.includes(def)) used.push(def);
2716
+ return [text, ...used.map((d) => d.text)].filter(Boolean).join("\n\n");
2717
+ };
2718
+ const topLevel = tree.children.filter((n) => n.type === "heading");
2719
+ const level = topLevel.length ? Math.min(...topLevel.map((h) => h.depth)) : 0;
2720
+ const unreferenced = defs.filter((d) => !referenced.has(d.key));
2721
+ if (!opts.split || level === 0) {
2722
+ const h1s = headings.filter((h) => h.depth === 1);
2723
+ const first = tree.children.find((n) => n.type !== "yaml");
2724
+ if (h1s.length === 1 && first === h1s[0]) {
2725
+ return {
2726
+ chapters: [
2727
+ {
2728
+ title: oneLine(textOf(h1s[0])) || opts.fallbackTitle,
2729
+ body: body(off(h1s[0], "end"), source.length, 0, unreferenced)
2730
+ }
2731
+ ],
2732
+ noHeadings: false
2733
+ };
2734
+ }
2735
+ return {
2736
+ chapters: [
2737
+ {
2738
+ title: opts.fallbackTitle,
2739
+ body: body(0, source.length, h1s.length ? 1 : 0, unreferenced)
2740
+ }
2741
+ ],
2742
+ noHeadings: headings.length === 0
2743
+ };
2744
+ }
2745
+ const splits = topLevel.filter((h) => h.depth === level);
2746
+ const shift = 1 - level;
2747
+ const chapters = [];
2748
+ const firstStart = off(splits[0], "start");
2749
+ const leadingOrphans = orphansIn(0, firstStart);
2750
+ const hasLeading = body(0, firstStart, shift, []).trim() !== "";
2751
+ if (hasLeading) {
2752
+ chapters.push({
2753
+ title: opts.frontMatterTitle,
2754
+ body: body(0, firstStart, shift, leadingOrphans)
2755
+ });
2756
+ }
2757
+ splits.forEach((heading, i) => {
2758
+ const next = splits[i + 1];
2759
+ const end = next ? off(next, "start") : source.length;
2760
+ const extra = orphansIn(off(heading, "start"), end);
2761
+ if (i === 0 && !hasLeading) extra.unshift(...leadingOrphans);
2762
+ chapters.push({
2763
+ title: oneLine(textOf(heading)) || opts.fallbackTitle,
2764
+ body: body(off(heading, "end"), end, shift, extra)
2765
+ });
2766
+ });
2767
+ return { chapters, noHeadings: false };
2768
+ }
2769
+ var off;
2770
+ var init_split = __esm({
2771
+ "src/node/import/split.ts"() {
2772
+ "use strict";
2773
+ init_markdown();
2774
+ init_common();
2775
+ off = (node, edge) => node.position?.[edge].offset ?? 0;
2776
+ }
2777
+ });
2778
+
2779
+ // src/node/import/index.ts
2780
+ var import_exports = {};
2781
+ __export(import_exports, {
2782
+ htmlToMarkdown: () => htmlToMarkdown,
2783
+ importFile: () => importFile,
2784
+ splitManuscript: () => splitManuscript
2785
+ });
2786
+ import { existsSync as existsSync11, statSync as statSync3 } from "fs";
2787
+ import { basename as basename6, extname as extname6, resolve as resolve7 } from "path";
2788
+ async function importFile(book, file, opts = {}) {
2789
+ const path = resolve7(file);
2790
+ if (!existsSync11(path) || !statSync3(path).isFile()) throw new Error(`\u627E\u4E0D\u5230\u6A94\u6848\uFF1A${file}`);
2791
+ const ext = extname6(path).toLowerCase();
2792
+ if (ext !== ".docx" && !MARKDOWN_EXTENSIONS.has(ext)) {
2793
+ throw new Error(
2794
+ `\u4E0D\u652F\u63F4\u7684\u6A94\u6848\u683C\u5F0F\uFF1A${ext || "\uFF08\u6C92\u6709\u526F\u6A94\u540D\uFF09"}\u3002\u53EF\u4EE5\u532F\u5165 .docx\u3001.md\u3001.markdown \u6216 .txt\uFF1B.doc \u8ACB\u5148\u5728 Word \u53E6\u5B58\u6210 .docx\u3002`
2795
+ );
2796
+ }
2797
+ const config = await book.loadConfig();
2798
+ const log = { assets: [], warnings: [] };
2799
+ const markdown = ext === ".docx" ? await readDocxFile(path, book, log) : await readMarkdownFile(path, book, log);
2800
+ if (!markdown.trim()) {
2801
+ log.warnings.push(`\u300C${basename6(path)}\u300D\u6C92\u6709\u5167\u5BB9\uFF0C\u6C92\u6709\u5EFA\u7ACB\u4EFB\u4F55\u7AE0\u7BC0\u3002`);
2802
+ return { created: [], assets: log.assets, warnings: log.warnings };
2803
+ }
2804
+ const split = opts.split !== false;
2805
+ const name = basename6(path, extname6(path)).trim() || "\u532F\u5165\u7684\u7A3F\u4EF6";
2806
+ const { chapters, noHeadings } = splitManuscript(markdown, {
2807
+ split,
2808
+ fallbackTitle: name,
2809
+ frontMatterTitle: isEnglish(config.language) ? "Front matter" : "\u524D\u8A00\u7D20\u6750"
2810
+ });
2811
+ if (noHeadings) {
2812
+ log.warnings.push(
2813
+ `\u300C${basename6(path)}\u300D\u88E1\u6C92\u6709\u5075\u6E2C\u5230\u6A19\u984C\u6A23\u5F0F\uFF08Word \u7684\u300C\u6A19\u984C 1\uFF0F\u6A19\u984C 2\u300D\u6216 Markdown \u7684 # \u6A19\u984C\uFF09\uFF0C\u6240\u4EE5\u6574\u4EFD\u532F\u5165\u6210\u4E00\u7AE0\u300C${name}\u300D\u3002\u53EF\u4EE5\u8ACB AI \u4F9D\u5167\u5BB9\u5E6B\u4F60\u5207\u5206\u6210\u7AE0\u7BC0\u3002`
2814
+ );
2815
+ }
2816
+ const created = [];
2817
+ for (const chapter of chapters) {
2818
+ created.push(
2819
+ await book.createChapter(chapter.title.slice(0, 200), {
2820
+ body: chapter.body,
2821
+ status: "draft",
2822
+ listed: opts.listed !== false
2823
+ })
2824
+ );
2825
+ }
2826
+ return { created, assets: log.assets, warnings: log.warnings };
2827
+ }
2828
+ var MARKDOWN_EXTENSIONS;
2829
+ var init_import = __esm({
2830
+ "src/node/import/index.ts"() {
2831
+ "use strict";
2832
+ init_common();
2833
+ init_docx2();
2834
+ init_markdown3();
2835
+ init_split();
2836
+ init_docx2();
2837
+ init_split();
2838
+ MARKDOWN_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".markdown", ".txt"]);
2839
+ }
2840
+ });
2841
+
2842
+ // src/cli/index.ts
2843
+ import { spawn as spawn2 } from "child_process";
2844
+ import { basename as basename7, extname as extname7 } from "path";
2845
+ import { createInterface } from "readline/promises";
2846
+ import { parseArgs } from "util";
2847
+
2848
+ // src/node/project.ts
2849
+ import { existsSync as existsSync3 } from "fs";
2850
+ import { cp, mkdir as mkdir3, readdir as readdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
2851
+ import { dirname as dirname2, join as join3, relative as relative2, resolve, sep as sep2 } from "path";
2852
+ import { isMap as isMap2, parseDocument as parseDocument2 } from "yaml";
2853
+
2854
+ // src/node/book.ts
2855
+ init_config();
2856
+ import { createHash } from "crypto";
2857
+ import { existsSync } from "fs";
2858
+ import { mkdir, readdir, readFile, rename, stat, writeFile } from "fs/promises";
2859
+ import { dirname, extname, join, relative, sep } from "path";
2860
+ import { isMap, parseDocument } from "yaml";
2861
+
2862
+ // src/shared/edits.ts
2863
+ init_annotations();
2864
+ init_markdown();
2865
+ import { stringify as stringifyYaml } from "yaml";
2866
+ var EditConflict = class extends Error {
2867
+ constructor(message = "The chapter changed since it was loaded.") {
2868
+ super(message);
2869
+ this.name = "EditConflict";
2870
+ }
2871
+ };
2872
+ function replaceRange(source, start, end, expected, text) {
2873
+ if (source.slice(start, end) !== expected) throw new EditConflict();
2874
+ return source.slice(0, start) + text + source.slice(end);
2875
+ }
2876
+ function locateBlock(source, blockStart, expected) {
2877
+ const { blocks } = parseChapter(source);
2878
+ const exact = blocks.find(
2879
+ (b) => b.start === blockStart && source.slice(b.start, b.end) === expected
2880
+ );
2881
+ if (exact) return exact;
2882
+ const byText = blocks.filter((b) => source.slice(b.start, b.end) === expected);
2883
+ if (byText.length === 1) return byText[0];
2884
+ throw new EditConflict();
2885
+ }
2886
+ function insertComment(source, c) {
2887
+ const block = locateBlock(source, c.blockStart, c.blockText);
2888
+ const id = newAnnotationId("comment");
2889
+ const marker = formatMarker(
2890
+ "comment",
2891
+ {
2892
+ id,
2893
+ by: c.by ?? "human",
2894
+ at: (c.now ?? /* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z"),
2895
+ quote: c.quote?.trim().slice(0, 200) ?? ""
2896
+ },
2897
+ c.body.trim()
2898
+ );
2899
+ const at = block.start;
2900
+ return { source: `${source.slice(0, at)}${marker}
2901
+ ${source.slice(at)}`, id };
2902
+ }
2903
+ function removeAnnotation(source, id) {
2904
+ const { annotations } = parseChapter(source);
2905
+ const a = annotations.find((x) => x.id === id);
2906
+ if (!a) throw new EditConflict("The note no longer exists.");
2907
+ return cutMarker(source, a.start, a.end);
2908
+ }
2909
+ function cutMarker(source, start, end) {
2910
+ let cutEnd = end;
2911
+ if (source[cutEnd] === "\n") cutEnd++;
2912
+ const before = source.slice(0, start);
2913
+ if (source[cutEnd] === "\n" && (before.endsWith("\n\n") || before === "")) cutEnd++;
2914
+ return before + source.slice(cutEnd);
2915
+ }
2916
+ function acceptSuggestion(source, id) {
2917
+ const parsed = parseChapter(source);
2918
+ const s = parsed.annotations.find((x) => x.id === id);
2919
+ if (s?.kind !== "suggest") throw new EditConflict("The suggestion no longer exists.");
2920
+ const replacement = s.body.replace(/^\n+|\s+$/g, "");
2921
+ if (s.blockIndex === null || s.span === 0) {
2922
+ let end = s.end;
2923
+ if (source[end] === "\n") end++;
2924
+ const tail = source.slice(end);
2925
+ const sep7 = tail === "" ? "\n" : tail.startsWith("\n") ? "\n" : "\n\n";
2926
+ return source.slice(0, s.start) + replacement + sep7 + tail;
2927
+ }
2928
+ const first = parsed.blocks[s.blockIndex];
2929
+ const last = parsed.blocks[Math.min(parsed.blocks.length - 1, s.blockIndex + s.span - 1)];
2930
+ const kept = parsed.annotations.filter((a) => a.id !== s.id && a.start >= s.start && a.end <= last.end).map((a) => source.slice(a.start, a.end));
2931
+ const prefix = kept.length ? `${kept.join("\n")}
2932
+ ` : "";
2933
+ const start = Math.min(s.start, first.start);
2934
+ return source.slice(0, start) + prefix + replacement + source.slice(last.end);
2935
+ }
2936
+ function setFrontmatter(source, key, value) {
2937
+ const { frontmatter, frontmatterRange } = parseChapter(source);
2938
+ const next = { ...frontmatter };
2939
+ if (value === void 0 || value === "") delete next[key];
2940
+ else next[key] = value;
2941
+ const body = frontmatterRange ? source.slice(frontmatterRange[1]).replace(/^\n+/, "") : source;
2942
+ if (Object.keys(next).length === 0) return body;
2943
+ const yaml = stringifyYaml(next, { lineWidth: 0 }).trimEnd();
2944
+ return `---
2945
+ ${yaml}
2946
+ ---
2947
+
2948
+ ${body}`;
2949
+ }
2950
+ function setTitle(source, title) {
2951
+ const parsed = parseChapter(source);
2952
+ const clean = title.replace(/[ \t\r\n]+/g, " ").trim();
2953
+ const h1 = parsed.blocks.find((b) => b.node.type === "heading" && b.node.depth === 1);
2954
+ if (h1) return `${source.slice(0, h1.start)}# ${clean}${source.slice(h1.end)}`;
2955
+ const at = parsed.frontmatterRange ? parsed.frontmatterRange[1] : 0;
2956
+ const head = source.slice(0, at);
2957
+ const rest = source.slice(at).replace(/^\n+/, "");
2958
+ return `${head}${head ? "\n\n" : ""}# ${clean}
2959
+
2960
+ ${rest}`;
2961
+ }
2962
+
2963
+ // src/node/book.ts
2964
+ init_markdown();
2965
+ var BOOK_FILE = "book.yaml";
2966
+ var ProjectError = class extends Error {
2967
+ constructor(message, status = 400) {
2968
+ super(message);
2969
+ this.status = status;
2970
+ this.name = "ProjectError";
2971
+ }
2972
+ status;
2973
+ };
2974
+ function versionOf(source) {
2975
+ return createHash("sha1").update(source).digest("hex").slice(0, 12);
2976
+ }
2977
+ function isChapterId(id) {
2978
+ return /^[^/\\]+\.md$/i.test(id) && !id.startsWith(".") && !id.includes("..");
2979
+ }
2980
+ function statusOf(value) {
2981
+ return CHAPTER_STATUSES.includes(value) ? value : "draft";
2982
+ }
2983
+ function summarizeChapter(id, source, listed, updatedAt) {
2984
+ const parsed = parseChapter(source);
2985
+ let comments = 0;
2986
+ let aiNotes = 0;
2987
+ let suggestions = 0;
2988
+ for (const a of parsed.annotations) {
2989
+ if (a.kind === "suggest") suggestions++;
2990
+ else if (a.by === "ai") aiNotes++;
2991
+ else comments++;
2992
+ }
2993
+ const fm = parsed.frontmatter;
2994
+ return {
2995
+ id,
2996
+ title: parsed.title || (typeof fm.title === "string" ? fm.title : "") || id.replace(/\.md$/i, ""),
2997
+ status: statusOf(fm.status),
2998
+ summary: typeof fm.summary === "string" ? fm.summary : "",
2999
+ words: parsed.words,
3000
+ sections: parsed.headings.filter((h) => h.depth === 2).map((h) => h.text),
3001
+ comments,
3002
+ aiNotes,
3003
+ suggestions,
3004
+ listed,
3005
+ version: versionOf(source),
3006
+ updatedAt: updatedAt.toISOString()
3007
+ };
3008
+ }
3009
+ function slugify(title) {
3010
+ return title.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
3011
+ }
3012
+ var BRIEF_PLACEHOLDER = "\uFF08\u5C1A\u672A\u9032\u884C\u7ACB\u9805\u8A2A\u8AC7\uFF09";
3013
+ function safeFileName(name) {
3014
+ const base = name.split(/[/\\]/).pop() ?? "";
3015
+ const clean = [...base.normalize("NFC")].filter((ch) => ch.charCodeAt(0) >= 32).join("").replace(/[<>:"|?*]/g, "").replace(/\s+/g, "-").replace(/^\.+/, "").slice(0, 120);
3016
+ return clean || "file";
3017
+ }
3018
+ var Book = class {
3019
+ constructor(project, id) {
3020
+ this.project = project;
3021
+ this.id = id;
3022
+ }
3023
+ project;
3024
+ id;
3025
+ get root() {
3026
+ return join(this.project.booksDir, this.id);
3027
+ }
3028
+ get configPath() {
3029
+ return join(this.root, BOOK_FILE);
3030
+ }
3031
+ get chaptersDir() {
3032
+ return join(this.root, "chapters");
3033
+ }
3034
+ get outputDir() {
3035
+ return join(this.project.root, "output", this.id);
3036
+ }
3037
+ get history() {
3038
+ return this.project.historyFor(this.id);
3039
+ }
3040
+ exists() {
3041
+ return existsSync(this.configPath);
3042
+ }
3043
+ chapterPath(id) {
3044
+ if (!isChapterId(id)) throw new ProjectError(`\u4E0D\u5408\u6CD5\u7684\u7AE0\u7BC0\u6A94\u540D\uFF1A${id}`);
3045
+ return join(this.chaptersDir, id);
3046
+ }
3047
+ async readConfigDocument() {
3048
+ const text = existsSync(this.configPath) ? await readFile(this.configPath, "utf8") : "";
3049
+ return parseDocument(text);
3050
+ }
3051
+ async loadConfig() {
3052
+ const [defaults, own] = await Promise.all([
3053
+ this.project.loadDefaults(),
3054
+ this.readConfigDocument()
3055
+ ]);
3056
+ return resolveConfig(mergeConfig(defaults, own.toJS()));
3057
+ }
3058
+ /** Edit book.yaml in place, keeping the author's comments and layout. */
3059
+ async updateConfig(mutate) {
3060
+ const doc = await this.readConfigDocument();
3061
+ if (!isMap(doc.contents)) doc.contents = doc.createNode({});
3062
+ mutate(doc);
3063
+ await writeFile(this.configPath, doc.toString({ lineWidth: 0 }));
3064
+ }
3065
+ async listChapterFiles() {
3066
+ if (!existsSync(this.chaptersDir)) return [];
3067
+ const entries = await readdir(this.chaptersDir, { withFileTypes: true });
3068
+ return entries.filter((e) => e.isFile() && isChapterId(e.name)).map((e) => e.name).sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
3069
+ }
3070
+ async readChapter(id) {
3071
+ const path = this.chapterPath(id);
3072
+ if (!existsSync(path)) throw new ProjectError(`\u627E\u4E0D\u5230\u7AE0\u7BC0\uFF1A${id}`, 404);
3073
+ const source = await readFile(path, "utf8");
3074
+ return { id, source, version: versionOf(source) };
3075
+ }
3076
+ /**
3077
+ * Write a chapter. With `baseVersion`, refuse when the file changed since
3078
+ * the caller read it. The previous content goes to history first.
3079
+ */
3080
+ async writeChapter(id, source, baseVersion) {
3081
+ const path = this.chapterPath(id);
3082
+ const previous = existsSync(path) ? await readFile(path, "utf8") : null;
3083
+ if (baseVersion && previous !== null && versionOf(previous) !== baseVersion) {
3084
+ throw new ProjectError(
3085
+ "\u9019\u4E00\u7AE0\u5728\u4F60\u8F09\u5165\u5F8C\u88AB\u4FEE\u6539\u904E\u4E86\uFF08\u53EF\u80FD\u662F AI \u6B63\u5728\u6539\uFF09\u3002\u8ACB\u91CD\u65B0\u6574\u7406\u5F8C\u518D\u8A66\u3002",
3086
+ 409
3087
+ );
3088
+ }
3089
+ if (previous === source) return versionOf(source);
3090
+ if (previous !== null) await this.history.snapshot(id, previous);
3091
+ await mkdir(dirname(path), { recursive: true });
3092
+ const tmp = `${path}.mosage-tmp`;
3093
+ await writeFile(tmp, source);
3094
+ await rename(tmp, path);
3095
+ this.history.remember(id, source);
3096
+ return versionOf(source);
3097
+ }
3098
+ /** Read–transform–write with a version check against what the client saw. */
3099
+ async transformChapter(id, transform, baseVersion) {
3100
+ const { source, version } = await this.readChapter(id);
3101
+ if (baseVersion && baseVersion !== version) {
3102
+ throw new ProjectError(
3103
+ "\u9019\u4E00\u7AE0\u5728\u4F60\u8F09\u5165\u5F8C\u88AB\u4FEE\u6539\u904E\u4E86\uFF08\u53EF\u80FD\u662F AI \u6B63\u5728\u6539\uFF09\u3002\u8ACB\u91CD\u65B0\u6574\u7406\u5F8C\u518D\u8A66\u3002",
3104
+ 409
3105
+ );
3106
+ }
3107
+ return this.writeChapter(id, transform(source), version);
3108
+ }
3109
+ async hasBrief() {
3110
+ const path = join(this.root, "brief.md");
3111
+ if (!existsSync(path)) return false;
3112
+ return !(await readFile(path, "utf8")).includes(BRIEF_PLACEHOLDER);
3113
+ }
3114
+ async summarize() {
3115
+ const config = await this.loadConfig();
3116
+ const files = await this.listChapterFiles();
3117
+ const fileSet = new Set(files);
3118
+ const listedSet = new Set(config.chapters);
3119
+ const summaries = /* @__PURE__ */ new Map();
3120
+ let updated = existsSync(this.configPath) ? (await stat(this.configPath)).mtime : /* @__PURE__ */ new Date(0);
3121
+ await Promise.all(
3122
+ files.map(async (id) => {
3123
+ const path = this.chapterPath(id);
3124
+ const [source, info] = await Promise.all([readFile(path, "utf8"), stat(path)]);
3125
+ if (info.mtime > updated) updated = info.mtime;
3126
+ summaries.set(id, summarizeChapter(id, source, listedSet.has(id), info.mtime));
3127
+ })
3128
+ );
3129
+ const chapters = config.chapters.filter((id) => fileSet.has(id)).map((id) => summaries.get(id));
3130
+ const drafts = files.filter((id) => !listedSet.has(id)).map((id) => summaries.get(id));
3131
+ const totals = { words: 0, comments: 0, aiNotes: 0, suggestions: 0 };
3132
+ for (const c of [...chapters, ...drafts]) {
3133
+ totals.words += c.listed ? c.words : 0;
3134
+ totals.comments += c.comments;
3135
+ totals.aiNotes += c.aiNotes;
3136
+ totals.suggestions += c.suggestions;
3137
+ }
3138
+ return {
3139
+ id: this.id,
3140
+ config,
3141
+ chapters,
3142
+ drafts,
3143
+ missing: config.chapters.filter((id) => !fileSet.has(id)),
3144
+ totals,
3145
+ hasBrief: await this.hasBrief(),
3146
+ updatedAt: updated.toISOString()
3147
+ };
3148
+ }
3149
+ /** The listed chapters with their sources, in reading order — export input. */
3150
+ async loadManuscript() {
3151
+ const config = await this.loadConfig();
3152
+ const files = new Set(await this.listChapterFiles());
3153
+ const ids = config.chapters.filter((id) => files.has(id));
3154
+ const chapters = await Promise.all(
3155
+ ids.map(async (id) => ({ id, source: await readFile(this.chapterPath(id), "utf8") }))
3156
+ );
3157
+ return { config, chapters };
3158
+ }
3159
+ async setChapterOrder(ids) {
3160
+ for (const id of ids) if (!isChapterId(id)) throw new ProjectError(`\u4E0D\u5408\u6CD5\u7684\u7AE0\u7BC0\u6A94\u540D\uFF1A${id}`);
3161
+ const unique = [...new Set(ids)];
3162
+ await this.updateConfig((doc) => {
3163
+ doc.set("chapters", doc.createNode(unique));
3164
+ });
3165
+ }
3166
+ async addToOrder(id, position) {
3167
+ const config = await this.loadConfig();
3168
+ const order = config.chapters.filter((c) => c !== id);
3169
+ order.splice(position ?? order.length, 0, id);
3170
+ await this.setChapterOrder(order);
3171
+ }
3172
+ async removeFromOrder(id) {
3173
+ const config = await this.loadConfig();
3174
+ await this.setChapterOrder(config.chapters.filter((c) => c !== id));
3175
+ }
3176
+ /** Create `chapters/NN-slug.md` and append it to the chapter order. */
3177
+ async createChapter(title, opts = {}) {
3178
+ const files = await this.listChapterFiles();
3179
+ const numbers = files.map((f) => Number.parseInt(f, 10)).filter(Number.isFinite);
3180
+ const next = String(numbers.length ? Math.max(...numbers) + 1 : 1).padStart(2, "0");
3181
+ const slug = slugify(title) || "chapter";
3182
+ let id = `${next}-${slug}.md`;
3183
+ for (let n = 2; files.includes(id); n++) id = `${next}-${slug}-${n}.md`;
3184
+ const body = opts.body?.trim() ? `${opts.body.trim()}
3185
+ ` : "";
3186
+ let source = `# ${title.trim() || "\u672A\u547D\u540D\u7AE0\u7BC0"}
3187
+
3188
+ ${body}`;
3189
+ source = setFrontmatter(source, "status", opts.status ?? "idea");
3190
+ if (opts.summary) source = setFrontmatter(source, "summary", opts.summary);
3191
+ await this.writeChapter(id, source);
3192
+ if (opts.listed !== false) await this.addToOrder(id);
3193
+ return id;
3194
+ }
3195
+ folderPath(folder) {
3196
+ if (folder !== "assets" && folder !== "notes")
3197
+ throw new ProjectError(`\u4E0D\u5408\u6CD5\u7684\u8CC7\u6599\u593E\uFF1A${folder}`);
3198
+ return join(this.root, folder);
3199
+ }
3200
+ /** Everything under assets/ and notes/ — images and reference material. */
3201
+ async listFiles() {
3202
+ const chapterSources = await Promise.all(
3203
+ (await this.listChapterFiles()).map((id) => readFile(this.chapterPath(id), "utf8"))
3204
+ );
3205
+ const everything = chapterSources.join("\n");
3206
+ const walk2 = async (folder) => {
3207
+ const base = this.folderPath(folder);
3208
+ if (!existsSync(base)) return [];
3209
+ const entries = await readdir(base, { recursive: true, withFileTypes: true });
3210
+ const files = [];
3211
+ for (const e of entries) {
3212
+ if (!e.isFile() || e.name.startsWith(".")) continue;
3213
+ const full = join(e.parentPath, e.name);
3214
+ const name = relative(base, full).split(sep).join("/");
3215
+ if (name.split("/").some((seg) => seg.startsWith("."))) continue;
3216
+ const info = await stat(full);
3217
+ files.push({
3218
+ name,
3219
+ path: `${folder}/${name}`,
3220
+ size: info.size,
3221
+ updatedAt: info.mtime.toISOString(),
3222
+ used: folder === "assets" ? everything.includes(name) || everything.includes(encodeURI(name)) : void 0
3223
+ });
3224
+ }
3225
+ return files.sort((a, b) => a.name.localeCompare(b.name, "zh-Hant"));
3226
+ };
3227
+ return { assets: await walk2("assets"), notes: await walk2("notes") };
3228
+ }
3229
+ /** Copy a file into assets/ or notes/, never overwriting. Returns its name. */
3230
+ async saveFile(folder, name, data) {
3231
+ const dir = this.folderPath(folder);
3232
+ await mkdir(dir, { recursive: true });
3233
+ const clean = safeFileName(name);
3234
+ const ext = extname(clean);
3235
+ const stem = clean.slice(0, clean.length - ext.length);
3236
+ let final = clean;
3237
+ for (let n = 2; existsSync(join(dir, final)); n++) final = `${stem}-${n}${ext}`;
3238
+ await writeFile(join(dir, final), data);
3239
+ return final;
3240
+ }
3241
+ /** Move a file to .mosage/trash instead of deleting it. */
3242
+ async trashFile(folder, name) {
3243
+ const base = this.folderPath(folder);
3244
+ const path = join(base, ...name.split("/"));
3245
+ const inside = relative(base, path);
3246
+ if (!inside || inside.startsWith("..") || !existsSync(path)) {
3247
+ throw new ProjectError(`\u627E\u4E0D\u5230\u6A94\u6848\uFF1A${folder}/${name}`, 404);
3248
+ }
3249
+ const trash = join(this.project.stateDir, "trash", this.id, folder);
3250
+ await mkdir(trash, { recursive: true });
3251
+ const stamp2 = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3252
+ await rename(path, join(trash, `${stamp2}-${safeFileName(name)}`));
3253
+ }
3254
+ async setChapterStatus(id, status, baseVersion) {
3255
+ if (!CHAPTER_STATUSES.includes(status)) throw new ProjectError(`\u4E0D\u5408\u6CD5\u7684\u72C0\u614B\uFF1A${status}`);
3256
+ return this.transformChapter(id, (s) => setFrontmatter(s, "status", status), baseVersion);
3257
+ }
3258
+ async setChapterSummary(id, summary, baseVersion) {
3259
+ return this.transformChapter(
3260
+ id,
3261
+ (s) => setFrontmatter(s, "summary", summary.trim()),
3262
+ baseVersion
3263
+ );
3264
+ }
3265
+ async setChapterTitle(id, title, baseVersion) {
3266
+ if (!title.trim()) throw new ProjectError("\u7AE0\u7BC0\u6A19\u984C\u4E0D\u80FD\u662F\u7A7A\u7684");
3267
+ return this.transformChapter(id, (s) => setTitle(s, title), baseVersion);
3268
+ }
3269
+ };
3270
+
3271
+ // src/node/history.ts
3272
+ init_wordcount();
3273
+ import { existsSync as existsSync2 } from "fs";
3274
+ import { mkdir as mkdir2, readdir as readdir2, readFile as readFile2, rm, writeFile as writeFile2 } from "fs/promises";
3275
+ import { join as join2 } from "path";
3276
+ var QUIET_MS = 9e4;
3277
+ var KEEP = 100;
3278
+ function stamp(date) {
3279
+ return date.toISOString().replace(/[:.]/g, "-");
3280
+ }
3281
+ function parseStamp(name) {
3282
+ const m = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/.exec(name);
3283
+ return m ? `${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z` : name;
3284
+ }
3285
+ var History = class {
3286
+ constructor(dir) {
3287
+ this.dir = dir;
3288
+ }
3289
+ dir;
3290
+ known = /* @__PURE__ */ new Map();
3291
+ lastSnapshot = /* @__PURE__ */ new Map();
3292
+ chapterDir(id) {
3293
+ return join2(this.dir, id.replace(/\.md$/i, ""));
3294
+ }
3295
+ /** Record the content we last saw on disk, without snapshotting. */
3296
+ remember(id, content) {
3297
+ this.known.set(id, content);
3298
+ }
3299
+ /** A watcher saw the file change: snapshot what it was before. */
3300
+ async observe(id, content) {
3301
+ const before = this.known.get(id);
3302
+ this.known.set(id, content);
3303
+ if (before === void 0 || before === content) return false;
3304
+ await this.snapshot(id, before);
3305
+ return true;
3306
+ }
3307
+ async snapshot(id, content, force = false) {
3308
+ const now = Date.now();
3309
+ if (!force && now - (this.lastSnapshot.get(id) ?? 0) < QUIET_MS) return;
3310
+ this.lastSnapshot.set(id, now);
3311
+ const dir = this.chapterDir(id);
3312
+ await mkdir2(dir, { recursive: true });
3313
+ const names = (await readdir2(dir)).filter((n) => n.endsWith(".md")).sort();
3314
+ const latest = names.at(-1);
3315
+ if (latest && await readFile2(join2(dir, latest), "utf8") === content) return;
3316
+ await writeFile2(join2(dir, `${stamp(new Date(now))}.md`), content);
3317
+ for (const old of names.slice(0, Math.max(0, names.length + 1 - KEEP))) {
3318
+ await rm(join2(dir, old), { force: true });
3319
+ }
3320
+ }
3321
+ async list(id) {
3322
+ const dir = this.chapterDir(id);
3323
+ if (!existsSync2(dir)) return [];
3324
+ const names = (await readdir2(dir)).filter((n) => n.endsWith(".md")).sort().reverse();
3325
+ return Promise.all(
3326
+ names.map(async (name) => {
3327
+ const content = await readFile2(join2(dir, name), "utf8");
3328
+ return {
3329
+ id: name.replace(/\.md$/, ""),
3330
+ at: parseStamp(name),
3331
+ words: countWords(content),
3332
+ size: content.length
3333
+ };
3334
+ })
3335
+ );
3336
+ }
3337
+ async read(id, snapshotId) {
3338
+ if (!/^[\w-]+$/.test(snapshotId)) throw new Error("invalid snapshot id");
3339
+ return readFile2(join2(this.chapterDir(id), `${snapshotId}.md`), "utf8");
3340
+ }
3341
+ };
3342
+
3343
+ // src/node/project.ts
3344
+ var PROJECT_FILE = "mosage.yaml";
3345
+ function isBookId(id) {
3346
+ return /^[\p{L}\p{N}][\p{L}\p{N}_-]*$/u.test(id) && id.length <= 80;
3347
+ }
3348
+ function bookIdFrom(title) {
3349
+ return slugify(title) || "book";
3350
+ }
3351
+ var Project = class _Project {
3352
+ constructor(root) {
3353
+ this.root = root;
3354
+ }
3355
+ root;
3356
+ histories = /* @__PURE__ */ new Map();
3357
+ /** Walk up from `start` to the folder holding mosage.yaml. */
3358
+ static find(start = process.cwd()) {
3359
+ let dir = resolve(start);
3360
+ for (; ; ) {
3361
+ if (existsSync3(join3(dir, PROJECT_FILE))) return new _Project(dir);
3362
+ const parent = dirname2(dir);
3363
+ if (parent === dir) {
3364
+ throw new ProjectError(
3365
+ `\u627E\u4E0D\u5230 ${PROJECT_FILE}\u3002\u8ACB\u5728 MoSage \u5C08\u6848\u8CC7\u6599\u593E\u88E1\u57F7\u884C\uFF0C\u6216\u5148\u7528 \`npx mosage init <\u8CC7\u6599\u593E>\` \u5EFA\u7ACB\u5C08\u6848\u3002`,
3366
+ 404
3367
+ );
3368
+ }
3369
+ dir = parent;
3370
+ }
3371
+ }
3372
+ get configPath() {
3373
+ return join3(this.root, PROJECT_FILE);
3374
+ }
3375
+ get booksDir() {
3376
+ return join3(this.root, "books");
3377
+ }
3378
+ get stateDir() {
3379
+ return join3(this.root, ".mosage");
3380
+ }
3381
+ historyFor(bookId) {
3382
+ let history = this.histories.get(bookId);
3383
+ if (!history) {
3384
+ history = new History(join3(this.stateDir, "history", bookId));
3385
+ this.histories.set(bookId, history);
3386
+ }
3387
+ return history;
3388
+ }
3389
+ async readConfigDocument() {
3390
+ const text = existsSync3(this.configPath) ? await readFile3(this.configPath, "utf8") : "";
3391
+ return parseDocument2(text);
3392
+ }
3393
+ /** mosage.yaml as plain data — the defaults every book inherits. */
3394
+ async loadDefaults() {
3395
+ const data = (await this.readConfigDocument()).toJS();
3396
+ return data && typeof data === "object" && !Array.isArray(data) ? data : {};
3397
+ }
3398
+ async updateConfig(mutate) {
3399
+ const doc = await this.readConfigDocument();
3400
+ if (!isMap2(doc.contents)) doc.contents = doc.createNode({});
3401
+ mutate(doc);
3402
+ await writeFile3(this.configPath, doc.toString({ lineWidth: 0 }));
3403
+ }
3404
+ book(id) {
3405
+ if (!isBookId(id)) throw new ProjectError(`\u4E0D\u5408\u6CD5\u7684\u66F8\u7C4D\u4EE3\u865F\uFF1A${id}`);
3406
+ const book = new Book(this, id);
3407
+ if (!book.exists()) throw new ProjectError(`\u627E\u4E0D\u5230\u9019\u672C\u66F8\uFF1Abooks/${id}/${BOOK_FILE}`, 404);
3408
+ return book;
3409
+ }
3410
+ /** Book folders, in the order of `books:` in mosage.yaml, then by name. */
3411
+ async listBookIds() {
3412
+ if (!existsSync3(this.booksDir)) return [];
3413
+ const entries = await readdir3(this.booksDir, { withFileTypes: true });
3414
+ const ids = entries.filter((e) => e.isDirectory() && isBookId(e.name)).filter((e) => existsSync3(join3(this.booksDir, e.name, BOOK_FILE))).map((e) => e.name);
3415
+ const defaults = await this.loadDefaults();
3416
+ const order = Array.isArray(defaults.books) ? defaults.books.map(String) : [];
3417
+ const rank = (id) => {
3418
+ const i = order.indexOf(id);
3419
+ return i === -1 ? Number.MAX_SAFE_INTEGER : i;
3420
+ };
3421
+ return ids.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b, "en", { numeric: true }));
3422
+ }
3423
+ async listBooks() {
3424
+ const ids = await this.listBookIds();
3425
+ return Promise.all(ids.map((id) => new Book(this, id).summarize()));
3426
+ }
3427
+ async setBookOrder(ids) {
3428
+ for (const id of ids) if (!isBookId(id)) throw new ProjectError(`\u4E0D\u5408\u6CD5\u7684\u66F8\u7C4D\u4EE3\u865F\uFF1A${id}`);
3429
+ await this.updateConfig((doc) => doc.set("books", doc.createNode([...new Set(ids)])));
3430
+ }
3431
+ /**
3432
+ * Resolve which book a command means: an explicit id, the book folder the
3433
+ * shell is in, or the only book there is.
3434
+ */
3435
+ async resolveBook(explicit, cwd = process.cwd()) {
3436
+ if (explicit) return this.book(explicit);
3437
+ const rel = relative2(this.booksDir, resolve(cwd));
3438
+ if (rel && !rel.startsWith("..")) return this.book(rel.split(sep2)[0]);
3439
+ const ids = await this.listBookIds();
3440
+ if (ids.length === 1) return this.book(ids[0]);
3441
+ if (ids.length === 0) {
3442
+ throw new ProjectError(
3443
+ "\u9019\u500B\u5C08\u6848\u9084\u6C92\u6709\u4EFB\u4F55\u66F8\u3002\u7528 `npx mosage new <\u4EE3\u865F>` \u5EFA\u7ACB\uFF0C\u6216\u8ACB AI \u5E36\u4F60\u7ACB\u9805\u3002"
3444
+ );
3445
+ }
3446
+ throw new ProjectError(`\u6709 ${ids.length} \u672C\u66F8\uFF0C\u8ACB\u6307\u5B9A\u662F\u54EA\u4E00\u672C\uFF1A${ids.join("\u3001")}`);
3447
+ }
3448
+ /** Scaffold books/<id>/ from the bundled template. */
3449
+ async createBook(templateDir, opts = {}) {
3450
+ let id = opts.id?.trim() || bookIdFrom(opts.title ?? "");
3451
+ if (!isBookId(id)) throw new ProjectError(`\u4E0D\u5408\u6CD5\u7684\u66F8\u7C4D\u4EE3\u865F\uFF1A${id}\uFF08\u8ACB\u7528\u82F1\u6587\u3001\u6578\u5B57\u8207 -\uFF09`);
3452
+ const base = id;
3453
+ for (let n = 2; existsSync3(join3(this.booksDir, id)); n++) {
3454
+ if (opts.id) throw new ProjectError(`books/${id} \u5DF2\u7D93\u5B58\u5728`);
3455
+ id = `${base}-${n}`;
3456
+ }
3457
+ const book = new Book(this, id);
3458
+ await mkdir3(this.booksDir, { recursive: true });
3459
+ await cp(templateDir, book.root, { recursive: true });
3460
+ await book.updateConfig((doc) => {
3461
+ if (opts.type) doc.set("type", opts.type);
3462
+ if (opts.title) doc.set("title", opts.title.trim());
3463
+ if (opts.author) doc.set("author", opts.author.trim());
3464
+ });
3465
+ const ids = await this.listBookIds();
3466
+ await this.setBookOrder(ids.includes(id) ? ids : [...ids, id]);
3467
+ return book;
3468
+ }
3469
+ /** Where the author is reading — for the `current-position` skill. */
3470
+ async writeCurrent(position) {
3471
+ await mkdir3(this.stateDir, { recursive: true });
3472
+ await writeFile3(
3473
+ join3(this.stateDir, "current.json"),
3474
+ `${JSON.stringify({ ...position, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)}
3475
+ `
3476
+ );
3477
+ }
3478
+ };
3479
+
3480
+ // src/node/server.ts
3481
+ init_config();
3482
+ import { existsSync as existsSync5 } from "fs";
3483
+ import { readFile as readFile5, stat as stat2, writeFile as writeFile5 } from "fs/promises";
3484
+ import { createServer } from "http";
3485
+ import { basename as basename2, extname as extname3, join as join7, normalize, relative as relative4, resolve as resolve3, sep as sep4 } from "path";
3486
+ import { fileURLToPath as fileURLToPath2 } from "url";
3487
+ import chokidar from "chokidar";
3488
+ var MIME2 = {
3489
+ ".html": "text/html; charset=utf-8",
3490
+ ".js": "text/javascript; charset=utf-8",
3491
+ ".css": "text/css; charset=utf-8",
3492
+ ".json": "application/json; charset=utf-8",
3493
+ ".svg": "image/svg+xml",
3494
+ ".png": "image/png",
3495
+ ".jpg": "image/jpeg",
3496
+ ".jpeg": "image/jpeg",
3497
+ ".gif": "image/gif",
3498
+ ".webp": "image/webp",
3499
+ ".bmp": "image/bmp",
3500
+ ".ico": "image/x-icon",
3501
+ ".woff2": "font/woff2",
3502
+ ".md": "text/markdown; charset=utf-8",
3503
+ ".txt": "text/plain; charset=utf-8",
3504
+ ".pdf": "application/pdf"
3505
+ };
3506
+ var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
3507
+ var HttpError = class extends Error {
3508
+ constructor(status, message) {
3509
+ super(message);
3510
+ this.status = status;
3511
+ }
3512
+ status;
3513
+ };
3514
+ function defaultWebDir() {
3515
+ return fileURLToPath2(new URL("./web/", import.meta.url));
3516
+ }
3517
+ function send(res, status, body) {
3518
+ const text = JSON.stringify(body);
3519
+ res.writeHead(status, {
3520
+ "content-type": "application/json; charset=utf-8",
3521
+ "cache-control": "no-store"
3522
+ });
3523
+ res.end(text);
3524
+ }
3525
+ function reply(res, status, body) {
3526
+ send(res, status, body);
3527
+ return true;
3528
+ }
3529
+ async function readJson(req) {
3530
+ const chunks = [];
3531
+ let size = 0;
3532
+ for await (const chunk of req) {
3533
+ size += chunk.length;
3534
+ if (size > 60 * 1024 * 1024) throw new HttpError(413, "request too large");
3535
+ chunks.push(chunk);
3536
+ }
3537
+ if (chunks.length === 0) return {};
3538
+ try {
3539
+ const data = JSON.parse(Buffer.concat(chunks).toString("utf8"));
3540
+ if (data && typeof data === "object" && !Array.isArray(data)) return data;
3541
+ } catch {
3542
+ }
3543
+ throw new HttpError(400, "invalid JSON body");
3544
+ }
3545
+ function hostnameOf(host) {
3546
+ if (!host) return "";
3547
+ if (host.startsWith("[")) return host.slice(0, host.indexOf("]") + 1);
3548
+ return host.split(":")[0];
3549
+ }
3550
+ function guard(req, allowAnyHost) {
3551
+ if (!allowAnyHost && !LOCAL_HOSTS.has(hostnameOf(req.headers.host).toLowerCase())) {
3552
+ throw new HttpError(403, "unexpected host");
3553
+ }
3554
+ if (req.method === "GET" || req.method === "HEAD") return;
3555
+ if (req.headers["sec-fetch-site"] === "cross-site") {
3556
+ throw new HttpError(403, "cross-site request blocked");
3557
+ }
3558
+ const origin = req.headers.origin;
3559
+ if (origin) {
3560
+ let originHost = "";
3561
+ try {
3562
+ originHost = new URL(origin).host;
3563
+ } catch {
3564
+ }
3565
+ if (originHost !== req.headers.host) throw new HttpError(403, "origin mismatch");
3566
+ }
3567
+ if (req.method !== "DELETE") {
3568
+ const type = req.headers["content-type"] ?? "";
3569
+ if (!type.startsWith("application/json")) {
3570
+ throw new HttpError(415, "content-type must be application/json");
3571
+ }
3572
+ }
3573
+ }
3574
+ function str2(value, name) {
3575
+ if (typeof value !== "string") throw new HttpError(400, `${name} must be a string`);
3576
+ return value;
3577
+ }
3578
+ function int(value, name) {
3579
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
3580
+ throw new HttpError(400, `${name} must be a non-negative integer`);
3581
+ }
3582
+ return value;
3583
+ }
3584
+ function optionalStr(value) {
3585
+ return typeof value === "string" ? value : void 0;
3586
+ }
3587
+ async function startServer(project, opts = {}) {
3588
+ const host = opts.host ?? "127.0.0.1";
3589
+ const allowAnyHost = !["127.0.0.1", "localhost", "::1"].includes(host);
3590
+ const webDir = opts.webDir ?? defaultWebDir();
3591
+ const templateDir = opts.bookTemplateDir;
3592
+ const clients = /* @__PURE__ */ new Set();
3593
+ const broadcast = (event) => {
3594
+ const data = `data: ${JSON.stringify(event)}
3595
+
3596
+ `;
3597
+ for (const res of clients) res.write(data);
3598
+ };
3599
+ for (const bookId of await project.listBookIds()) {
3600
+ const book = project.book(bookId);
3601
+ for (const id of await book.listChapterFiles()) {
3602
+ try {
3603
+ book.history.remember(id, (await book.readChapter(id)).source);
3604
+ } catch {
3605
+ }
3606
+ }
3607
+ }
3608
+ const watcher = chokidar.watch([project.booksDir, project.configPath], {
3609
+ ignoreInitial: true,
3610
+ awaitWriteFinish: { stabilityThreshold: 120, pollInterval: 40 },
3611
+ ignored: (path) => path.endsWith(".mosage-tmp") || path.includes(`${sep4}node_modules${sep4}`)
3612
+ });
3613
+ const pending = /* @__PURE__ */ new Map();
3614
+ watcher.on("all", (_event, path) => {
3615
+ clearTimeout(pending.get(path));
3616
+ pending.set(
3617
+ path,
3618
+ setTimeout(async () => {
3619
+ pending.delete(path);
3620
+ const [bookId, folder, file, extra] = relative4(project.booksDir, path).split(sep4);
3621
+ if (bookId && !bookId.startsWith("..")) {
3622
+ if (folder === "chapters" && file && !extra && isChapterId(file)) {
3623
+ try {
3624
+ const book = project.book(bookId);
3625
+ await book.history.observe(file, (await book.readChapter(file)).source);
3626
+ } catch {
3627
+ }
3628
+ broadcast({ type: "chapter", book: bookId, id: file });
3629
+ } else if (folder === "assets") {
3630
+ broadcast({ type: "assets", book: bookId });
3631
+ }
3632
+ broadcast({ type: "book", book: bookId });
3633
+ }
3634
+ broadcast({ type: "library" });
3635
+ }, 80)
3636
+ );
3637
+ });
3638
+ const libraryRoutes = async (req, res, route) => {
3639
+ switch (route) {
3640
+ case "GET /library": {
3641
+ const defaults = await project.loadDefaults();
3642
+ return reply(res, 200, {
3643
+ name: basename2(project.root),
3644
+ author: typeof defaults.author === "string" ? defaults.author : "",
3645
+ books: await project.listBooks()
3646
+ });
3647
+ }
3648
+ case "POST /books": {
3649
+ if (!templateDir) throw new HttpError(500, "book template missing");
3650
+ const body = await readJson(req);
3651
+ const type = optionalStr(body.type);
3652
+ if (type && !["book", "thesis", "other"].includes(type)) {
3653
+ throw new HttpError(400, "invalid type");
3654
+ }
3655
+ const book = await project.createBook(templateDir, {
3656
+ id: optionalStr(body.id),
3657
+ title: optionalStr(body.title),
3658
+ type
3659
+ });
3660
+ return reply(res, 201, { id: book.id });
3661
+ }
3662
+ case "POST /books/order": {
3663
+ const body = await readJson(req);
3664
+ if (!Array.isArray(body.books)) throw new HttpError(400, "books must be an array");
3665
+ await project.setBookOrder(body.books.map((b) => str2(b, "book")));
3666
+ return reply(res, 200, { ok: true });
3667
+ }
3668
+ case "POST /current": {
3669
+ const body = await readJson(req);
3670
+ await project.writeCurrent(body);
3671
+ return reply(res, 200, { ok: true });
3672
+ }
3673
+ case "GET /events": {
3674
+ res.writeHead(200, {
3675
+ "content-type": "text/event-stream; charset=utf-8",
3676
+ "cache-control": "no-store",
3677
+ connection: "keep-alive"
3678
+ });
3679
+ res.write("retry: 1500\n\n");
3680
+ clients.add(res);
3681
+ req.on("close", () => clients.delete(res));
3682
+ return true;
3683
+ }
3684
+ }
3685
+ return false;
3686
+ };
3687
+ const bookRoutes = async (req, res, book, parts, url) => {
3688
+ const route = routeKey(req.method ?? "GET", parts);
3689
+ const id = parts[1];
3690
+ switch (route) {
3691
+ case "GET /":
3692
+ return reply(res, 200, await book.summarize());
3693
+ case "POST /config": {
3694
+ const body = await readJson(req);
3695
+ await book.updateConfig((doc) => {
3696
+ for (const key of ["title", "subtitle", "author"]) {
3697
+ if (typeof body[key] === "string") doc.set(key, body[key].trim());
3698
+ }
3699
+ if (typeof body.stage === "string") {
3700
+ if (!STAGES.includes(body.stage)) throw new HttpError(400, "invalid stage");
3701
+ doc.set("stage", body.stage);
3702
+ }
3703
+ if (typeof body.targetWords === "number" && body.targetWords >= 0) {
3704
+ doc.set("targetWords", Math.round(body.targetWords));
3705
+ }
3706
+ });
3707
+ return reply(res, 200, await book.summarize());
3708
+ }
3709
+ case "POST /order": {
3710
+ const body = await readJson(req);
3711
+ if (!Array.isArray(body.chapters)) throw new HttpError(400, "chapters must be an array");
3712
+ await book.setChapterOrder(body.chapters.map((c) => str2(c, "chapter")));
3713
+ return reply(res, 200, await book.summarize());
3714
+ }
3715
+ case "POST /chapters": {
3716
+ const body = await readJson(req);
3717
+ const newId = await book.createChapter(str2(body.title, "title"), {
3718
+ summary: optionalStr(body.summary)
3719
+ });
3720
+ return reply(res, 201, { id: newId });
3721
+ }
3722
+ case "GET /chapters/:id":
3723
+ return reply(res, 200, await book.readChapter(id));
3724
+ case "PUT /chapters/:id": {
3725
+ const body = await readJson(req);
3726
+ const version = await book.writeChapter(
3727
+ id,
3728
+ str2(body.source, "source"),
3729
+ optionalStr(body.baseVersion)
3730
+ );
3731
+ return reply(res, 200, { version });
3732
+ }
3733
+ case "PATCH /chapters/:id": {
3734
+ const body = await readJson(req);
3735
+ let base = optionalStr(body.baseVersion);
3736
+ if (typeof body.title === "string") base = await book.setChapterTitle(id, body.title, base);
3737
+ if (typeof body.summary === "string") {
3738
+ base = await book.setChapterSummary(id, body.summary, base);
3739
+ }
3740
+ if (typeof body.status === "string") {
3741
+ base = await book.setChapterStatus(id, body.status, base);
3742
+ }
3743
+ return reply(res, 200, { version: base });
3744
+ }
3745
+ case "POST /chapters/:id/list": {
3746
+ const body = await readJson(req);
3747
+ await book.addToOrder(id, typeof body.position === "number" ? body.position : void 0);
3748
+ return reply(res, 200, await book.summarize());
3749
+ }
3750
+ case "POST /chapters/:id/unlist":
3751
+ await book.removeFromOrder(id);
3752
+ return reply(res, 200, await book.summarize());
3753
+ case "POST /chapters/:id/replace": {
3754
+ const body = await readJson(req);
3755
+ const start = int(body.start, "start");
3756
+ const end = int(body.end, "end");
3757
+ const expected = str2(body.expected, "expected");
3758
+ const text = str2(body.text, "text");
3759
+ const version = await book.transformChapter(
3760
+ id,
3761
+ (s) => replaceRange(s, start, end, expected, text)
3762
+ );
3763
+ return reply(res, 200, { version });
3764
+ }
3765
+ case "POST /chapters/:id/comments": {
3766
+ const body = await readJson(req);
3767
+ let newId = "";
3768
+ const version = await book.transformChapter(id, (s) => {
3769
+ const out = insertComment(s, {
3770
+ blockStart: int(body.blockStart, "blockStart"),
3771
+ blockText: str2(body.blockText, "blockText"),
3772
+ body: str2(body.body, "body"),
3773
+ quote: optionalStr(body.quote)
3774
+ });
3775
+ newId = out.id;
3776
+ return out.source;
3777
+ });
3778
+ return reply(res, 201, { id: newId, version });
3779
+ }
3780
+ case "DELETE /chapters/:id/annotations/:ann": {
3781
+ const version = await book.transformChapter(id, (s) => removeAnnotation(s, parts[3]));
3782
+ return reply(res, 200, { version });
3783
+ }
3784
+ case "POST /chapters/:id/annotations/:ann/accept": {
3785
+ const version = await book.transformChapter(id, (s) => acceptSuggestion(s, parts[3]));
3786
+ return reply(res, 200, { version });
3787
+ }
3788
+ case "GET /chapters/:id/history":
3789
+ book.chapterPath(id);
3790
+ return reply(res, 200, { snapshots: await book.history.list(id) });
3791
+ case "GET /chapters/:id/history/:snap":
3792
+ book.chapterPath(id);
3793
+ return reply(res, 200, { source: await book.history.read(id, parts[3]) });
3794
+ case "POST /chapters/:id/history/:snap/restore": {
3795
+ const snapshot = await book.history.read(id, parts[3]);
3796
+ const current = await book.readChapter(id);
3797
+ await book.history.snapshot(id, current.source, true);
3798
+ const version = await book.writeChapter(id, snapshot);
3799
+ return reply(res, 200, { version });
3800
+ }
3801
+ case "GET /doc/brief":
3802
+ case "GET /doc/style": {
3803
+ const file = join7(book.root, parts[1] === "brief" ? "brief.md" : "STYLE.md");
3804
+ const source = existsSync5(file) ? await readFile5(file, "utf8") : "";
3805
+ return reply(res, 200, { source });
3806
+ }
3807
+ case "PUT /doc/brief":
3808
+ case "PUT /doc/style": {
3809
+ const body = await readJson(req);
3810
+ const file = join7(book.root, parts[1] === "brief" ? "brief.md" : "STYLE.md");
3811
+ await writeFile5(file, str2(body.source, "source"));
3812
+ return reply(res, 200, { ok: true });
3813
+ }
3814
+ case "GET /files":
3815
+ return reply(res, 200, await book.listFiles());
3816
+ case "POST /files": {
3817
+ const body = await readJson(req);
3818
+ const folder = str2(body.folder, "folder");
3819
+ const data = Buffer.from(str2(body.data, "data"), "base64");
3820
+ const name = await book.saveFile(folder, str2(body.name, "name"), data);
3821
+ return reply(res, 201, { name, path: `${folder}/${name}` });
3822
+ }
3823
+ case "DELETE /files": {
3824
+ const folder = url.searchParams.get("folder") ?? "";
3825
+ const name = url.searchParams.get("name") ?? "";
3826
+ await book.trashFile(folder, name);
3827
+ return reply(res, 200, { ok: true });
3828
+ }
3829
+ case "GET /export/docx":
3830
+ case "GET /export/html":
3831
+ case "GET /export/md": {
3832
+ const format = parts[1];
3833
+ const { renderManuscript: renderManuscript2 } = await Promise.resolve().then(() => (init_export(), export_exports));
3834
+ const file = await renderManuscript2(book, format);
3835
+ res.writeHead(200, {
3836
+ "content-type": file.contentType,
3837
+ "content-length": file.data.length,
3838
+ "content-disposition": `attachment; filename="${book.id}.${format}"; filename*=UTF-8''${encodeURIComponent(file.fileName)}`,
3839
+ "cache-control": "no-store"
3840
+ });
3841
+ res.end(file.data);
3842
+ return true;
3843
+ }
3844
+ }
3845
+ return false;
3846
+ };
3847
+ const api = async (req, res, url) => {
3848
+ const parts = url.pathname.split("/").filter(Boolean).slice(1).map(decodeURIComponent);
3849
+ const method = req.method ?? "GET";
3850
+ if (await libraryRoutes(req, res, `${method} /${parts.join("/")}`)) return;
3851
+ if (parts[0] === "books" && parts[1] && parts[1] !== "order") {
3852
+ if (await bookRoutes(req, res, project.book(parts[1]), parts.slice(2), url)) return;
3853
+ }
3854
+ throw new HttpError(404, `no route for ${method} ${url.pathname}`);
3855
+ };
3856
+ const serveProjectFile = async (res, rel) => {
3857
+ const path = resolve3(project.root, normalize(rel).replace(/^([/\\])+/, ""));
3858
+ const inside = relative4(project.root, path);
3859
+ if (inside.startsWith("..") || inside.split(sep4).some((seg) => seg.startsWith(".")) || !existsSync5(path) || !(await stat2(path)).isFile()) {
3860
+ throw new HttpError(404, "not found");
3861
+ }
3862
+ res.writeHead(200, {
3863
+ "content-type": MIME2[extname3(path).toLowerCase()] ?? "application/octet-stream",
3864
+ "cache-control": "no-cache"
3865
+ });
3866
+ res.end(await readFile5(path));
3867
+ };
3868
+ const serveWeb = async (res, pathname) => {
3869
+ const file = resolve3(webDir, `.${pathname === "/" ? "/index.html" : pathname}`);
3870
+ const target = file.startsWith(resolve3(webDir)) && existsSync5(file) && (await stat2(file)).isFile() ? file : join7(webDir, "index.html");
3871
+ if (!existsSync5(target)) {
3872
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
3873
+ res.end("MoSage web UI is not built. Run `npm run build:web` in the mosage package.");
3874
+ return;
3875
+ }
3876
+ res.writeHead(200, {
3877
+ "content-type": MIME2[extname3(target)] ?? "application/octet-stream",
3878
+ "cache-control": target.includes(`${sep4}assets${sep4}`) ? "max-age=31536000, immutable" : "no-cache"
3879
+ });
3880
+ res.end(await readFile5(target));
3881
+ };
3882
+ const server = createServer(async (req, res) => {
3883
+ const url = new URL(req.url ?? "/", "http://localhost");
3884
+ try {
3885
+ guard(req, allowAnyHost);
3886
+ if (url.pathname.startsWith("/api/")) return await api(req, res, url);
3887
+ if (url.pathname.startsWith("/files/")) {
3888
+ return await serveProjectFile(
3889
+ res,
3890
+ decodeURIComponent(url.pathname.slice("/files/".length))
3891
+ );
3892
+ }
3893
+ return await serveWeb(res, url.pathname);
3894
+ } catch (err) {
3895
+ const status = err instanceof HttpError || err instanceof ProjectError ? err.status : err instanceof EditConflict ? 409 : err?.code === "ENOENT" ? 404 : 500;
3896
+ if (status === 500) console.error(err);
3897
+ if (!res.headersSent) send(res, status, { error: err.message ?? String(err) });
3898
+ else res.end();
3899
+ }
3900
+ });
3901
+ const heartbeat = setInterval(() => {
3902
+ for (const res of clients) res.write(": ping\n\n");
3903
+ }, 25e3);
3904
+ const port = await listen(server, host, opts.port ?? 5280);
3905
+ const shownHost = host === "0.0.0.0" || host === "::" ? "localhost" : host;
3906
+ return {
3907
+ url: `http://${shownHost.includes(":") ? `[${shownHost}]` : shownHost}:${port}`,
3908
+ server,
3909
+ async close() {
3910
+ clearInterval(heartbeat);
3911
+ for (const res of clients) res.end();
3912
+ await watcher.close();
3913
+ await new Promise((done) => server.close(() => done()));
3914
+ }
3915
+ };
3916
+ }
3917
+ function routeKey(method, parts) {
3918
+ if (parts.length === 0) return `${method} /`;
3919
+ const shape = parts.map((part, i) => {
3920
+ if (parts[0] !== "chapters") return part;
3921
+ if (i === 1) return ":id";
3922
+ if (i === 3 && parts[2] === "annotations") return ":ann";
3923
+ if (i === 3 && parts[2] === "history") return ":snap";
3924
+ return part;
3925
+ });
3926
+ return `${method} /${shape.join("/")}`;
3927
+ }
3928
+ async function listen(server, host, port) {
3929
+ for (let attempt = 0; attempt < 20; attempt++) {
3930
+ try {
3931
+ await new Promise((ok, fail) => {
3932
+ server.once("error", fail);
3933
+ server.listen(port + attempt, host, () => {
3934
+ server.off("error", fail);
3935
+ ok();
3936
+ });
3937
+ });
3938
+ return server.address().port;
3939
+ } catch (err) {
3940
+ if (err.code !== "EADDRINUSE" || port === 0) throw err;
3941
+ }
3942
+ }
3943
+ throw new Error(`No free port found from ${port}`);
3944
+ }
3945
+
3946
+ // src/cli/init.ts
3947
+ import { spawn } from "child_process";
3948
+ import { existsSync as existsSync7 } from "fs";
3949
+ import { cp as cp2, mkdir as mkdir5, readdir as readdir4, readFile as readFile6, rm as rm2, symlink, writeFile as writeFile6 } from "fs/promises";
3950
+ import { basename as basename3, join as join9, relative as relative5, resolve as resolve4 } from "path";
3951
+ import { parseDocument as parseDocument3 } from "yaml";
3952
+
3953
+ // src/cli/paths.ts
3954
+ import { existsSync as existsSync6, readFileSync } from "fs";
3955
+ import { dirname as dirname4, join as join8 } from "path";
3956
+ import { fileURLToPath as fileURLToPath3 } from "url";
3957
+ var PACKAGE_ROOT = (() => {
3958
+ let dir = dirname4(fileURLToPath3(import.meta.url));
3959
+ for (; ; ) {
3960
+ const pkg = join8(dir, "package.json");
3961
+ if (existsSync6(pkg)) {
3962
+ try {
3963
+ if (JSON.parse(readFileSync(pkg, "utf8")).name === "mosage") return dir;
3964
+ } catch {
3965
+ }
3966
+ }
3967
+ const parent = dirname4(dir);
3968
+ if (parent === dir) throw new Error("Cannot locate the mosage package root.");
3969
+ dir = parent;
3970
+ }
3971
+ })();
3972
+ var VERSION = true ? "0.1.0" : JSON.parse(readFileSync(join8(PACKAGE_ROOT, "package.json"), "utf8")).version;
3973
+ var PROJECT_TEMPLATE_DIR = join8(PACKAGE_ROOT, "template", "project");
3974
+ var BOOK_TEMPLATE_DIR = join8(PACKAGE_ROOT, "template", "book");
3975
+ var SKILLS_DIR = join8(PACKAGE_ROOT, "skills");
3976
+ var WEB_DIR = join8(PACKAGE_ROOT, "dist", "web");
3977
+
3978
+ // src/cli/style.ts
3979
+ var enabled = process.stdout.isTTY && !process.env.NO_COLOR;
3980
+ var wrap = (open, close) => (text) => enabled ? `\x1B[${open}m${text}\x1B[${close}m` : text;
3981
+ var bold = wrap(1, 22);
3982
+ var dim = wrap(2, 22);
3983
+ var red = wrap(31, 39);
3984
+ var green = wrap(32, 39);
3985
+ var yellow = wrap(33, 39);
3986
+ var cyan = wrap(36, 39);
3987
+ var magenta = wrap(35, 39);
3988
+
3989
+ // src/cli/init.ts
3990
+ function detectPackageManager() {
3991
+ const agent = process.env.npm_config_user_agent ?? "";
3992
+ if (agent.startsWith("pnpm")) return "pnpm";
3993
+ if (agent.startsWith("yarn")) return "yarn";
3994
+ if (agent.startsWith("bun")) return "bun";
3995
+ return "npm";
3996
+ }
3997
+ async function isNonEmpty(dir) {
3998
+ if (!existsSync7(dir)) return false;
3999
+ return (await readdir4(dir)).some((name) => !name.startsWith("."));
4000
+ }
4001
+ function run(cmd, args, cwd, quiet = false) {
4002
+ return new Promise((ok, fail) => {
4003
+ const child = spawn(cmd, args, {
4004
+ cwd,
4005
+ stdio: quiet ? "ignore" : "inherit",
4006
+ shell: process.platform === "win32"
4007
+ });
4008
+ child.on("error", fail);
4009
+ child.on(
4010
+ "close",
4011
+ (code) => code === 0 ? ok() : fail(new Error(`${cmd} exited with ${code}`))
4012
+ );
4013
+ });
4014
+ }
4015
+ async function installSkills(target) {
4016
+ const agentsDir = join9(target, ".agents", "skills");
4017
+ const claudeDir = join9(target, ".claude", "skills");
4018
+ await mkdir5(agentsDir, { recursive: true });
4019
+ await mkdir5(claudeDir, { recursive: true });
4020
+ const names = (await readdir4(SKILLS_DIR, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
4021
+ for (const name of names) {
4022
+ const src = join9(agentsDir, name);
4023
+ await rm2(src, { recursive: true, force: true });
4024
+ await cp2(join9(SKILLS_DIR, name), src, { recursive: true });
4025
+ const link = join9(claudeDir, name);
4026
+ await rm2(link, { recursive: true, force: true });
4027
+ if (process.platform === "win32") {
4028
+ await cp2(src, link, { recursive: true });
4029
+ } else {
4030
+ await symlink(relative5(claudeDir, src), link);
4031
+ }
4032
+ }
4033
+ return names;
4034
+ }
4035
+ async function init(opts) {
4036
+ const target = resolve4(process.cwd(), opts.dir);
4037
+ if (await isNonEmpty(target) && !opts.force) {
4038
+ throw new Error(`${target} \u4E0D\u662F\u7A7A\u8CC7\u6599\u593E\u3002\u63DB\u4E00\u500B\u540D\u7A31\uFF0C\u6216\u52A0\u4E0A --force \u5F37\u5236\u5EFA\u7ACB\u3002`);
4039
+ }
4040
+ await mkdir5(target, { recursive: true });
4041
+ await cp2(PROJECT_TEMPLATE_DIR, target, { recursive: true, force: opts.force });
4042
+ if (existsSync7(join9(target, "gitignore"))) {
4043
+ await cp2(join9(target, "gitignore"), join9(target, ".gitignore"));
4044
+ await rm2(join9(target, "gitignore"));
4045
+ }
4046
+ const name = basename3(target);
4047
+ const pkgPath = join9(target, "package.json");
4048
+ const pkg = JSON.parse(await readFile6(pkgPath, "utf8"));
4049
+ pkg.name = name.toLowerCase().replace(/[^a-z0-9-_.]+/g, "-").replace(/^[-.]+|-+$/g, "") || "my-writing";
4050
+ pkg.devDependencies = { ...pkg.devDependencies, mosage: `^${VERSION}` };
4051
+ await writeFile6(pkgPath, `${JSON.stringify(pkg, null, 2)}
4052
+ `);
4053
+ const configPath = join9(target, "mosage.yaml");
4054
+ if (opts.author) {
4055
+ const doc = parseDocument3(await readFile6(configPath, "utf8"));
4056
+ doc.set("author", opts.author);
4057
+ await writeFile6(configPath, doc.toString({ lineWidth: 0 }));
4058
+ }
4059
+ const skills = await installSkills(target);
4060
+ const out = process.stdout;
4061
+ out.write(`
4062
+ ${green("\u2714")} ${bold("\u5DF2\u5EFA\u7ACB\u5BEB\u4F5C\u5C08\u6848")} ${dim(target)}
4063
+ `);
4064
+ out.write(dim(` \u5DF2\u5B89\u88DD ${skills.length} \u500B AI skills\uFF1A${skills.join("\u3001")}
4065
+ `));
4066
+ const pm = detectPackageManager();
4067
+ let installed = false;
4068
+ if (opts.install) {
4069
+ out.write(`
4070
+ ${bold(`\u6B63\u5728\u7528 ${pm} \u5B89\u88DD\u76F8\u4F9D\u5957\u4EF6\u2026`)}
4071
+
4072
+ `);
4073
+ try {
4074
+ await run(pm, ["install"], target);
4075
+ installed = true;
4076
+ } catch (err) {
4077
+ out.write(
4078
+ `
4079
+ ${yellow("!")} \u5B89\u88DD\u5931\u6557\uFF1A${dim(err.message)}\uFF0C\u7A0D\u5F8C\u53EF\u624B\u52D5\u57F7\u884C ${cyan(`${pm} install`)}
4080
+ `
4081
+ );
4082
+ }
4083
+ }
4084
+ if (opts.git) {
4085
+ try {
4086
+ await run("git", ["init", "-q"], target, true);
4087
+ await run("git", ["add", "-A"], target, true);
4088
+ await run(
4089
+ "git",
4090
+ [
4091
+ "-c",
4092
+ "user.name=MoSage",
4093
+ "-c",
4094
+ "user.email=mosage@localhost",
4095
+ "commit",
4096
+ "-qm",
4097
+ "\u5EFA\u7ACB\u5BEB\u4F5C\u5C08\u6848"
4098
+ ],
4099
+ target,
4100
+ true
4101
+ );
4102
+ out.write(`${green("\u2714")} \u5DF2\u5EFA\u7ACB git \u7248\u672C\u5EAB\uFF08AI \u7684\u6BCF\u4E00\u6B21\u4FEE\u6539\u90FD\u80FD\u56DE\u6EAF\uFF09
4103
+ `);
4104
+ } catch {
4105
+ out.write(`${yellow("!")} \u7565\u904E git \u521D\u59CB\u5316\uFF08\u627E\u4E0D\u5230 git \u6216\u521D\u59CB\u5316\u5931\u6557\uFF09
4106
+ `);
4107
+ }
4108
+ }
4109
+ const cd = relative5(process.cwd(), target) || ".";
4110
+ const devCmd = pm === "npm" ? "npm run dev" : `${pm} dev`;
4111
+ out.write(`
4112
+ ${bold("\u63A5\u4E0B\u4F86\uFF1A")}
4113
+
4114
+ `);
4115
+ out.write(` ${dim("1.")} \u958B\u555F\u5BEB\u4F5C\u4ECB\u9762
4116
+ `);
4117
+ if (cd !== ".") out.write(` ${cyan(`cd ${cd}`)}
4118
+ `);
4119
+ if (!installed) out.write(` ${cyan(`${pm} install`)}
4120
+ `);
4121
+ out.write(` ${cyan(devCmd)} ${dim("# \u700F\u89BD\u5668\u958B\u555F http://localhost:5280")}
4122
+
4123
+ `);
4124
+ out.write(` ${dim("2.")} \u5728\u540C\u4E00\u500B\u8CC7\u6599\u593E\u958B\u555F AI \u5DE5\u5177
4125
+ `);
4126
+ out.write(` ${cyan("claude")} ${dim("# Claude Code\uFF0C\u6216")} ${cyan("codex")}
4127
+
4128
+ `);
4129
+ out.write(` ${dim("3.")} \u5C0D AI \u8AAA\u300C${bold("\u958B\u59CB")}\u300D\uFF08\u6216\u8F38\u5165 ${cyan("/kickoff")}\uFF09
4130
+ `);
4131
+ out.write(
4132
+ dim(
4133
+ " AI \u6703\u5148\u4E86\u89E3\u4F60\u7684\u5BEB\u4F5C\u76EE\u7684\u3001\u8B80\u8005\u8207\u98A8\u683C\uFF0C\u518D\u548C\u4F60\u4E00\u8D77\u8A02\u66F8\u540D\u8207\u5927\u7DB1\uFF0C\n \u5728\u700F\u89BD\u5668\u88E1\u7DE8\u6392\u597D\u7AE0\u7BC0\u5F8C\uFF0C\u5C31\u53EF\u4EE5\u958B\u59CB\u4E00\u7AE0\u4E00\u7AE0\u5BEB\u3002\n \u4E00\u500B\u5C08\u6848\u53EF\u4EE5\u653E\u5F88\u591A\u672C\u66F8 \u2014\u2014 \u60F3\u5BEB\u4E0B\u4E00\u672C\u6642\uFF0C\u8DDF AI \u8AAA\u300C\u6211\u60F3\u5BEB\u4E00\u672C\u65B0\u66F8\u300D\u3002\n\n"
4134
+ )
4135
+ );
4136
+ }
4137
+
4138
+ // src/cli/status.ts
4139
+ init_markdown();
4140
+ init_wordcount();
4141
+ import { existsSync as existsSync8 } from "fs";
4142
+ import { dirname as dirname5, resolve as resolve5 } from "path";
4143
+ var STAGE_LABEL = {
4144
+ kickoff: "\u7ACB\u9805\uFF08\u8A2A\u8AC7\u5BEB\u4F5C\u76EE\u7684\uFF09",
4145
+ outline: "\u5927\u7DB1\uFF08\u8A02\u66F8\u540D\u8207\u7AE0\u7BC0\uFF09",
4146
+ writing: "\u64B0\u5BEB",
4147
+ revising: "\u4FEE\u8A02",
4148
+ done: "\u5B8C\u7A3F"
4149
+ };
4150
+ var STATUS_LABEL = {
4151
+ idea: "\u69CB\u60F3",
4152
+ draft: "\u8349\u7A3F",
4153
+ revising: "\u4FEE\u8A02\u4E2D",
4154
+ done: "\u5B8C\u6210"
4155
+ };
4156
+ async function collectStatus(target) {
4157
+ const book = await target.summarize();
4158
+ const notes = [];
4159
+ const brokenImages = [];
4160
+ for (const chapter of [...book.chapters, ...book.drafts]) {
4161
+ const { source } = await target.readChapter(chapter.id);
4162
+ const parsed = parseChapter(source);
4163
+ for (const a of parsed.annotations) {
4164
+ notes.push({
4165
+ chapter: chapter.id,
4166
+ id: a.id,
4167
+ kind: a.kind,
4168
+ by: a.by,
4169
+ line: lineAt(source, a.start),
4170
+ quote: a.quote,
4171
+ body: a.body
4172
+ });
4173
+ }
4174
+ const visit = (node) => {
4175
+ const n = node;
4176
+ if (n.type === "image") {
4177
+ const img = node;
4178
+ const url = decodeURI(img.url.split(/[?#]/)[0]);
4179
+ if (!/^[a-z]+:/i.test(url) && url) {
4180
+ const fromChapter = resolve5(dirname5(target.chapterPath(chapter.id)), url);
4181
+ const fromRoot = resolve5(target.root, url);
4182
+ if (!existsSync8(fromChapter) && !existsSync8(fromRoot)) {
4183
+ brokenImages.push({
4184
+ chapter: chapter.id,
4185
+ line: img.position?.start.line ?? 0,
4186
+ url: img.url
4187
+ });
4188
+ }
4189
+ }
4190
+ }
4191
+ n.children?.forEach(visit);
4192
+ };
4193
+ visit(parsed.tree);
4194
+ }
4195
+ return { ...book, notes, brokenImages };
4196
+ }
4197
+ async function printStatus(target, json) {
4198
+ const s = await collectStatus(target);
4199
+ if (json) {
4200
+ process.stdout.write(`${JSON.stringify(s, null, 2)}
4201
+ `);
4202
+ return;
4203
+ }
4204
+ const { config } = s;
4205
+ const out = (line = "") => process.stdout.write(`${line}
4206
+ `);
4207
+ const kind = config.type === "thesis" ? "\u8AD6\u6587" : config.type === "book" ? "\u66F8" : "\u5BEB\u4F5C\u5C08\u6848";
4208
+ out();
4209
+ out(
4210
+ `${bold(config.title || "\uFF08\u5C1A\u672A\u547D\u540D\uFF09")}${config.subtitle ? ` ${dim(config.subtitle)}` : ""}`
4211
+ );
4212
+ out(
4213
+ dim(
4214
+ `${kind}${config.author ? ` \xB7 ${config.author}` : ""} \xB7 \u968E\u6BB5\uFF1A${STAGE_LABEL[config.stage]}`
4215
+ )
4216
+ );
4217
+ const goal = config.targetWords ? ` / \u76EE\u6A19 ${formatCount(config.targetWords)}` : "";
4218
+ const pct = config.targetWords ? `\uFF08${Math.round(s.totals.words / config.targetWords * 100)}%\uFF09` : "";
4219
+ out(dim(`\u5B57\u6578 ${formatCount(s.totals.words)}${goal}${pct}`));
4220
+ out();
4221
+ out(dim(`books/${s.id}/`));
4222
+ out();
4223
+ if (!s.hasBrief) {
4224
+ out(`${yellow("\u9084\u6C92\u6709\u5BEB\u4F5C\u4F01\u5283\uFF08brief.md\uFF09\u3002")}\u5C0D AI \u8AAA\u300C\u958B\u59CB\u300D\u9032\u884C\u7ACB\u9805\u8A2A\u8AC7\u3002`);
4225
+ out();
4226
+ }
4227
+ if (s.chapters.length) {
4228
+ out(bold("\u7AE0\u7BC0"));
4229
+ s.chapters.forEach((c, i) => {
4230
+ const flags = [
4231
+ c.comments ? yellow(`${c.comments} \u5247\u7559\u8A00\u5F85 AI \u8655\u7406`) : "",
4232
+ c.suggestions ? magenta(`${c.suggestions} \u500B AI \u4FEE\u6539\u5EFA\u8B70`) : "",
4233
+ c.aiNotes ? cyan(`${c.aiNotes} \u5247 AI \u7559\u8A00`) : ""
4234
+ ].filter(Boolean);
4235
+ out(
4236
+ ` ${dim(String(i + 1).padStart(2))} ${c.title} ${dim(`${STATUS_LABEL[c.status]} \xB7 ${formatCount(c.words)} \u5B57 \xB7 ${c.id}`)}${flags.length ? ` ${flags.join(" ")}` : ""}`
4237
+ );
4238
+ });
4239
+ out();
4240
+ }
4241
+ if (s.drafts.length) {
4242
+ out(bold("\u672A\u6392\u5165\u76EE\u9304\u7684\u8349\u7A3F"));
4243
+ for (const c of s.drafts) out(` \xB7 ${c.title} ${dim(c.id)}`);
4244
+ out();
4245
+ }
4246
+ if (s.missing.length) {
4247
+ out(red(`mosage.yaml \u5217\u51FA\u4F46\u627E\u4E0D\u5230\u7684\u6A94\u6848\uFF1A${s.missing.join("\u3001")}`));
4248
+ out();
4249
+ }
4250
+ if (s.brokenImages.length) {
4251
+ out(red("\u627E\u4E0D\u5230\u7684\u5716\u7247"));
4252
+ for (const b of s.brokenImages)
4253
+ out(` books/${s.id}/chapters/${b.chapter}:${b.line} ${b.url}`);
4254
+ out();
4255
+ }
4256
+ if (s.notes.length) {
4257
+ out(bold("\u5F85\u8655\u7406\u7684\u6A19\u8A18"));
4258
+ for (const n of s.notes) {
4259
+ const label = n.kind === "suggest" ? magenta("AI \u5EFA\u8B70") : n.by === "ai" ? cyan("AI \u7559\u8A00") : yellow("\u7D66 AI \u7684\u7559\u8A00");
4260
+ const text = n.body.replace(/\s+/g, " ").slice(0, 60);
4261
+ out(` ${label} ${dim(`books/${s.id}/chapters/${n.chapter}:${n.line}`)} ${text}`);
4262
+ }
4263
+ out();
4264
+ } else if (s.chapters.length) {
4265
+ out(green("\u6C92\u6709\u5F85\u8655\u7406\u7684\u7559\u8A00\u6216\u5EFA\u8B70\u3002"));
4266
+ out();
4267
+ }
4268
+ }
4269
+ async function printLibrary(project, json) {
4270
+ const books = await project.listBooks();
4271
+ if (json) {
4272
+ process.stdout.write(`${JSON.stringify({ root: project.root, books }, null, 2)}
4273
+ `);
4274
+ return;
4275
+ }
4276
+ const out = (line = "") => process.stdout.write(`${line}
4277
+ `);
4278
+ out();
4279
+ if (books.length === 0) {
4280
+ out(`${yellow("\u9019\u500B\u5C08\u6848\u9084\u6C92\u6709\u4EFB\u4F55\u66F8\u3002")}`);
4281
+ out(
4282
+ "\u7528 Claude Code \u6216 Codex \u958B\u555F\u9019\u500B\u8CC7\u6599\u593E\uFF0C\u8AAA\u300C\u958B\u59CB\u300D\u2014\u2014 AI \u6703\u5E36\u4F60\u5B8C\u6210\u7ACB\u9805\u8A2A\u8AC7\u4E26\u5EFA\u7ACB\u7B2C\u4E00\u672C\u66F8\u3002"
4283
+ );
4284
+ out();
4285
+ return;
4286
+ }
4287
+ out(bold(`\u66F8\u67B6\uFF08${books.length} \u672C\uFF09`));
4288
+ for (const b of books) {
4289
+ const c = b.config;
4290
+ const kind = c.type === "thesis" ? "\u8AD6\u6587" : c.type === "book" ? "\u66F8" : "\u9577\u6587";
4291
+ const progress = c.targetWords ? `${formatCount(b.totals.words)} / ${formatCount(c.targetWords)} \u5B57` : `${formatCount(b.totals.words)} \u5B57`;
4292
+ const flags = [
4293
+ b.totals.comments ? yellow(`${b.totals.comments} \u5247\u7559\u8A00\u5F85\u8655\u7406`) : "",
4294
+ b.totals.suggestions ? magenta(`${b.totals.suggestions} \u500B AI \u5EFA\u8B70`) : ""
4295
+ ].filter(Boolean);
4296
+ out(
4297
+ ` ${bold(c.title || "\uFF08\u672A\u547D\u540D\uFF09")} ${dim(`${kind} \xB7 ${STAGE_LABEL[c.stage]} \xB7 ${b.chapters.length} \u7AE0 \xB7 ${progress} \xB7 books/${b.id}/`)}${flags.length ? ` ${flags.join(" ")}` : ""}`
4298
+ );
4299
+ }
4300
+ out();
4301
+ out(dim("\u67E5\u770B\u55AE\u672C\uFF1Amosage status <\u4EE3\u865F>"));
4302
+ out();
4303
+ }
4304
+
4305
+ // src/cli/index.ts
4306
+ var HELP = `
4307
+ ${bold("MoSage")} ${dim(`v${VERSION}`)} \u2014 \u548C AI \u4E00\u8D77\u5BEB\u66F8\u3001\u5BEB\u8AD6\u6587
4308
+
4309
+ ${bold("\u5EFA\u7ACB\u5C08\u6848")}\uFF08\u4E00\u500B\u5C08\u6848\u53EF\u4EE5\u653E\u5F88\u591A\u672C\u66F8\uFF09
4310
+ ${cyan("npx mosage init <\u8CC7\u6599\u593E>")} \u5EFA\u7ACB\u5BEB\u4F5C\u5C08\u6848
4311
+ --author <\u4F5C\u8005> --no-install --no-git --force
4312
+
4313
+ ${bold("\u5728\u5C08\u6848\u8CC7\u6599\u593E\u88E1")}
4314
+ ${cyan("mosage dev")} \u958B\u555F\u5BEB\u4F5C\u4ECB\u9762\uFF08\u9810\u8A2D http://localhost:5280\uFF09
4315
+ --port <\u57E0\u865F> --host <\u4F4D\u5740> --no-open
4316
+ ${cyan("mosage new <\u4EE3\u865F>")} \u65B0\u589E\u4E00\u672C\u66F8\uFF08\u901A\u5E38\u7531 AI \u5728\u7ACB\u9805\u6642\u57F7\u884C\uFF09
4317
+ --title <\u66F8\u540D> --type book|thesis|other
4318
+ ${cyan("mosage status [\u66F8]")} \u66F8\u67B6\u7E3D\u89BD\uFF0F\u55AE\u672C\u66F8\u7684\u7AE0\u7BC0\u3001\u5B57\u6578\u3001\u5F85\u8655\u7406\u6A19\u8A18
4319
+ --json
4320
+ ${cyan("mosage export [\u66F8] [docx|html|md]")} \u532F\u51FA\uFF08\u9810\u8A2D Word\uFF09\u5230 output/<\u66F8>/
4321
+ --out <\u6A94\u6848\u8DEF\u5F91>
4322
+ ${cyan("mosage import <\u6A94\u6848.docx|.md>")} \u532F\u5165\u65E2\u6709\u7A3F\u4EF6\uFF0C\u4F9D\u6A19\u984C\u5207\u6210\u7AE0\u7BC0\uFF08\u9810\u8A2D\u5EFA\u7ACB\u65B0\u66F8\uFF09
4323
+ --book <\u4EE3\u865F> --title <\u66F8\u540D> --no-split --unlisted
4324
+ ${cyan("mosage sync-skills")} \u5347\u7D1A mosage \u5F8C\uFF0C\u66F4\u65B0\u5C08\u6848\u88E1\u7684 AI skills
4325
+
4326
+ [\u66F8] \u53EF\u7701\u7565\uFF1A\u5728\u66F8\u7684\u8CC7\u6599\u593E\u88E1\u57F7\u884C\u3001\u6216\u5C08\u6848\u53EA\u6709\u4E00\u672C\u66F8\u6642\u6703\u81EA\u52D5\u5224\u65B7\u3002
4327
+ `;
4328
+ var FORMATS = /* @__PURE__ */ new Set(["docx", "html", "md"]);
4329
+ function openBrowser(url) {
4330
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url]] : ["xdg-open", [url]];
4331
+ try {
4332
+ const child = spawn2(cmd, args, { stdio: "ignore", detached: true });
4333
+ child.on("error", () => {
4334
+ });
4335
+ child.unref();
4336
+ } catch {
4337
+ }
4338
+ }
4339
+ async function ask(question, fallback) {
4340
+ if (!process.stdin.isTTY) return fallback;
4341
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
4342
+ try {
4343
+ const answer = (await rl.question(`${question} ${dim(`(${fallback})`)} `)).trim();
4344
+ return answer || fallback;
4345
+ } finally {
4346
+ rl.close();
4347
+ }
4348
+ }
4349
+ function projectType(value) {
4350
+ if (value === void 0) return void 0;
4351
+ if (value === "book" || value === "thesis" || value === "other") return value;
4352
+ throw new Error("--type \u53EA\u80FD\u662F book\u3001thesis \u6216 other");
4353
+ }
4354
+ async function main(argv) {
4355
+ if (argv.length === 0) {
4356
+ try {
4357
+ Project.find();
4358
+ } catch {
4359
+ process.stdout.write(HELP);
4360
+ return;
4361
+ }
4362
+ }
4363
+ const [command = "dev", ...rest] = argv;
4364
+ const { values, positionals } = parseArgs({
4365
+ args: rest,
4366
+ allowPositionals: true,
4367
+ strict: false,
4368
+ options: {
4369
+ port: { type: "string" },
4370
+ host: { type: "string" },
4371
+ "no-open": { type: "boolean" },
4372
+ out: { type: "string" },
4373
+ json: { type: "boolean" },
4374
+ type: { type: "string" },
4375
+ title: { type: "string" },
4376
+ author: { type: "string" },
4377
+ book: { type: "string" },
4378
+ "no-install": { type: "boolean" },
4379
+ "no-git": { type: "boolean" },
4380
+ force: { type: "boolean" },
4381
+ "no-split": { type: "boolean" },
4382
+ unlisted: { type: "boolean" }
4383
+ }
4384
+ });
4385
+ const opt = (name) => typeof values[name] === "string" ? values[name] : void 0;
4386
+ switch (command) {
4387
+ case "help":
4388
+ case "--help":
4389
+ case "-h":
4390
+ process.stdout.write(HELP);
4391
+ return;
4392
+ case "version":
4393
+ case "--version":
4394
+ case "-v":
4395
+ process.stdout.write(`${VERSION}
4396
+ `);
4397
+ return;
4398
+ case "init":
4399
+ case "create": {
4400
+ const dir = positionals[0] ?? await ask("\u5C08\u6848\u8CC7\u6599\u593E\u540D\u7A31\uFF1F", "my-writing");
4401
+ await init({
4402
+ dir,
4403
+ author: opt("author"),
4404
+ install: !values["no-install"],
4405
+ git: !values["no-git"],
4406
+ force: Boolean(values.force)
4407
+ });
4408
+ return;
4409
+ }
4410
+ case "dev":
4411
+ case "start":
4412
+ case "open": {
4413
+ const project = Project.find();
4414
+ const host = opt("host") ?? "127.0.0.1";
4415
+ const running = await startServer(project, {
4416
+ port: opt("port") ? Number(opt("port")) : 5280,
4417
+ host,
4418
+ webDir: WEB_DIR,
4419
+ bookTemplateDir: BOOK_TEMPLATE_DIR
4420
+ });
4421
+ process.stdout.write(
4422
+ `
4423
+ ${green("\u25CF")} ${bold("MoSage \u5BEB\u4F5C\u4ECB\u9762")} ${cyan(running.url)}
4424
+ ${dim(` \u5C08\u6848\uFF1A${project.root}`)}
4425
+ `
4426
+ );
4427
+ if (host !== "127.0.0.1" && host !== "localhost") {
4428
+ process.stdout.write(
4429
+ `${yellow("!")} \u4F3A\u670D\u5668\u5C0D ${host} \u958B\u653E \u2014\u2014 \u540C\u4E00\u500B\u7DB2\u8DEF\u4E0A\u7684\u4EFB\u4F55\u4EBA\u90FD\u80FD\u8B80\u5BEB\u4F60\u7684\u7A3F\u4EF6\u3002
4430
+ `
4431
+ );
4432
+ }
4433
+ process.stdout.write(dim(" \u6309 Ctrl+C \u7D50\u675F\n\n"));
4434
+ if (!values["no-open"]) openBrowser(running.url);
4435
+ const stop = async () => {
4436
+ await running.close();
4437
+ process.exit(0);
4438
+ };
4439
+ process.on("SIGINT", stop);
4440
+ process.on("SIGTERM", stop);
4441
+ return;
4442
+ }
4443
+ case "new": {
4444
+ const project = Project.find();
4445
+ const book = await project.createBook(BOOK_TEMPLATE_DIR, {
4446
+ id: positionals[0],
4447
+ title: opt("title"),
4448
+ type: projectType(opt("type"))
4449
+ });
4450
+ process.stdout.write(
4451
+ `${green("\u2714")} \u5DF2\u5EFA\u7ACB ${bold(`books/${book.id}/`)}\uFF08book.yaml\u3001brief.md\u3001STYLE.md\u3001chapters/\u3001assets/\u3001notes/\uFF09
4452
+ `
4453
+ );
4454
+ return;
4455
+ }
4456
+ case "status": {
4457
+ const project = Project.find();
4458
+ const json = Boolean(values.json);
4459
+ const explicit = positionals[0];
4460
+ let book = null;
4461
+ if (explicit) book = project.book(explicit);
4462
+ else {
4463
+ try {
4464
+ book = await project.resolveBook();
4465
+ } catch {
4466
+ book = null;
4467
+ }
4468
+ }
4469
+ if (book) await printStatus(book, json);
4470
+ else await printLibrary(project, json);
4471
+ return;
4472
+ }
4473
+ case "export": {
4474
+ const project = Project.find();
4475
+ const format = positionals.find((p) => FORMATS.has(p.replace(/^\./, "")))?.replace(/^\./, "");
4476
+ const bookId = positionals.find((p) => !FORMATS.has(p.replace(/^\./, "")));
4477
+ const book = await project.resolveBook(bookId);
4478
+ const { exportManuscript: exportManuscript2 } = await Promise.resolve().then(() => (init_export(), export_exports));
4479
+ const result = await exportManuscript2(book, format ?? "docx", opt("out"));
4480
+ process.stdout.write(
4481
+ `${green("\u2714")} \u5DF2\u532F\u51FA ${bold(result.path)} ${dim(`(${Math.max(1, Math.round(result.bytes / 1024))} KB)`)}
4482
+ `
4483
+ );
4484
+ return;
4485
+ }
4486
+ case "import": {
4487
+ const file = positionals[0];
4488
+ if (!file) throw new Error("\u8ACB\u6307\u5B9A\u8981\u532F\u5165\u7684\u6A94\u6848\uFF0C\u4F8B\u5982\uFF1Amosage import \u66F8\u7A3F.docx");
4489
+ const project = Project.find();
4490
+ const target = opt("book");
4491
+ let book;
4492
+ let created = false;
4493
+ const existing = target ? await project.listBookIds() : [];
4494
+ if (target && existing.includes(target)) {
4495
+ book = project.book(target);
4496
+ } else {
4497
+ const title = opt("title") ?? basename7(file, extname7(file));
4498
+ book = await project.createBook(BOOK_TEMPLATE_DIR, { id: target, title });
4499
+ await book.updateConfig((doc) => doc.set("stage", "writing"));
4500
+ created = true;
4501
+ }
4502
+ const { importFile: importFile2 } = await Promise.resolve().then(() => (init_import(), import_exports));
4503
+ const result = await importFile2(book, file, {
4504
+ split: !values["no-split"],
4505
+ listed: !values.unlisted
4506
+ });
4507
+ if (created) process.stdout.write(`${green("\u2714")} \u5EFA\u7ACB\u65B0\u66F8 ${bold(`books/${book.id}/`)}
4508
+ `);
4509
+ process.stdout.write(`${green("\u2714")} \u532F\u5165 ${result.created.length} \u500B\u7AE0\u7BC0
4510
+ `);
4511
+ for (const id of result.created) process.stdout.write(dim(` chapters/${id}
4512
+ `));
4513
+ if (result.assets.length) {
4514
+ process.stdout.write(dim(` \u53E6\u5B58\u4E86 ${result.assets.length} \u5F35\u5716\u7247\u5230 assets/imported/
4515
+ `));
4516
+ }
4517
+ for (const w of result.warnings) process.stdout.write(`${yellow("!")} ${w}
4518
+ `);
4519
+ if (created) {
4520
+ process.stdout.write(
4521
+ dim(
4522
+ "\n \u5EFA\u8B70\u63A5\u8457\u8ACB AI \u8B80\u904E\u7A3F\u5B50\u3001\u88DC\u9F4A brief.md \u8207 STYLE.md\uFF08\u5C0D AI \u8AAA\u300C\u5E6B\u6211\u6574\u7406\u9019\u672C\u532F\u5165\u7684\u66F8\u300D\uFF09\u3002\n"
4523
+ )
4524
+ );
4525
+ }
4526
+ return;
4527
+ }
4528
+ case "sync-skills": {
4529
+ const project = Project.find();
4530
+ const names = await installSkills(project.root);
4531
+ process.stdout.write(`${green("\u2714")} \u5DF2\u66F4\u65B0 ${names.length} \u500B skills\uFF1A${names.join("\u3001")}
4532
+ `);
4533
+ return;
4534
+ }
4535
+ default:
4536
+ process.stdout.write(`${red(`\u4E0D\u8A8D\u5F97\u7684\u6307\u4EE4\uFF1A${command}`)}
4537
+ ${HELP}`);
4538
+ process.exitCode = 1;
4539
+ }
4540
+ }
4541
+ main(process.argv.slice(2)).catch((err) => {
4542
+ process.stderr.write(`${red("\u2716")} ${err.message ?? err}
4543
+ `);
4544
+ process.exit(1);
4545
+ });