create-asciidoc-multi-book-workspace 0.6.0 → 0.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 - 2026-10-31
4
+
5
+ ### Removed
6
+
7
+ - The generated workspace has no `check` command. `pnpm run build` is the only way to render, and `check` ran the same path only so the runtime's checks could be run on their own.
8
+ - The runtime no longer judges a workspace: `XREF_UNRESOLVED`, `XREF_AMBIGUOUS`, `HTML_RESOURCE_MISSING`, `HTML_ANCHOR_MISSING`, `CATALOG_TARGET_MISSING`, `BOOK_MISSING_FROM_CATALOG`, and `MISSING_DOCTYPE` are gone. Whether a reference reaches one heading is read from the `asciidoc-abundant-tree` projection by the tool that reads it; the build fails only where it cannot produce the pages: a missing `catalog.adoc` or book, any Asciidoctor message, an include graph that cannot be parsed within `documentRoot` or an error-level parse diagnostic, or a configuration or include resolver it cannot use.
9
+
10
+ ### Changed
11
+
12
+ - Generated workspaces depend on `asciidoc-abundant-tree` `^0.6.0`. Its label space is four predicates on the heading nodes of the graph, and an xref whose address several headings own gets an edge to each of them. The runtime rewrites an HTML link only when its xref reaches exactly one section, inside a book and across books; any other xref is left as Asciidoctor wrote it. The document title owns labels but is not a section, so an xref to a title only the document title owns is left as written.
13
+ - The samples no longer say a check reports broken references. After renaming a heading, an author searches the book for the old name, which finds every reference written by it (qualified addresses included), its link text, and the prose. The structured writing conventions sample states where an unbound or ambiguous link lands on the page (an unbound one is broken; one that several headings share lands on the first of them), shows the projection as `asciidoc-abundant-tree` 0.6 writes it (heading nodes named by the projector, addresses as `aat:headline` and `aat:addressPath` values), and lists a title that starts with inline markup among those a reference cannot name by their text.
14
+
15
+ ### Upgrading an existing workspace
16
+
17
+ Create a temporary workspace with `pnpm create asciidoc-multi-book-workspace@0.7.0 tmp-books` and copy its `tools/adoc-books.mjs` over yours. In your `package.json`, remove the `check` script, set `"asciidoc-abundant-tree": "^0.6.0"`, and run `pnpm install`. Books need no change. To find references that reach no heading or several, read the projection: an xref edge (`aat:XrefEdge`) carries one `aat:targetHeading` for each heading its address reaches.
18
+
3
19
  ## 0.6.0 - 2026-10-31
4
20
 
5
21
  ### Added
package/README.md CHANGED
@@ -97,7 +97,6 @@ The generated scripts are:
97
97
 
98
98
  ```bash
99
99
  pnpm run build
100
- pnpm run check
101
100
  pnpm run clean
102
101
  ```
103
102
 
@@ -8,11 +8,10 @@ import asciidoctorFactory from "@asciidoctor/core";
8
8
  import { parseAbundantTree, rdf12 } from "asciidoc-abundant-tree";
9
9
  const require = createRequire(import.meta.url);
10
10
  const DIAGRAM_BLOCK_PATTERN = /^\[(?:actdiag|blockdiag|bpmn|bytefield|c4plantuml|d2|dbml|ditaa|erd|excalidraw|graphviz|mermaid|nomnoml|nwdiag|packetdiag|pikchr|plantuml|rackdiag|seqdiag|svgbob|symbolator|umlet|vega|vegalite|wavedrom|structurizr|diagramsnet|wireviz)(?:,|\])/m;
11
- const CATALOG_BOOK_XREF_PATTERN = /xref:books\/([^/\]]+)\/book\.adoc(?:#[^\[]+)?\[/g;
12
- const LOCAL_TARGET_PATTERN = /\b(href|src)="([^"]+)"/g;
13
11
  const HTML_ANCHOR_PATTERN = /\b(?:id|name)="([^"]+)"/g;
14
- const SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
15
12
  const AAT_NAMESPACE = "https://micheng.dev/ns/asciidoc-abundant-tree#";
13
+ // 标题投影的 label space:aat 图上标题节点的这四个谓词(aat 06 号书第 2 章「地址与 Label 空间」)。
14
+ const LABEL_PREDICATES = ["headline", "addressPath", "addressLabel", "generatedAddressLabel"];
16
15
  const HTML_FRAGMENT_LINK_PATTERN = /<a href="#([^"]*)"([^>]*)>([\s\S]*?)<\/a>/g;
17
16
  const HTML_DOCUMENT_LINK_PATTERN = /<a href="([^"#]+\.html)#([^"]*)"([^>]*)>([\s\S]*?)<\/a>/g;
18
17
  const HOME_MARKER = "data-multi-book-home";
@@ -345,7 +344,6 @@ function convertFile(asciidoctor, extensionRegistry, input, outputFile, baseDir,
345
344
  }
346
345
  return document;
347
346
  }
348
- // 交回每本书转换后的 Asciidoctor 文档:书内与跨书 xref 的目标要用它展开属性引用(bindReferences)。
349
347
  async function buildHtml(rootDir, books, asciidoctor, extensionRegistry, useKroki, fetchDiagrams) {
350
348
  await mkdir(path.join(rootDir, "build", "html"), { recursive: true });
351
349
  convertFile(asciidoctor, extensionRegistry, path.join(rootDir, "catalog.adoc"), path.join(rootDir, "build", "html", "catalog.html"), rootDir);
@@ -468,7 +466,7 @@ function sourceBundleFromDocument(document) {
468
466
  }).join("\n");
469
467
  }
470
468
  // 解析失败就让构建失败,不退回只有封面的阅读页:退回时 include 越过 documentRoot 只剩一条 warning,
471
- // 构建照常成功,没有人发现阅读页坏了(DESIGN.adoc 的 Workspace Contract Checks)。
469
+ // 构建照常成功,没有人发现阅读页坏了(DESIGN.adoc 的 Rendering Failures)。
472
470
  async function buildReaderBookData(config, book) {
473
471
  let parsed;
474
472
  let document;
@@ -557,9 +555,10 @@ async function buildReaderBookData(config, book) {
557
555
  references: bindReferences(config, parsed)
558
556
  };
559
557
  }
