@godcreator/word-report 0.4.0 → 0.4.1

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/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  | `@godcreator/code-pin`、`@godcreator/doc-export`、`@godcreator/trajectory` | 钉码、生成物、轨迹卡与星轨盘 |
14
14
  | `@godcreator/docfirst` | 命令行 `docfirst`:核对、确认、生成 |
15
15
  | `@godcreator/collab` | 项目协作:任务与模块、日志与日历、通知、合并检查 |
16
- | `@godcreator/word-report` | 报告:JSON 报告文档、报告列表与报告页、命令行 `report-docx`(检查、导出 Word) |
16
+ | `@godcreator/word-report` | 报告:正文写成 MDX 或 JSON 的报告文档、报告列表与报告页、命令行 `report-docx`(检查、导出 Word、JSON 转 MDX) |
17
17
 
18
18
  | 插件(`plugins/`) | 做什么 |
19
19
  | --- | --- |
@@ -1192,4 +1192,4 @@ function alphabet(n) {
1192
1192
  return s;
1193
1193
  }
1194
1194
  //#endregion
1195
- export { parseReportJson as a, walk as i, safeRelativePath as n, ReportContentError as o, validateDocument as r, assembleDocument as t };
1195
+ export { walk as a, validateDocument as i, safeRelativePath as n, parseReportJson as o, stableValue as r, ReportContentError as s, assembleDocument as t };
package/dist/cli.d.mts CHANGED
@@ -1,18 +1,27 @@
1
- import { n as ReportExportResult, o as ReportCheckResult, t as ReportExportOptions } from "./export-DC764Umz.mjs";
1
+ import { a as ConvertReportResult, c as ReportCheckResult, n as ReportExportResult, t as ReportExportOptions } from "./export-Da4yhrZa.mjs";
2
2
  //#region src/node/cli.d.ts
3
+ /** export、status 的入口要看磁盘上是哪种报告,由 main 补上 */
3
4
  type CliCommand = {
4
5
  command: 'export' | 'status';
5
- options: ReportExportOptions;
6
+ reportId: string;
7
+ options: Omit<ReportExportOptions, 'entry'>;
6
8
  } | {
7
9
  command: 'check';
8
10
  siteDir: string;
9
11
  json: boolean;
12
+ } | {
13
+ command: 'convert';
14
+ siteDir: string;
15
+ sourceId: string;
16
+ targetId: string;
10
17
  };
11
18
  /** 解析命令行;要显示说明时回 undefined。参数不对直接报错 */
12
19
  declare function parseCliArgs(args: string[], cwd?: string): CliCommand | undefined;