560
- // 书内 xref 指向谁,以 aat 的绑定为准(DESIGN.adoc 的 Reference Binding):标题原文、限定地址、手写 ID、生成的 ID
561
- // 都在它的 label space 里,重名给出候选而不替人挑。哪些是 xref、目标写的是什么(透传与转义不算,属性已展开,
562
- // 指向别的文档的另记 targetDocument)也由 aat 里的 Asciidoctor 读出;这里只读投影里每条 xref 边的结果。
558
+ // 书内 xref 落在哪一节,读 aat 的投影(DESIGN.adoc 的 Reference Binding):哪些是 xref、目标写的是什么(透传与转义
559
+ // 不算,属性已展开,指向别的文档的另记 targetDocument)由 aat 里的 Asciidoctor 读出,一处引用对上几节就有几条
560
+ // targetHeading。链接只能跳到一处,所以恰好一节才改写;零节或几节保持 Asciidoctor 写下的样子,不替人挑,也不报:
561
+ // 引用对不对是投影的读者(bookgraph)的解释,不是渲染的事(DESIGN.adoc 的 Division of Labour)。
563
562
  function bindReferences(config, document) {
564
563
  const projection = rdf12(document, { documentRoot: config.documentRoot });
565
564
  const graph = projection.graph;
@@ -567,65 +566,53 @@ function bindReferences(config, document) {
567
566
  return graph.match({ subject, predicate: { termType: "iri", value: `${AAT_NAMESPACE}${name}` } })
568
567
  .map((triple) => triple.object.termType === "triple" ? "" : triple.object.value);
569
568
  };
570
- const headings = new Map(projection.nodeIndex.entries()
569
+ // 只数节:书名也是图上的一个标题节点、也拥有标签,可它不是书里的一节,网页上没有落点(nodeIndex 里它不是 section)。
570
+ const sections = new Map(projection.nodeIndex.entries()
571
571
  .filter((entry) => entry.kind === "section")
572
572
  .map((entry) => [entry.iri.value, entry]));
573
- const located = (iris) => iris.flatMap((iri) => {
574
- const entry = headings.get(iri);
573
+ const located = (iris) => [...iris].flatMap((iri) => {
574
+ const entry = sections.get(iri);
575
575
  const id = entry?.node.ids?.[0];
576
576
  if (entry === undefined || id === undefined)
577
577
  return [];
578
- return [{ id, title: entry.node.title ?? id, relativePath: entry.relativePath ?? "", line: entry.sourceStartLine ?? entry.startLine }];
578
+ return [{ id, title: entry.node.title ?? id }];
579
579
  });
580
- const owners = (selector) => {
581
- return located(projection.labelCatalog.owners(selector).map((owner) => owner.value));
582
- };
583
- // 指向整本书的 xref(没有片段)没有 targetSelector,不在这里:它没有可绑定的东西,留给 HTML 本地资源检查。
584
- const edges = graph.match({ predicate: { termType: "iri", value: `${AAT_NAMESPACE}targetSelector` } }).map((triple) => ({
585
- iri: triple.subject,
586
- selector: triple.object.termType === "triple" ? "" : triple.object.value,
587
- document: values(triple.subject, "targetDocument")[0],
588
- relativePath: values(triple.subject, "relativePath")[0] ?? "",
589
- line: Number(values(triple.subject, "startLine")[0] ?? 0),
590
- column: Number(values(triple.subject, "startColumn")[0] ?? 0)
591
- })).sort((left, right) => left.relativePath.localeCompare(right.relativePath) || left.line - right.line || left.column - right.column);
580
+ // 别的书指过来的引用在这本书的 label space 里找:图上四个标签谓词的值,按整串相等找拥有者。
581
+ const ownersByLabel = new Map();
582
+ for (const name of LABEL_PREDICATES) {
583
+ for (const triple of graph.match({ predicate: { termType: "iri", value: `${AAT_NAMESPACE}${name}` } })) {
584
+ if (triple.object.termType !== "literal")
585
+ continue;
586
+ const iris = ownersByLabel.get(triple.object.value) ?? new Set();
587
+ iris.add(triple.subject.value);
588
+ ownersByLabel.set(triple.object.value, iris);
589
+ }
590
+ }
591
+ const owners = (selector) => located(ownersByLabel.get(selector) ?? []);
592
592
  const headingsByFragment = new Map();
593
593
  const crossBook = [];
594
- const issues = [];
595
- for (const edge of edges) {
596
- const where = `${edge.relativePath}:${edge.line} ${values(edge.iri, "raw")[0] ?? edge.selector}`;
597
- if (edge.document !== undefined) {
598
- crossBook.push({ where, document: edge.document, fragment: edge.selector });
594
+ // 指向整本书的 xref(没有片段)没有 targetSelector:没有可改写的片段。
595
+ for (const triple of graph.match({ predicate: { termType: "iri", value: `${AAT_NAMESPACE}targetSelector` } })) {
596
+ const edge = triple.subject;
597
+ const fragment = triple.object.termType === "triple" ? "" : triple.object.value;
598
+ const document = values(edge, "targetDocument")[0];
599
+ if (document !== undefined) {
600
+ crossBook.push({ document, fragment });
599
601
  continue;
600
602
  }
601
- const fragment = edge.selector;
602
- const found = located([...values(edge.iri, "targetHeading"), ...values(edge.iri, "candidateHeading")]);
603
- if (found.length === 1) {
604
- const heading = { id: found[0].id, title: found[0].title };
605
- headingsByFragment.set(fragment, heading);
606
- headingsByFragment.set(escapeHtmlAttribute(fragment), heading);
603
+ const found = located(values(edge, "targetHeading"));
604
+ if (found.length !== 1)
607
605
  continue;
608
- }
609
- if (found.length > 1) {
610
- issues.push(issue("XREF_AMBIGUOUS", `${where} -> ${describeCandidates(found)}`));
611
- continue;
612
- }
613
- issues.push(issue("XREF_UNRESOLVED", where));
606
+ headingsByFragment.set(fragment, found[0]);
607
+ headingsByFragment.set(escapeHtmlAttribute(fragment), found[0]);
614
608
  }
615
- return { headingsByFragment, crossBook, owners, issues };
609
+ return { headingsByFragment, crossBook, owners };
616
610
  }
617
- function describeCandidates(headings) {
618
- return [...headings]
619
- .sort((left, right) => left.relativePath.localeCompare(right.relativePath) || left.line - right.line)
620
- .map((heading) => `${heading.title} (${heading.relativePath}:${heading.line})`)
621
- .join(", ");
622
- }
623
- // 跨书 xref 同样以 aat 的绑定为准,只是 label space 在目标书里(DESIGN.adoc 的 Reference Binding)。aat 不打开别的
624
- // 文档,工作区解析了每一本书,所以在这里绑定。路径按书目录解析:Asciidoctor 把它照写进 HTML,而 HTML 的输出目录
625
- // 与 books/ 下的书目录一一对应。指向的不是工作区里的书、或只指向整本书(没有片段)的,留给 HTML 本地资源检查。
611
+ // 跨书 xref 同样跟着投影走,只是 label space 在目标书里(DESIGN.adoc 的 Reference Binding)。aat 不打开别的文档,
612
+ // 工作区解析了每一本书,所以在这里找。路径按书目录解析:Asciidoctor 把它照写进 HTML,而 HTML 的输出目录与 books/ 下的
613
+ // 书目录一一对应。指向的不是工作区里的书、只指向整本书(没有片段)、或在目标书里对上零节或几节的,保持原样。
626
614
  async function bindCrossBookReferences(book, references, bindingsByInput, anchorCache) {
627
615
  const linksByHref = new Map();
628
- const issues = [];
629
616
  for (const xref of references.crossBook) {
630
617
  const { fragment } = xref;
631
618
  // Asciidoctor 把 xref:other#x[] 与 xref:other.adoc#x[] 都读成文档 other:去掉 .adoc、没有扩展名的照写,
@@ -635,14 +622,8 @@ async function bindCrossBookReferences(book, references, bindingsByInput, anchor
635
622
  if (fragment === "" || target === undefined)
636
623
  continue;
637
624
  const found = target.references.owners(fragment);
638
- if (found.length === 0) {
639
- issues.push(issue("XREF_UNRESOLVED", `${xref.where} -> ${target.book.bookId}`));
625
+ if (found.length !== 1)
640
626
  continue;
641
- }
642
- if (found.length > 1) {
643
- issues.push(issue("XREF_AMBIGUOUS", `${xref.where} -> ${target.book.bookId}: ${describeCandidates(found)}`));
644
- continue;
645
- }
646
627
  const heading = found[0];
647
628
  // 指向本书自己的 book.adoc:Asciidoctor 把它当书内引用,写成 #片段。
648
629
  if (target.book.bookId === book.bookId) {
@@ -658,9 +639,9 @@ async function bindCrossBookReferences(book, references, bindingsByInput, anchor
658
639
  linksByHref.set(`${htmlPath}#${fragment}`, link);
659
640
  linksByHref.set(escapeHtmlAttribute(`${htmlPath}#${fragment}`), link);
660
641
  }
661
- return { linksByHref, issues };
642
+ return linksByHref;
662
643
  }
663
- // 绑定上、而目标书的 HTML 里没有这个片段的跨书链接,改成指向绑定的标题。Asciidoctor 对没写显示文字的跨书 xref
644
+ // 在目标书里恰好对上一节、而目标书的 HTML 里没有这个片段的跨书链接,改成指向那一节的标题。Asciidoctor 对没写显示文字的跨书 xref
664
645
  // 显示文档路径;改写时换成标题。
665
646
  function rewriteCrossBookXrefs(html, linksByHref) {
666
647
  if (linksByHref.size === 0)
@@ -673,7 +654,7 @@ function rewriteCrossBookXrefs(html, linksByHref) {
673
654
  return `<a href="${escapeHtmlAttribute(link.href)}"${rest}>${shown}</a>`;
674
655
  });
675
656
  }
676
- // Asciidoctor 把它解析不了的书内 xref 写成 <a href="#选择器">[选择器]</a>;aat 绑定上的,改成指向绑定的标题,
657
+ // Asciidoctor 把它解析不了的书内 xref 写成 <a href="#选择器">[选择器]</a>;aat 的投影里恰好对上一节的,改成指向那一节的标题,
677
658
  // 显示标题。片段已经是 HTML 里某个元素的 id 时,保持 Asciidoctor 写下的样子。
678
659
  function rewriteBoundXrefs(html, headingsByFragment) {
679
660
  if (headingsByFragment.size === 0)
@@ -1205,24 +1186,21 @@ ${readerStyles()}
1205
1186
  }
1206
1187
  async function addHomeLinks(rootDir, books, config) {
1207
1188
  const catalog = path.join(rootDir, "build", "html", "catalog.html");
1208
- // 跨书 xref 在目标书的 label space 里绑定,所以先把每一本书都解析完。
1189
+ // 跨书 xref 在目标书的 label space 里找,所以先把每一本书都解析完。
1209
1190
  const readerData = new Map();
1210
1191
  for (const book of books) {
1211
1192
  readerData.set(book.bookId, await buildReaderBookData(config, book));
1212
1193
  }
1213
1194
  const bindingsByInput = new Map(books.map((book) => [book.input, { book, references: readerData.get(book.bookId).references }]));
1214
1195
  const anchorCache = new Map();
1215
- const issues = [];
1216
1196
  for (const book of books) {
1217
1197
  const htmlFile = path.join(book.htmlOutputDir, "book.html");
1218
1198
  const href = path.relative(path.dirname(htmlFile), catalog);
1219
1199
  const data = readerData.get(book.bookId);
1220
- const crossBook = await bindCrossBookReferences(book, data.references, bindingsByInput, anchorCache);
1221
- issues.push(...data.references.issues, ...crossBook.issues);
1222
- const html = rewriteCrossBookXrefs(rewriteBoundXrefs(await readFile(htmlFile, "utf8"), data.references.headingsByFragment), crossBook.linksByHref);
1200
+ const crossBookLinks = await bindCrossBookReferences(book, data.references, bindingsByInput, anchorCache);
1201
+ const html = rewriteCrossBookXrefs(rewriteBoundXrefs(await readFile(htmlFile, "utf8"), data.references.headingsByFragment), crossBookLinks);
1223
1202
  await writeFile(htmlFile, addBookControlsToBookHtml(html, href, config.homeLink, data.sourceBundle, data.pageMap, book.bookId), "utf8");
1224
1203
  }
1225
- return issues;
1226
1204
  }
1227
1205
  async function writeRootIndex(rootDir, rootIndex) {
1228
1206
  const outputDir = path.join(rootDir, "build", "html");
@@ -1241,55 +1219,6 @@ async function writeRootIndex(rootDir, rootIndex) {
1241
1219
  </html>
1242
1220
  `, "utf8");
1243
1221
  }
1244
- function extractCatalogBookIds(catalogSource) {
1245
- const ids = new Set();
1246
- for (const match of catalogSource.matchAll(CATALOG_BOOK_XREF_PATTERN))
1247
- ids.add(match[1]);
1248
- return [...ids].sort((a, b) => a.localeCompare(b));
1249
- }
1250
- function issue(code, detail) {
1251
- return { code, detail };
1252
- }
1253
- // xref 不在这里查:书内与跨书的引用以 aat 的绑定为准(bindReferences、bindCrossBookReferences),不存在的书与其余
1254
- // 锚点由 missingLocalResources 在生成的 HTML 上核对(DESIGN.adoc 的 Workspace Contract Checks 与 Reference Binding)。
1255
- async function workspaceContractIssues(rootDir, books) {
1256
- const issues = [];
1257
- const catalogSource = await readIfExists(path.join(rootDir, "catalog.adoc"));
1258
- const catalogBookIds = extractCatalogBookIds(catalogSource);
1259
- const bookSet = new Set(books.map((book) => book.bookId));
1260
- const catalogBookSet = new Set(catalogBookIds);
1261
- for (const bookId of catalogBookIds) {
1262
- if (!bookSet.has(bookId))
1263
- issues.push(issue("CATALOG_TARGET_MISSING", bookId));
1264
- }
1265
- for (const book of books) {
1266
- if (!catalogBookSet.has(book.bookId))
1267
- issues.push(issue("BOOK_MISSING_FROM_CATALOG", book.bookId));
1268
- }
1269
- for (const book of books) {
1270
- const bookSource = await readFile(book.input, "utf8");
1271
- if (!/^:doctype:\s+book$/m.test(bookSource))
1272
- issues.push(issue("MISSING_DOCTYPE", book.bookId));
1273
- }
1274
- return issues;
1275
- }
1276
- async function collectHtmlFiles(dir) {
1277
- return collectFiles(dir, (filePath) => filePath.endsWith(".html"));
1278
- }
1279
- function extractLocalTargets(html) {
1280
- const targets = [];
1281
- for (const match of html.matchAll(LOCAL_TARGET_PATTERN)) {
1282
- const [, attribute, rawTarget] = match;
1283
- if (rawTarget === "" || rawTarget === "#" || rawTarget.startsWith("//") || SCHEME_PATTERN.test(rawTarget)) {
1284
- continue;
1285
- }
1286
- const targetWithoutFragment = rawTarget.split("#", 1)[0].split("?", 1)[0];
1287
- const fragment = rawTarget.includes("#") ? rawTarget.split("#", 2)[1].split("?", 1)[0] : "";
1288
- if (targetWithoutFragment !== "" || fragment !== "")
1289
- targets.push({ attribute, rawTarget });
1290
- }
1291
- return targets;
1292
- }
1293
1222
  function decodeUrlFragment(fragment) {
1294
1223
  try {
1295
1224
  return decodeURIComponent(fragment);
@@ -1312,39 +1241,6 @@ async function anchorsForHtmlFile(filePath, cache) {
1312
1241
  cache.set(filePath, anchors);
1313
1242
  return anchors;
1314
1243
  }
1315
- async function missingLocalResources(rootDir) {
1316
- const htmlDir = path.join(rootDir, "build", "html");
1317
- const htmlFiles = await collectHtmlFiles(htmlDir);
1318
- const issues = [];
1319
- const anchorCache = new Map();
1320
- for (const htmlFile of htmlFiles) {
1321
- const html = await readFile(htmlFile, "utf8");
1322
- for (const target of extractLocalTargets(html)) {
1323
- const targetPath = target.rawTarget.split("#", 1)[0].split("?", 1)[0];
1324
- const fragment = target.rawTarget.includes("#") ? target.rawTarget.split("#", 2)[1].split("?", 1)[0] : "";
1325
- const resolved = targetPath === "" ? htmlFile : path.resolve(path.dirname(htmlFile), targetPath);
1326
- if (targetPath !== "" && !await existsFile(resolved)) {
1327
- issues.push(issue("HTML_RESOURCE_MISSING", `${path.relative(htmlDir, htmlFile)} -> ${target.rawTarget}`));
1328
- continue;
1329
- }
1330
- if (target.attribute === "href" && fragment !== "" && (targetPath === "" || resolved.endsWith(".html"))) {
1331
- const anchors = await anchorsForHtmlFile(resolved, anchorCache);
1332
- const decodedFragment = decodeUrlFragment(fragment);
1333
- if (!anchors.has(fragment) && !anchors.has(decodedFragment)) {
1334
- issues.push(issue("HTML_ANCHOR_MISSING", `${path.relative(htmlDir, htmlFile)} -> ${target.rawTarget}`));
1335
- }
1336
- }
1337
- }
1338
- }
1339
- return issues;
1340
- }
1341
- async function assertNoIssues(label, issues) {
1342
- if (issues.length === 0)
1343
- return;
1344
- for (const entry of issues)
1345
- console.error(`${entry.code}: ${entry.detail}`);
1346
- throw new Error(`${label} failed with ${issues.length} issue(s)`);
1347
- }
1348
1244
  export async function buildWorkspace(rootDir = process.cwd()) {
1349
1245
  const catalog = path.join(rootDir, "catalog.adoc");
1350
1246
  if (!await existsFile(catalog))
@@ -1358,14 +1254,8 @@ export async function buildWorkspace(rootDir = process.cwd()) {
1358
1254
  const asciidoctor = createAsciidoctor(useKroki);
1359
1255
  await buildHtml(rootDir, books, asciidoctor, includeResolverRegistry(asciidoctor, config), useKroki, fetchDiagrams);
1360
1256
  await copyAssets(rootDir, books);
1361
- const referenceIssues = await addHomeLinks(rootDir, books, config);
1257
+ await addHomeLinks(rootDir, books, config);
1362
1258
  await writeRootIndex(rootDir, config.rootIndex);
1363
- await assertNoIssues("reference binding check", referenceIssues);
1364
- await assertNoIssues("HTML local resource check", await missingLocalResources(rootDir));
1365
- await assertNoIssues("workspace contract check", await workspaceContractIssues(rootDir, books));
1366
- }
1367
- export async function checkWorkspace(rootDir = process.cwd()) {
1368
- await buildWorkspace(rootDir);
1369
1259
  }
1370
1260
  export async function cleanWorkspace(rootDir = process.cwd()) {
1371
1261
  await rm(path.join(rootDir, "build"), { force: true, recursive: true });
@@ -1376,15 +1266,11 @@ async function main() {
1376
1266
  await buildWorkspace();
1377
1267
  return;
1378
1268
  }
1379
- if (command === "check") {
1380
- await checkWorkspace();
1381
- return;
1382
- }
1383
1269
  if (command === "clean") {
1384
1270
  await cleanWorkspace();
1385
1271
  return;
1386
1272
  }
1387
- console.error("Usage: node tools/adoc-books.mjs <build|check|clean>");
1273
+ console.error("Usage: node tools/adoc-books.mjs <build|clean>");
1388
1274
  process.exitCode = 1;
1389
1275
  }
1390
1276
  const executedPath = process.argv[1] ? path.resolve(process.argv[1]) : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-asciidoc-multi-book-workspace",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Create a complete AsciiDoc multi-book workspace.",
5
5
  "license": "Apache-2.0",
6
6
  "author": {
@@ -54,7 +54,7 @@
54
54
  "devDependencies": {
55
55
  "@asciidoctor/core": "^3.0.4",
56
56
  "@types/node": "^24.10.1",
57
- "asciidoc-abundant-tree": "^0.3.0",
57
+ "asciidoc-abundant-tree": "link:../asciidoc-abundant-tree",
58
58
  "asciidoctor-kroki": "^0.18.1",
59
59
  "playwright": "^1.59.1",
60
60
  "typescript": "^5.9.3"
@@ -29,13 +29,11 @@ To fetch diagram images into the local HTML output, run:
29
29
  ADOC_BOOKS_FETCH_DIAGRAMS=1 pnpm run build
30
30
  ```
31
31
 
32
- ## Check
32
+ ## When The Build Fails
33
33
 
34
- ```bash
35
- pnpm run check
36
- ```
34
+ The build fails only when it cannot produce the pages: Asciidoctor reports a message while rendering, a book's includes leave `documentRoot` (see Configure), or an include resolver returns something the build cannot use.
37
35
 
38
- This checks the current catalog, existing books, book doctypes, cross-book xrefs, anchors, and local HTML resources, and that every book's includes stay within `documentRoot` (see Configure).
36
+ It does not judge your books. Whether an xref reaches the heading you meant, whether a local link resolves, and whether `catalog.adoc` lists every book are left to tools that read the RDF projection `asciidoc-abundant-tree` makes of each book. An xref whose address one heading owns links to that heading; one that no heading owns, or several do, keeps the link Asciidoctor wrote, and the build still succeeds.
39
37
 
40
38
  ## Configure
41
39
 
@@ -52,7 +50,7 @@ export default {
52
50
  };
53
51
  ```
54
52
 
55
- `documentRoot` matters when this workspace lives inside a project repository. Set it to the project root (`".."` when the workspace is a direct subdirectory) and books can include the project's own code instead of a copy of it. Source paths in the reader and the `// file:` markers of copied source then start at that root. It must contain this workspace. An include that leaves `documentRoot` fails `pnpm run check` and names the book.
53
+ `documentRoot` matters when this workspace lives inside a project repository. Set it to the project root (`".."` when the workspace is a direct subdirectory) and books can include the project's own code instead of a copy of it. Source paths in the reader and the `// file:` markers of copied source then start at that root. It must contain this workspace. An include that leaves `documentRoot` fails the build and names the book.
56
54
 
57
55
  `includeResolvers` lets books include lines chosen by your own code, for example one function out of a source file:
58
56
 
@@ -95,7 +93,7 @@ Delete `books/<book-id>/`, then remove the matching `xref:books/<book-id>/book.a
95
93
  Run:
96
94
 
97
95
  ```bash
98
- pnpm run check
96
+ pnpm run build
99
97
  ```
100
98
 
101
99
  ## Add A Book
@@ -4,5 +4,4 @@ book:: 具有独立 `book.adoc` 和构建产物的书籍单位。
4
4
  part:: 同一本 book 内部的 level 0 大分部。
5
5
  catalog:: 多书工作区的读者导航入口。
6
6
  shared resource:: 被 catalog 或多本书共同引用的资源。
7
- custom ID:: 用 `[#id]` 给标题加的另一个地址。标题就是地址;同名的标题写上级标题、以 `::` 隔开来引用,同一个上级下也同名或标题里有 `#`、`::` 时,才给它再加一个。
8
- contract check:: 对工作区源文件约定进行静态检查的脚本行为。
7
+ custom ID:: 用 `[#id]` 给标题加的另一个地址。标题就是地址;同名的标题写上级标题、以 `::` 隔开来引用,同一个上级下也同名、标题里有 `#`、`::` 或标题以行内标记开头时,才给它再加一个。
@@ -16,4 +16,4 @@ xref + : + ../other-book/book.adoc#目标章节[]
16
16
 
17
17
  上面的写法用分段文本展示,避免默认样本之间形成真实链接依赖。实际写作时去掉空格和加号,写成一个完整的 cross reference 宏。
18
18
 
19
- 被跨书引用的标题不需要另写 anchor:标题就是地址,跨书与书内一样。目标标题改了名,`pnpm run check` 会指出每一处没有跟上的引用。
19
+ 被跨书引用的标题不需要另写 anchor:标题就是地址,跨书与书内一样。目标标题改了名,在引用它的书里搜一遍旧名字,就能找到每一处没有跟上的引用。
@@ -1,3 +1,3 @@
1
1
  == 上册核心模型
2
2
 
3
- 本章提供下册引用的核心模型。下册用本章的标题引用它;本章改了名,`pnpm run check` 会指出下册里每一处没有跟上的引用。
3
+ 本章提供下册引用的核心模型。下册用本章的标题引用它;本章改了名,在下册里搜一遍旧名字,就能找到每一处没有跟上的引用。
@@ -44,20 +44,20 @@
44
44
  [source,text]
45
45
  ----
46
46
  1. 只是换一个更准确的名字吗?
47
- ├─ 是:改标题。工作区的检查会逐处报出还写着旧名字的引用,按报告逐处改成新名字。
48
- │ 显示文本和正文里的旧名字检查报不出,在书里搜一遍旧名字。
47
+ ├─ 是:改标题。在书里搜一遍旧名字,找到每一处引用,连同显示文本和正文里的
48
+ │ 旧名字,逐处改成新名字。
49
49
  │ 旧名字值得让读者查到时,把它写进别名。结束。
50
50
  └─ 否:看第 2 步。
51
51
 
52
52
  2. 这一节收的范围变了吗?
53
53
  ├─ 是:改标题,并重读这一节的说明、收什么与不收什么、相关的节和已经写下的结论。
54
- │ 对检查报出的每一处旧引用,先判断它说的是留下的部分还是去掉的部分,
54
+ │ 对搜旧名字找到的每一处引用,先判断它说的是留下的部分还是去掉的部分,
55
55
  │ 再决定改成新名字,还是改指别的一节。看第 3 步。
56
56
  └─ 否:看第 3 步。
57
57
 
58
58
  3. 这一节被拆开,或不再使用了吗?
59
59
  ├─ 是:不留它。删掉它,或把它改成拆出来的某一节的名字。
60
- │ 检查会逐处报出旧引用,逐一判断每一处该改指哪一节。结束。
60
+ │ 搜一遍旧名字找到旧引用,逐一判断每一处该改指哪一节。结束。
61
61
  └─ 否:结束。
62
62
  ----
63
63
 
@@ -34,35 +34,36 @@ asciidoc-abundant-tree 把这份源文本投影成 RDF。下面摘出与这些
34
34
 
35
35
  [source,turtle]
36
36
  ----
37
- <#炒糖色> a aat:Heading ;
37
+ :heading-2 a aat:Heading ;
38
38
  aat:headline "炒糖色" ;
39
39
  aat:role "technique" .
40
40
 
41
- <#红烧肉> a aat:Heading ;
41
+ :heading-3 a aat:Heading ;
42
42
  aat:headline "红烧肉" ;
43
43
  aat:role "dish" ;
44
- aat:containsDirectly <#火候> ;
45
- rel:depends-on <#炒糖色> .
44
+ aat:containsDirectly :heading-4 ;
45
+ rel:depends-on :heading-2 .
46
46
 
47
- <#火候> a aat:Heading ;
48
- aat:headline "火候" .
47
+ :heading-4 a aat:Heading ;
48
+ aat:headline "火候" ;
49
+ aat:addressPath "红烧肉::火候" , "家常菜谱::红烧肉::火候" .
49
50
 
50
- <#红烧排骨> a aat:Heading ;
51
+ :heading-5 a aat:Heading ;
51
52
  aat:headline "红烧排骨" ;
52
53
  aat:role "dish" ;
53
- aat:references <#红烧肉> .
54
+ aat:references :heading-3 .
54
55
 
55
- <…/projection#xref-edge-l11-c15-o0> a aat:XrefEdge ;
56
+ :xref-edge-l11-c15-o0 a aat:XrefEdge ;
56
57
  aat:targetSelector "炒糖色" ;
57
- aat:targetHeading <#炒糖色> ;
58
+ aat:targetHeading :heading-2 ;
58
59
  aat:rel "depends-on" .
59
60
  ----
60
61
 
61
- 标题节点以它的地址命名:`<#炒糖色>` 是这份源文件里地址为“炒糖色”的那一节。几个标题同名时,节点名用限定地址,例如三道菜都有《火候》时,红烧肉下的那一节是 `<#红烧肉::火候>`(见《xref:标题、寻址与引用[]》)。
62
+ `:` 代表这份源文件。标题节点的名字由投影器按标题在书里的先后起:书名是 `:heading-1`,《炒糖色》是第二个标题,所以是 `:heading-2`。前面添一个标题,后面每个节点的名字都变,因此节点名只在这一次投影里指称那一节。标题原文和地址作为值记在节点上:标题原文记在 `aat:headline`,补上上级标题的限定地址记在 `aat:addressPath`,例如《火候》的 `红烧肉::火候`,作者写的 custom ID 记在 `aat:addressLabel`。工具认一节,按这些值去找,不读节点名(见《xref:标题、寻址与引用[]》)。
62
63
 
63
- 每个标题的原文记在 `aat:headline`,role 记在 `aat:role`,上下级记成 `aat:containsDirectly`。普通 xref 投影为 `aat:references`;写了 `rel` 的 xref 投影为以谓词命名的边,这里是 `rel:depends-on`。每条 xref 另有一个节点(`<…>` 处省去了它名字前面的文件部分),记下源文本里写的目标 `aat:targetSelector` 和它找到的标题 `aat:targetHeading`。引用写的是标题原文,所以找到的就是那个标题。
64
+ role 记在 `aat:role`,上下级记成 `aat:containsDirectly`。普通 xref 投影为 `aat:references`;写了 `rel` 的 xref 投影为以谓词命名的边,这里是 `rel:depends-on`。每条 xref 另有一个节点,记下源文本里写的目标 `aat:targetSelector` 和它找到的标题 `aat:targetHeading`。引用写的是标题原文,所以找到的就是那个标题。一处引用的目标有几节拥有,就记几条 `aat:targetHeading`,投影不替作者挑。
64
65
 
65
- 写书的人不必自己运行投影。工作区的构建核对引用时读的是这张图,查询工具按 role 列出标题时读的也是这张图。源文本怎样写,决定了它们能从书里读出什么。
66
+ 写书的人不必自己运行投影。工作区的构建把网页上的链接接到哪一节,读的是这张图;读投影的工具检查引用、按 role 列出标题,读的也是这张图。源文本怎样写,决定了它们能从书里读出什么。
66
67
 
67
68
  `rel` 用哪些谓词,由书自己定。如果菜谱要表明一道菜由另一道菜改良而来,就先在自己的关系谓词声明里加上这个谓词,再在 xref 里使用它:
68
69
 
@@ -30,9 +30,9 @@
30
30
 
31
31
  Asciidoctor 还会从标题生成一个 ID,生成时去掉标点,同名的按出现的先后加上 `-2`、`-3`。这个 ID 随标题和标题的位置变化,因此引用不应写它。
32
32
 
33
- 工作区的构建(`pnpm run build` 与 `pnpm run check`)按地址核对每一处引用:找不到目标,或目标不止一个,就报出引用所在的文件与行;找到了,就把 HTML 里的链接接到那个标题上。单独用 Asciidoctor 或编辑器预览时情形不同。Asciidoctor 把不含空格的引用目标当作 ID 去找,而它生成的 ID 去掉了标点,因此目标不含空格、却带顿号或冒号的引用,以及下面要讲的限定地址,在预览里是断的。这时源文件并没有写错,应以工作区的构建为准。
33
+ 工作区的构建(`pnpm run build`)按地址把网页上的链接接到那一节。引用找不到目标时,构建不改它,网页上这条链接是断的,显示成方括号里的地址。目标不止一节时,一条链接只能跳到一处,构建也不替作者挑,链接照 Asciidoctor 自己的规则走:几节同名,它落到书里最前面的那一节。这样的链接看上去好好的,却可能落到作者不想指的那一节,比断链更难发现。单独用 Asciidoctor 或编辑器预览时,链接都照 Asciidoctor 自己的规则走:它把不含空格的引用目标当作 ID 去找,而它生成的 ID 去掉了标点,因此目标不含空格、却带顿号或冒号的引用,以及下面要讲的限定地址,在预览里是断的。这时源文件并没有写错,应以工作区的构建为准。
34
34
 
35
- 写完以后,在工作区的根目录运行 `pnpm run check`。它核对三件事:每一处引用都找到了唯一的目标;include 进来的文件都在工作区里;HTML 里引用的图片等资源都在。role、字段和字段的取值应照凡例里的声明写,这几项由作者自己核对。
35
+ 构建只在做不出页面时失败,例如 include 进来的文件不在工作区里。引用找不到目标或目标不止一节,构建照常完成;哪些引用找不到目标、哪些对上了几节,由读投影的工具检查,它读的就是《xref:源文本与投影[]》里的那张图。role、字段和字段的取值应照凡例里的声明写。
36
36
 
37
37
  标题会重名。按类别组织的书,各个大标题下常有名字相同的小节:
38
38
 
@@ -51,7 +51,7 @@ Asciidoctor 还会从标题生成一个 ID,生成时去掉标点,同名的
51
51
  === 火候
52
52
  ----
53
53
 
54
- 三节《火候》各讲一道菜,一般不会被单独引用。某一处要指向《红烧肉》下的《火候》时,`pass:[xref:火候[]]` 有三个候选,检查会报出引用所在的位置和三节各自的位置。这时应把上级标题写在前面,用 `::` 隔开:
54
+ 三节《火候》各讲一道菜,一般不会被单独引用。某一处要指向《红烧肉》下的《火候》时,`pass:[xref:火候[]]` 对上三节,网页上的链接落到书里最前面的那一节,要指的若是另外两节,就落错了。读投影的工具会把这处引用连同三节各自的位置报出来。这时应把上级标题写在前面,用 `::` 隔开:
55
55
 
56
56
  [source,asciidoc]
57
57
  ----
@@ -69,16 +69,16 @@ Asciidoctor 还会从标题生成一个 ID,生成时去掉标点,同名的
69
69
 
70
70
  同一个上级下有两个同名的标题时,完整的路径也分不开它们,例如糖醋排骨要炸两遍,《糖醋排骨》下写了两节《炸》。两节同名,多半是标题没有说清这一节讲什么。这时宜先改标题,例如改成《初炸》与《复炸》,改好以后,两节的标题各是各的地址。
71
71
 
72
- 标题改不了时,或者标题原文里带 `#` 或 `::`(引用会把它们当作分隔)时,标题和限定地址都指不出唯一的一节。要引用这样的一节,应在它的标题上方加 custom ID(`[#…]`),引用写这个 ID。加 custom ID 时,应符合下列规定:
72
+ 标题改不了时,标题和限定地址都指不出唯一的一节。还有两种标题,按原文写不出引用:标题原文里带 `#` 或 `::` 的,引用会把它们当作分隔;标题以行内标记开头的,例如 `+`book.adoc` 的职责+`,AsciiDoc 不把以反引号、星号这类标记起头的目标读成引用。要引用这样的一节,应在它的标题上方加 custom ID(`[#…]`),引用写这个 ID。加 custom ID 时,应符合下列规定:
73
73
 
74
74
  * 名字说清这一节讲什么,不写序号和位置词,例如不写 `炸-2`、`第二个炸`,因为序号和位置会随编排变化;
75
- * 名字不与书里的任何标题相同,否则这个地址有两个候选,检查会报出来;
75
+ * 名字不与书里的任何标题相同。例如给《卤味》下讲糖色的一节加了 `[#炒糖色]`,书里又有一节《炒糖色》,这个地址就对上两节;Asciidoctor 先按 ID 去找,网页上 `pass:[xref:炒糖色[]]` 落到加了 ID 的那一节,原来指《炒糖色》的引用便不声不响地指到了别处;
76
76
  * 同一个上级下同名的几节,要引用哪一节,就给哪一节加;只给其中一节加时,另一节的标题和限定地址仍然有两个候选;
77
77
  * 引用写上显示文本,写出能让读者分清是哪一节的说法。
78
78
 
79
79
  custom ID 是同一个标题的第二个地址,不替代标题。作者是在要引用某一节时才发现标题当不了地址的,custom ID 也宜在那时再加,不必预先写下。
80
80
 
81
- 标题改名以后,检查会逐处报出仍写着旧名字的引用,限定地址里写着旧名字的也一样,因为书的全部源文件都在同一个工作区里,每一处引用都在检查的范围之内,作者依报告逐处改正即可。检查核对的是引用的地址,显示文本和正文里写着的旧名字,检查报不出,所以改名时还应在书里搜一遍旧名字。预先写下的 ID 会让旧引用在改名后照常找到目标,这些引用便躲过了检查,ID 里也留下了旧的说法。因此,标题当得了地址时,不应写 custom ID。
81
+ 标题改名以后,按旧名字写的引用都找不到目标,网页上的这些链接是断的。按旧名字写的引用,地址里都含着旧名字,限定地址也一样,因此改名时在书里搜一遍旧名字,就能找到每一处引用,连同显示文本和正文里写着的旧名字,作者逐处改正即可。预先写下的 ID 会让旧引用在改名后照常落到这一节:这些引用写的是 ID,里面没有旧名字,搜旧名字搜不到它们,可这一节讲的可能已不是它们当初要指的事,ID 里也留下了旧的说法。因此,标题当得了地址时,不应写 custom ID。
82
82
 
83
83
  role 只属于写了它的那个标题。希望查询工具把某个下级标题当作某种 role 列出时,应在它自己的标题上写这个 role。没有写 role 的下级标题只是父标题下的一节,读者按上下文理解它。
84
84
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  标题是一节现在的名字。读者读到的是它,引用写的也是它,因此标题就是这一节的地址,写法见《xref:标题、寻址与引用[]》。
4
4
 
5
- 作者想到更准确的名字时,先改标题,再改引用。标题改好以后,工作区的检查会逐处报出仍写着旧名字的引用,作者照报告逐处改成新名字。显示文本和正文里写着的旧名字,检查报不出,作者还应在书里搜一遍旧名字。
5
+ 作者想到更准确的名字时,先改标题,再改引用。标题改好以后,按旧名字写的引用都找不到目标。它们的地址里都含着旧名字,作者在书里搜一遍旧名字,就能找到每一处引用,连同显示文本和正文里写着的旧名字,逐处改成新名字。
6
6
 
7
7
  一样东西还有别的叫法、又值得让读者查到时,应把别的叫法写进标题下的别名字段,正文只用标题这一个名字(见《xref:一样东西一个名字[]》)。例如:
8
8
 
@@ -23,13 +23,13 @@
23
23
  别名:: 凉拌与腌制(旧名,那时腌制也收在这一节)
24
24
  ----
25
25
 
26
- 这时作者除了改标题,还应重读这一节的说明、收录与不收录的内容、相关的节和已经写下的结论。对检查报出的每一处旧引用,作者先判断它说的是留下的凉拌,还是去掉的腌制,再决定改成《凉拌菜》,还是改指讲腌制的那一节。
26
+ 这时作者除了改标题,还应重读这一节的说明、收录与不收录的内容、相关的节和已经写下的结论。对搜旧名字找到的每一处引用,作者先判断它说的是留下的凉拌,还是去掉的腌制,再决定改成《凉拌菜》,还是改指讲腌制的那一节。
27
27
 
28
28
  改名、拆分或停用一节时,应符合下列规定:
29
29
 
30
30
  * 不为旧标题保留一节“指路”,旧名字写进别名;
31
31
  * 一节被拆开或不再使用时,删掉它,或把它改成拆出来的某一节的名字。
32
32
 
33
- 如果保留旧标题,旧引用仍能找到它,检查便报不出这些引用。删掉或改名以后,检查会逐处报出旧引用,作者再逐一判断每一处该改指哪一节。这一节从前的样子留在版本库的历史里。
33
+ 如果保留旧标题,旧引用仍能落到它,网页上的链接不断,作者也就不会回头看这些引用。删掉或改名以后,旧引用的链接断开,搜一遍旧名字就找到它们,作者再逐一判断每一处该改指哪一节。这一节从前的样子留在版本库的历史里。
34
34
 
35
35
  custom ID 不替代标题,也不为旧名字保留位置。重名的标题怎样写地址、custom ID 什么时候加,见《xref:标题、寻址与引用[]》。
@@ -49,7 +49,7 @@ fields:: 要求某种 role 的标题在标题下第一个描述列表里写出
49
49
 
50
50
  照这两条声明,每道菜都应在 attrlist 里写 `难度`,取易、中、难之一,并在标题下第一个描述列表里写出 `主料` 和 `用时`。`主料` 若写在 attrlist 里(如 `[.dish, 主料=五花肉]`),不算写在描述列表里,不满足 `declares=fields` 的要求。声明只列要求写出的字段,可写可不写的字段照常写,例如《xref:字段[]》里的《红烧肉》多写了 `出处`,仍符合这两条声明。这道菜写全了声明要求的字段,其余各章的菜谱例子只摘出与该章有关的几行。
51
51
 
52
- 是否照声明写,由作者自己核对。工作区的检查核对哪几件事,见《xref:标题、寻址与引用[]》。
52
+ 是否照声明写,由作者或读投影的工具核对,工作区的构建不核对。
53
53
 
54
54
  写声明时,应符合下列规定:
55
55
 
@@ -30,7 +30,7 @@ v0.1, 2026-05
30
30
 
31
31
  |写带代码、图表、共享资源的技术书
32
32
  |xref:books/03-technical-book-workflow/book.adoc[03 技术书工作流]
33
- |它展示资源边界、图表和构建检查。
33
+ |它展示资源边界与图表。
34
34
 
35
35
  |查结构选择、术语和写法模式
36
36
  |xref:books/04-reference-manual/book.adoc[04 参考手册]
@@ -8,12 +8,11 @@
8
8
  "packageManager": "pnpm@10.33.0",
9
9
  "scripts": {
10
10
  "build": "node tools/adoc-books.mjs build",
11
- "check": "node tools/adoc-books.mjs check",
12
11
  "clean": "node tools/adoc-books.mjs clean"
13
12
  },
14
13
  "devDependencies": {
15
14
  "@asciidoctor/core": "^3.0.4",
16
- "asciidoc-abundant-tree": "^0.3.0",
15
+ "asciidoc-abundant-tree": "^0.6.0",
17
16
  "asciidoctor-kroki": "^0.18.1"
18
17
  }
19
18
  }
@@ -7,7 +7,6 @@ An AsciiDoc multi-book workspace that starts with one book: `books/01-structured
7
7
  ```bash
8
8
  pnpm install
9
9
  pnpm run build # writes build/html/index.html
10
- pnpm run check # checks the catalog, books, xrefs, anchors, and local HTML resources
11
10
  pnpm run clean # removes build/
12
11
  ```
13
12
 
@@ -37,4 +36,4 @@ The rules a resolver follows are in the
37
36
 
38
37
  Copy `books/01-structured-writing-conventions` to `books/<NN-book-id>`, edit its `book.adoc`, then add an
39
38
  `xref:books/<NN-book-id>/book.adoc[...]` line to `catalog.adoc`.
40
- `pnpm run check` fails until `catalog.adoc` and `books/` list the same books.
39
+ The build does not compare `catalog.adoc` with `books/`: a book you leave out of the catalog is built but not linked from it.