13
20
  interface CliDependencies {
14
21
  reportExport?: (options: ReportExportOptions) => Promise<ReportExportResult>;
15
22
  checkReports?: (siteDir: string) => Promise<ReportCheckResult>;
23
+ reportEntryIn?: (siteDir: string, reportId: string) => Promise<string>;
24
+ convertReportToMdx?: (siteDir: string, sourceId: string, targetId: string) => Promise<ConvertReportResult>;
16
25
  sleep?: (milliseconds: number) => Promise<void>;
17
26
  stdout?: (text: string) => void;
18
27
  stderr?: (text: string) => void;
package/dist/cli.mjs CHANGED
@@ -1,17 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { n as reportExport, r as checkReports } from "./export-DJQiBPPU.mjs";
3
- import { r as reportEntryOf } from "./report-files-CgWzLxH-.mjs";
2
+ import { i as checkReports, r as reportExport, t as convertReportToMdx } from "./convert-o7bPWP9A.mjs";
3
+ import { o as reportEntryIn, s as reportEntryOf } from "./report-files-BsIvifZe.mjs";
4
4
  import path from "node:path";
5
5
  import { realpathSync } from "node:fs";
6
6
  import { fileURLToPath } from "node:url";
7
7
  //#region src/node/cli.ts
8
8
  const help = `用法:report-docx <export|status> <报告 id> [选项]
9
9
  report-docx check [选项]
10
+ report-docx convert <JSON 报告 id> <新报告 id> [--site <目录>]
10
11
 
11
- 报告在站点目录的 reports/<报告 id>/content.json。
12
- export 导出这份报告的 Word 与 PDF,等这次导出做完,把结果按 JSON 打出来
13
- status 只看这份报告的导出状态,不启动 Word
14
- check 检查站点里全部报告(每份报告、每一章)的格式、引用与图片,有问题时退出码非零
12
+ 报告在站点目录的 reports/<报告 id>/:入口是 content.json(JSON 报告)或 index.mdx(MDX 报告)。
13
+ export 导出这份报告的 Word 与 PDF,等这次导出做完,把结果按 JSON 打出来
14
+ status 只看这份报告的导出状态,不启动 Word
15
+ check 检查站点里全部报告(JSON 报告的每份与每一章、MDX 报告整份)的格式、引用与图片,有问题时退出码非零
16
+ convert 把一份 JSON 报告转成一份新的 MDX 报告,转完编译回来和原报告逐项比对,对不上就删掉新目录、退出码非零;不动原报告
15
17
 
16
18
  选项:
17
19
  --site <目录> 站点目录(默认当前目录)
@@ -43,7 +45,7 @@ function flagValuesOf(flags, allowed) {
43
45
  }
44
46
  /** 解析命令行;要显示说明时回 undefined。参数不对直接报错 */
45
47
  function parseCliArgs(args, cwd = process.cwd()) {
46
- if (args.length === 0) throw new Error("缺少命令:export、status 或 check;用 --help 查看用法");
48
+ if (args.length === 0) throw new Error("缺少命令:export、status、check 或 convert;用 --help 查看用法");
47
49
  if (args.includes("--help") || args.includes("-h")) return void 0;
48
50
  const [command, ...rest] = args;
49
51
  if (command === "check") {
@@ -54,17 +56,30 @@ function parseCliArgs(args, cwd = process.cwd()) {
54
56
  json: switches.has("--json")
55
57
  };
56
58
  }
57
- if (command !== "export" && command !== "status") throw new Error(`认不出的命令 ${JSON.stringify(command)}:只有 export、status、check;用 --help 查看用法`);
59
+ if (command === "convert") {
60
+ const [sourceId, targetId, ...flags] = rest;
61
+ if (sourceId === void 0 || targetId === void 0 || sourceId.startsWith("-") || targetId.startsWith("-")) throw new Error("convert 要给两个报告 id:要转的 JSON 报告、新的 MDX 报告");
62
+ reportEntryOf(sourceId);
63
+ reportEntryOf(targetId);
64
+ const { values } = flagValuesOf(flags, ["--site"]);
65
+ return {
66
+ command,
67
+ siteDir: path.resolve(cwd, values.get("--site") ?? "."),
68
+ sourceId,
69
+ targetId
70
+ };
71
+ }
72
+ if (command !== "export" && command !== "status") throw new Error(`认不出的命令 ${JSON.stringify(command)}:只有 export、status、check、convert;用 --help 查看用法`);
58
73
  const [reportId, ...flags] = rest;
59
74
  if (reportId === void 0 || reportId.startsWith("-")) throw new Error(`${command} 要给报告 id(站点 reports/ 下的目录名)`);
60
- const entry = reportEntryOf(reportId);
75
+ reportEntryOf(reportId);
61
76
  const { values } = flagValuesOf(flags, ["--site", "--engine"]);
62
77
  const engine = values.get("--engine");
63
78
  return {
64
79
  command,
80
+ reportId,
65
81
  options: {
66
82
  siteDir: path.resolve(cwd, values.get("--site") ?? "."),
67
- entry,
68
83
  mode: "complete",
69
84
  action: command === "export" ? "start" : "status",
70
85
  ...engine !== void 0 ? { engineDir: path.resolve(cwd, engine) } : {}
@@ -78,6 +93,12 @@ function checkReportText(result) {
78
93
  if (result.problems.length === 0) lines.push("没有问题。");
79
94
  return `${lines.join("\n")}\n`;
80
95
  }
96
+ function convertReportText(result) {
97
+ if (result.converted) return `转好了:${result.targetDir}\n${result.files.map((file) => ` ${file}`).join("\n")}\n`;
98
+ const lines = [`没有转换:${result.reason}`];
99
+ for (const problem of result.problems) lines.push(` ${problem}`);
100
+ return `${lines.join("\n")}\n`;
101
+ }
81
102
  /** export 要等引擎做完同一个任务才退出:引擎在固定下来的快照上工作,进程退了就没人等它的结果 */
82
103
  async function runExport(options, dependencies) {
83
104
  let result = await dependencies.reportExport(options);
@@ -113,7 +134,16 @@ async function main(args, dependencies = {}) {
113
134
  write(parsed.json ? `${JSON.stringify(result)}\n` : checkReportText(result));
114
135
  return result.problems.length > 0 ? 1 : 0;
115
136
  }
116
- const result = await runExport(parsed.options, {
137
+ if (parsed.command === "convert") {
138
+ const result = await (dependencies.convertReportToMdx ?? convertReportToMdx)(parsed.siteDir, parsed.sourceId, parsed.targetId);
139
+ write(convertReportText(result));
140
+ return result.converted ? 0 : 1;
141
+ }
142
+ const entry = await (dependencies.reportEntryIn ?? reportEntryIn)(parsed.options.siteDir, parsed.reportId);
143
+ const result = await runExport({
144
+ ...parsed.options,
145
+ entry
146
+ }, {
117
147
  reportExport: dependencies.reportExport ?? reportExport,
118
148
  sleep
119
149
  });
@@ -1,6 +1,7 @@
1
- import { a as parseReportJson, i as walk, n as safeRelativePath, o as ReportContentError, r as validateDocument, t as assembleDocument } from "./assemble-D25NLmLx.mjs";
2
- import { a as reportsDirName } from "./report-files-CgWzLxH-.mjs";
3
- import { access, mkdir, open, readFile, readdir, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
1
+ import { a as walk, i as validateDocument, n as safeRelativePath, o as parseReportJson, r as stableValue, s as ReportContentError, t as assembleDocument } from "./assemble-BpllQNVY.mjs";
2
+ import { a as assembleMdxReport, n as mdxReportPlanOf, r as MDX_ENTRY_NAME, s as mdxReportInputsOf } from "./mdx-4Rs1N1nv.mjs";
3
+ import { l as reportsDirName, o as reportEntryIn, r as isMdxEntry, s as reportEntryOf, t as JSON_ENTRY_NAME } from "./report-files-BsIvifZe.mjs";
4
+ import { access, copyFile, mkdir, open, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
4
5
  import path from "node:path";
5
6
  import { createHash, randomUUID } from "node:crypto";
6
7
  import sharp from "sharp";
@@ -51,18 +52,20 @@ async function readReport(entryAbsolute, options = {}) {
51
52
  throw new ReportContentError("Unreadable report file", key);
52
53
  }
53
54
  }
55
+ async function readText(absolute, format) {
56
+ const { bytes, key } = await readWithin(absolute);
57
+ try {
58
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
59
+ } catch {
60
+ throw new ReportContentError(`Document must be UTF-8 ${format}`, key);
61
+ }
62
+ }
54
63
  async function load(absolute) {
55
64
  const key = relative(absolute);
56
65
  if (visiting.has(key)) throw new ReportContentError("Circular document include", key);
57
66
  if (Object.hasOwn(files, key)) return;
58
67
  visiting.add(key);
59
- const { bytes } = await readWithin(absolute);
60
- let text;
61
- try {
62
- text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
63
- } catch {
64
- throw new ReportContentError("Document must be UTF-8 JSON", key);
65
- }
68
+ const text = await readText(absolute, "JSON");
66
69
  files[key] = text;
67
70
  const document = parseReportJson(text, key);
68
71
  validateDocument(document, key);
@@ -76,8 +79,28 @@ async function readReport(entryAbsolute, options = {}) {
76
79
  }
77
80
  visiting.delete(key);
78
81
  }
79
- await load(entry);
80
- const result = assembleDocument(relative(entry), files, options);
82
+ /** MDX 报告:入口、各章与文献列表都进快照,编译成一份文档再组装 */
83
+ async function loadMdx() {
84
+ const entryKey = relative(entry);
85
+ files[entryKey] = await readText(entry, "MDX");
86
+ const { chapters, references } = mdxReportInputsOf(files[entryKey], entryKey);
87
+ for (const chapter of chapters) files[chapter] = await readText(path.resolve(root, chapter), "MDX");
88
+ if (await stat(path.resolve(root, references)).then((info) => info.isFile(), (error) => {
89
+ if (error.code === "ENOENT") return false;
90
+ throw error;
91
+ })) files[references] = await readText(path.resolve(root, references), "MDX");
92
+ return assembleMdxReport({
93
+ reportId: path.basename(path.dirname(entry)),
94
+ entry: entryKey,
95
+ files
96
+ }, options);
97
+ }
98
+ let result;
99
+ if (path.basename(entry) === "index.mdx") result = await loadMdx();
100
+ else {
101
+ await load(entry);
102
+ result = assembleDocument(relative(entry), files, options);
103
+ }
81
104
  const assetBytes = Object.create(null), normalized = /* @__PURE__ */ new Map();
82
105
  for (const [src, source] of Object.entries(result.assets)) {
83
106
  const folded = source.normalize("NFC").toLowerCase(), previous = normalized.get(folded);
@@ -121,7 +144,10 @@ async function readReport(entryAbsolute, options = {}) {
121
144
  }
122
145
  //#endregion
123
146
  //#region src/node/check.ts
124
- /** 检查站点 reports/ 下的每个 content.json(每份报告与每一章):只用本包的代码,不执行站点里的脚本 */
147
+ /**
148
+ * 检查站点 reports/ 下的每个 content.json(每份报告与每一章)与每份 MDX 报告(报告目录根上的 index.mdx,只整份检查:MDX 的章不是独立文档)。
149
+ * 只用本包的代码,不执行站点里的脚本
150
+ */
125
151
  async function checkReports(siteDir) {
126
152
  const result = {
127
153
  checked: 0,
@@ -136,7 +162,8 @@ async function checkReports(siteDir) {
136
162
  throw new ReportContentError("没有这个站点目录", siteDir);
137
163
  }
138
164
  const relative = (file) => path.relative(root, file).split(path.sep).join("/");
139
- const entries = [], directories = /* @__PURE__ */ new Set();
165
+ const entries = [], mdxEntries = [], directories = /* @__PURE__ */ new Set();
166
+ const reports = path.join(root, reportsDirName);
140
167
  function diagnostic(error, file, mode) {
141
168
  const known = error instanceof ReportContentError;
142
169
  result.problems.push({
@@ -158,9 +185,9 @@ async function checkReports(siteDir) {
158
185
  if (item.isSymbolicLink()) isDirectory = (await stat(file)).isDirectory();
159
186
  if (isDirectory) await scan(file);
160
187
  else if (item.name === "content.json") entries.push(file);
188
+ else if (item.name === "index.mdx" && path.dirname(directory) === reports) mdxEntries.push(file);
161
189
  }
162
190
  }
163
- const reports = path.join(root, reportsDirName);
164
191
  try {
165
192
  await stat(reports);
166
193
  } catch (error) {
@@ -169,6 +196,35 @@ async function checkReports(siteDir) {
169
196
  }
170
197
  await scan(reports);
171
198
  entries.sort();
199
+ mdxEntries.sort();
200
+ for (const entry of mdxEntries) {
201
+ const file = relative(entry);
202
+ result.checked++;
203
+ try {
204
+ if (entries.includes(path.join(path.dirname(entry), "content.json"))) throw new ReportContentError(`报告目录里 ${JSON_ENTRY_NAME} 和 ${MDX_ENTRY_NAME} 只能留一个`, relative(path.dirname(entry)));
205
+ const report = await readReport(entry, {
206
+ root,
207
+ mode: "complete"
208
+ });
209
+ result.warnings.push(...report.warnings.map((warning) => ({
210
+ ...warning,
211
+ entry: file,
212
+ mode: "complete"
213
+ })));
214
+ result.runs.push({
215
+ file,
216
+ mode: "complete",
217
+ passed: true
218
+ });
219
+ } catch (error) {
220
+ diagnostic(error, file, "complete");
221
+ result.runs.push({
222
+ file,
223
+ mode: "complete",
224
+ passed: false
225
+ });
226
+ }
227
+ }
172
228
  for (const entry of entries) {
173
229
  const file = relative(entry);
174
230
  result.checked++;
@@ -216,7 +272,7 @@ async function checkReports(siteDir) {
216
272
  //#endregion
217
273
  //#region src/node/export.ts
218
274
  const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
219
- const exists = async (file) => access(file).then(() => true, () => false);
275
+ const exists$1 = async (file) => access(file).then(() => true, () => false);
220
276
  async function saved(file) {
221
277
  try {
222
278
  return JSON.parse(await readFile(file, "utf8"));
@@ -242,7 +298,7 @@ function installedPackageDir() {
242
298
  const packagedEngineDir = (packageDir) => path.join(packageDir, "engine");
243
299
  /** 入口相对站点目录,不收本机的任意路径 */
244
300
  function reportEntry(entry) {
245
- if (typeof entry !== "string" || !entry.endsWith("/content.json") || /[\\:\x00-\x1f]/.test(entry) || entry.startsWith("/") || entry.split("/").some((part) => !part || part === "." || part === "..")) throw new Error("报告入口要是站点目录里的相对 content.json 路径,如 reports/<报告 id>/content.json");
301
+ if (typeof entry !== "string" || !(entry.endsWith("/content.json") || entry.endsWith("/index.mdx")) || /[\\:\x00-\x1f]/.test(entry) || entry.startsWith("/") || entry.split("/").some((part) => !part || part === "." || part === "..")) throw new Error("报告入口要是站点目录里的相对路径,如 reports/<报告 id>/content.json 或 reports/<报告 id>/index.mdx");
246
302
  return entry;
247
303
  }
248
304
  function reportMode(value) {
@@ -268,12 +324,12 @@ async function engineFingerprint(engine, compiler) {
268
324
  }
269
325
  inputs.push(["compiler/package.json", hash(await readFile(path.join(compiler, "package.json")))]);
270
326
  await compilerFiles("dist");
271
- if (await exists(path.join(compiler, "src"))) await compilerFiles("src");
327
+ if (await exists$1(path.join(compiler, "src"))) await compilerFiles("src");
272
328
  return hash(JSON.stringify(inputs.sort(([a], [b]) => a.localeCompare(b))));
273
329
  }
274
- async function layoutOf(root, entry) {
330
+ async function layoutOf$1(root, entry) {
275
331
  const file = path.join(root, path.dirname(entry), "layout.json");
276
- if (!await exists(file)) return { file };
332
+ if (!await exists$1(file)) return { file };
277
333
  const resolved = await realpath(file), relative = path.relative(await realpath(root), resolved);
278
334
  if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("布局文件不在站点目录里");
279
335
  const bytes = await readFile(resolved);
@@ -294,7 +350,7 @@ async function capture(site, engine, compiler, entry, mode) {
294
350
  mode
295
351
  });
296
352
  rejectMultiImageFigures(report.document);
297
- const layout = await layoutOf(root, entry);
353
+ const layout = await layoutOf$1(root, entry);
298
354
  const engineHash = await engineFingerprint(engine, compiler);
299
355
  return {
300
356
  report,
@@ -500,7 +556,7 @@ async function reportExport(options) {
500
556
  const engine = path.resolve(options.engineDir ?? process.env.DOCREPORT_ENGINE_ROOT ?? packagedEngineDir(compiler));
501
557
  const key = keyFor(site, entry, mode), active = await latestAttempt(engine, compiler, key, site), previous = await previousJob(engine, key, site, entry, mode);
502
558
  const outputs = await outputPaths(engine, previous);
503
- if (!await exists(path.join(engine, ".venv/Scripts/python.exe"))) return {
559
+ if (!await exists$1(path.join(engine, ".venv/Scripts/python.exe"))) return {
504
560
  state: "unavailable",
505
561
  message: `Word 引擎 ${engine} 里还没有 Python 环境:在那里运行 python -m venv .venv 与 .venv\\Scripts\\pip install -r requirements.txt(要本机装着 Word 与 MathType);引擎放在别处时用 --engine 或环境变量 DOCREPORT_ENGINE_ROOT 指过去`,
506
562
  ...outputs
@@ -559,4 +615,152 @@ async function reportExport(options) {
559
615
  };
560
616
  }
561
617
  //#endregion
562
- export { readReport as i, reportExport as n, checkReports as r, reportEntry as t };
618
+ //#region src/node/convert.ts
619
+ const MAX_LISTED_DIFFERENCES = 20;
620
+ async function exists(file) {
621
+ return stat(file).then(() => true, (error) => {
622
+ if (error.code === "ENOENT") return false;
623
+ throw error;
624
+ });
625
+ }
626
+ async function layoutOf(reportDir) {
627
+ const file = path.join(reportDir, "layout.json");
628
+ if (!await exists(file)) return void 0;
629
+ const bytes = await readFile(file);
630
+ return {
631
+ bytes,
632
+ keys: Object.keys(JSON.parse(bytes.toString("utf8")))
633
+ };
634
+ }
635
+ const MARK_ORDER = [
636
+ "strong",
637
+ "em",
638
+ "sub",
639
+ "sup",
640
+ "code"
641
+ ];
642
+ /**
643
+ * 比对用的样子:只留写出来的 id(其余由编译器重新生成)、图片路径只看文件名、文档 id 不比、
644
+ * 相邻且标记相同的文字合并、标记按固定顺序、有序列表的起始数 1 不写、没有块的单元格当成一个空段(MDX 的空格子编译成空段,Word 里两者一样)
645
+ */
646
+ function comparable(document, keptIds) {
647
+ const normalize = (value) => {
648
+ if (Array.isArray(value)) {
649
+ const items = value.map(normalize);
650
+ const merged = [];
651
+ for (const item of items) {
652
+ const previous = merged.at(-1);
653
+ if (item?.["type"] === "text" && previous?.["type"] === "text" && stableValue(previous["marks"] ?? []) === stableValue(item["marks"] ?? [])) previous["text"] = `${previous["text"]}${item["text"]}`;
654
+ else merged.push(item);
655
+ }
656
+ return merged;
657
+ }
658
+ if (!value || typeof value !== "object") return value;
659
+ const copy = {};
660
+ for (const [key, child] of Object.entries(value)) {
661
+ if (key === "id" && !keptIds.has(child)) continue;
662
+ if (key === "src" && typeof child === "string") copy[key] = child.slice(child.lastIndexOf("/") + 1);
663
+ else if (key === "marks" && Array.isArray(child)) copy[key] = MARK_ORDER.filter((mark) => child.includes(mark));
664
+ else copy[key] = normalize(child);
665
+ }
666
+ if (copy["type"] === "list" && copy["start"] === 1) delete copy["start"];
667
+ if (Array.isArray(copy["cells"])) {
668
+ for (const cell of copy["cells"]) if (cell["blocks"].length === 0) cell["blocks"] = [{
669
+ type: "paragraph",
670
+ content: []
671
+ }];
672
+ }
673
+ return copy;
674
+ };
675
+ const { id: _documentId, ...rest } = document;
676
+ return normalize(rest);
677
+ }
678
+ function differencesOf(before, after, pointer = "") {
679
+ if (stableValue(before) === stableValue(after)) return [];
680
+ const bothArrays = Array.isArray(before) && Array.isArray(after);
681
+ const bothObjects = !bothArrays && before && after && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after);
682
+ if (bothArrays && before.length === after.length) return before.flatMap((item, index) => differencesOf(item, after[index], `${pointer}/${index}`));
683
+ if (bothObjects) return [.../* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])].sort().flatMap((key) => differencesOf(before[key], after[key], `${pointer}/${key}`));
684
+ return [`${pointer || "/"}:原报告 ${JSON.stringify(before)},转回来 ${JSON.stringify(after)}`];
685
+ }
686
+ /**
687
+ * 把一份 JSON 报告转成一份新的 MDX 报告(reports/<新 id>/),不动原报告。
688
+ * 原报告里有 MDX 写不出的内容就一个文件都不写;写完编译回来和原报告逐项比对,对不上就删掉新目录
689
+ */
690
+ async function convertReportToMdx(siteDir, sourceId, targetId) {
691
+ const site = await realpath(siteDir);
692
+ const sourceEntry = await reportEntryIn(site, sourceId);
693
+ if (isMdxEntry(sourceEntry)) throw new Error(`报告 ${sourceId} 已经是 MDX 报告`);
694
+ if (!/^[a-z]/.test(targetId)) throw new Error(`新报告 id「${targetId}」要以字母开头:MDX 报告的目录名就是文档 id`);
695
+ const targetDir = path.join(site, path.dirname(reportEntryOf(targetId)));
696
+ if (await exists(targetDir)) throw new Error(`reports/${targetId} 已经存在:convert 只写新目录`);
697
+ const source = await readReport(path.join(site, sourceEntry), {
698
+ root: site,
699
+ mode: "complete"
700
+ });
701
+ const layout = await layoutOf(path.join(site, reportsDirName, sourceId));
702
+ const plan = mdxReportPlanOf(source.document, source.assets, layout?.keys ?? []);
703
+ if (plan.problems.length) return {
704
+ converted: false,
705
+ reason: `原报告里有 ${plan.problems.length} 处 MDX 写不出来,没有写任何文件`,
706
+ problems: plan.problems
707
+ };
708
+ await mkdir(path.join(targetDir, "assets"), { recursive: true });
709
+ const written = [];
710
+ try {
711
+ for (const [name, text] of Object.entries(plan.files)) {
712
+ await writeFile(path.join(targetDir, name), text);
713
+ written.push(name);
714
+ }
715
+ for (const [name, compiledPath] of Object.entries(plan.images)) {
716
+ await copyFile(path.join(site, source.assets[compiledPath]), path.join(targetDir, "assets", name));
717
+ written.push(`assets/${name}`);
718
+ }
719
+ if (layout) {
720
+ await writeFile(path.join(targetDir, "layout.json"), layout.bytes);
721
+ written.push("layout.json");
722
+ }
723
+ let converted;
724
+ try {
725
+ converted = await readReport(path.join(targetDir, "index.mdx"), {
726
+ root: site,
727
+ mode: "complete"
728
+ });
729
+ } catch (error) {
730
+ await rm(targetDir, {
731
+ recursive: true,
732
+ force: true
733
+ });
734
+ return {
735
+ converted: false,
736
+ reason: "转出来的 MDX 编译不过,已删掉新目录",
737
+ problems: [error instanceof Error ? error.message : String(error)]
738
+ };
739
+ }
740
+ const differences = differencesOf(comparable(source.document, plan.keptIds), comparable(converted.document, plan.keptIds));
741
+ if (differences.length) {
742
+ await rm(targetDir, {
743
+ recursive: true,
744
+ force: true
745
+ });
746
+ return {
747
+ converted: false,
748
+ reason: `转回来和原报告有 ${differences.length} 处对不上,已删掉新目录`,
749
+ problems: differences.slice(0, MAX_LISTED_DIFFERENCES)
750
+ };
751
+ }
752
+ } catch (error) {
753
+ await rm(targetDir, {
754
+ recursive: true,
755
+ force: true
756
+ });
757
+ throw error;
758
+ }
759
+ return {
760
+ converted: true,
761
+ targetDir,
762
+ files: written.sort()
763
+ };
764
+ }
765
+ //#endregion
766
+ export { readReport as a, checkReports as i, reportEntry as n, reportExport as r, convertReportToMdx as t };
package/dist/core.d.mts CHANGED
@@ -10,4 +10,59 @@ declare function parseReportJson(text: string, file?: string): unknown;
10
10
  declare function validateDocument(value: unknown, file?: string): asserts value is ReportDocument;
11
11
  declare function safeRelativePath(raw: string, asset?: boolean): string;
12
12
  //#endregion
13
- export { Abstract, AssemblyOptions, AssemblyResult, Bibliography, CellBlock, Code, CompiledReport, ContentWarning, Equation, Figure, FigurePanel, Include, IndexEntry, MultiImageFigure, Paragraph, ReportBlock, ReportContentError, ReportDocument, ReportIndex, ReportInline, ReportList, Section, SingleImageFigure, SourceLocation, Table, TableCell, assembleDocument, parseReportJson, safeRelativePath, validateDocument };
13
+ //#region src/core/mdx/compile.d.ts
14
+ /** 报告目录里固定名字的两个文件 */
15
+ declare const MDX_ENTRY_NAME = "index.mdx";
16
+ declare const MDX_REFERENCES_NAME = "references.mdx";
17
+ interface MdxReportInput {
18
+ /** 报告目录名,用作文档 id */
19
+ reportId: string;
20
+ /** 入口 index.mdx 在 files 里的键;章与文献列表的路径相对它所在的目录 */
21
+ entry: string;
22
+ /** 路径 → MDX 原文:入口、chapters 列的各章,以及存在时的 references.mdx */
23
+ files: Record<string, string>;
24
+ }
25
+ interface CompiledMdxReport {
26
+ document: ReportDocument;
27
+ /** 节点 id → MDX 文件与「行:列」 */
28
+ sources: Record<string, SourceLocation>;
29
+ /** 编译结果里的 JSON 指针 → 最近的有记录的上级节点的 MDX 位置 */
30
+ locate(pointer: string): SourceLocation | undefined;
31
+ /** 编进来的 MDX 文件,按入口、各章、文献列表的顺序 */
32
+ files: string[];
33
+ }
34
+ /** 读入口原文就知道还要读哪些文件:章按 chapters 的顺序,文献列表 references.mdx 有没有由调用方看磁盘 */
35
+ declare function mdxReportInputsOf(indexText: string, entry: string): {
36
+ chapters: string[];
37
+ references: string;
38
+ };
39
+ /** 整份 MDX 报告 → 一份 docreport 文档(入口正文在前,各章按 chapters 的顺序接在后面);只解析、不执行 MDX */
40
+ declare function compileMdxReport({ reportId, entry, files }: MdxReportInput): CompiledMdxReport;
41
+ /** 编译并组装一份 MDX 报告:结果和 JSON 报告的组装结果同形,sources 指向 MDX 的行列,dependencies 是编进来的 MDX 文件 */
42
+ declare function assembleMdxReport(input: MdxReportInput, options?: AssemblyOptions): AssemblyResult;
43
+ //#endregion
44
+ //#region src/core/mdx/ids.d.ts
45
+ /** 编译器生成的 id 都以它开头;手写的 id 不许用,两边就不会撞 */
46
+ declare const GENERATED_ID_PREFIX = "auto-";
47
+ //#endregion
48
+ //#region src/core/mdx/emit.d.ts
49
+ /**
50
+ * 组装好的 docreport 文档 → 一份 MDX 报告的各个文件:convert 命令用它迁移 JSON 报告。
51
+ * 只有被引用的、layout.json 里出现的、文献与脚注的 id 写出来,其余交给编译器重新生成;
52
+ * MDX 写不出的内容记进 problems(带 JSON 指针),有一条就不该写文件
53
+ */
54
+ interface MdxReportPlan {
55
+ /** 文件名(相对新报告目录)→ MDX 原文 */
56
+ files: Record<string, string>;
57
+ /** 新报告里的图片文件名(assets/ 下)→ 原图在组装结果里的路径(AssemblyResult.assets 的键) */
58
+ images: Record<string, string>;
59
+ /** 写出来的手写 id:比对时只有它们要求两边一样 */
60
+ keptIds: Set<string>;
61
+ problems: string[];
62
+ }
63
+ /** 哪些 id 要写出来:被 ref 指到的、layout.json 的键、文献与脚注(它们本来就靠 id 对应) */
64
+ declare function keptIdsOf(document: ReportDocument, layoutKeys: string[]): Set<string>;
65
+ /** 组装好的文档 → MDX 报告。assets 是组装结果的 assets(图在组装结果里的路径 → 原图路径) */
66
+ declare function mdxReportPlanOf(document: ReportDocument, assets: Record<string, string>, layoutKeys: string[]): MdxReportPlan;
67
+ //#endregion
68
+ export { Abstract, AssemblyOptions, AssemblyResult, Bibliography, CellBlock, Code, type CompiledMdxReport, CompiledReport, ContentWarning, Equation, Figure, FigurePanel, GENERATED_ID_PREFIX, Include, IndexEntry, MDX_ENTRY_NAME, MDX_REFERENCES_NAME, type MdxReportInput, type MdxReportPlan, MultiImageFigure, Paragraph, ReportBlock, ReportContentError, ReportDocument, ReportIndex, ReportInline, ReportList, Section, SingleImageFigure, SourceLocation, Table, TableCell, assembleDocument, assembleMdxReport, compileMdxReport, keptIdsOf, mdxReportInputsOf, mdxReportPlanOf, parseReportJson, safeRelativePath, validateDocument };
package/dist/core.mjs CHANGED
@@ -1 +1,3 @@
1
- export { o as ReportContentError, t as assembleDocument, a as parseReportJson, n as safeRelativePath, r as validateDocument } from "./assemble-D25NLmLx.mjs";
1
+ import { i as validateDocument, n as safeRelativePath, o as parseReportJson, s as ReportContentError, t as assembleDocument } from "./assemble-BpllQNVY.mjs";
2
+ import { a as assembleMdxReport, c as GENERATED_ID_PREFIX, i as MDX_REFERENCES_NAME, n as mdxReportPlanOf, o as compileMdxReport, r as MDX_ENTRY_NAME, s as mdxReportInputsOf, t as keptIdsOf } from "./mdx-4Rs1N1nv.mjs";
3
+ export { GENERATED_ID_PREFIX, MDX_ENTRY_NAME, MDX_REFERENCES_NAME, ReportContentError, assembleDocument, assembleMdxReport, compileMdxReport, keptIdsOf, mdxReportInputsOf, mdxReportPlanOf, parseReportJson, safeRelativePath, validateDocument };
@@ -20,9 +20,28 @@ interface ReportCheckResult {
20
20
  mode: 'standalone' | 'complete';
21
21
  })[];
22
22
  }
23
- /** 检查站点 reports/ 下的每个 content.json(每份报告与每一章):只用本包的代码,不执行站点里的脚本 */
23
+ /**
24
+ * 检查站点 reports/ 下的每个 content.json(每份报告与每一章)与每份 MDX 报告(报告目录根上的 index.mdx,只整份检查:MDX 的章不是独立文档)。
25
+ * 只用本包的代码,不执行站点里的脚本
26
+ */
24
27
  declare function checkReports(siteDir: string): Promise<ReportCheckResult>;
25
28
  //#endregion
29
+ //#region src/node/convert.d.ts
30
+ type ConvertReportResult = {
31
+ converted: true;
32
+ targetDir: string;
33
+ files: string[];
34
+ } | {
35
+ converted: false;
36
+ reason: string;
37
+ problems: string[];
38
+ };
39
+ /**
40
+ * 把一份 JSON 报告转成一份新的 MDX 报告(reports/<新 id>/),不动原报告。
41
+ * 原报告里有 MDX 写不出的内容就一个文件都不写;写完编译回来和原报告逐项比对,对不上就删掉新目录
42
+ */
43
+ declare function convertReportToMdx(siteDir: string, sourceId: string, targetId: string): Promise<ConvertReportResult>;
44
+ //#endregion
26
45
  //#region src/node/export.d.ts
27
46
  type Mode = 'complete' | 'standalone';
28
47
  type Job = {
@@ -38,7 +57,7 @@ type Job = {
38
57
  };
39
58
  interface ReportExportOptions {
40
59
  siteDir: string;
41
- /** 相对站点目录的 content.json 路径,如 reports/<报告 id>/content.json(reportEntryOf) */
60
+ /** 相对站点目录的入口路径:reports/<报告 id>/content.json 或 reports/<报告 id>/index.mdx(reportEntryIn) */
42
61
  entry: string;
43
62
  mode?: Mode;
44
63
  action?: 'start' | 'status';
@@ -64,4 +83,4 @@ declare function reportEntry(entry: unknown): string;
64
83
  /** Package API: callers can start or inspect a render without a website or workbench process. */
65
84
  declare function reportExport(options: ReportExportOptions): Promise<ReportExportResult>;
66
85
  //#endregion
67
- export { ReportCheckDiagnostic as a, reportExport as i, ReportExportResult as n, ReportCheckResult as o, reportEntry as r, checkReports as s, ReportExportOptions as t };
86
+ export { ConvertReportResult as a, ReportCheckResult as c, reportExport as i, checkReports as l, ReportExportResult as n, convertReportToMdx as o, reportEntry as r, ReportCheckDiagnostic as s, ReportExportOptions as t };