create-asciidoc-multi-book-workspace 0.4.0 → 0.5.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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1 - 2026-10-23
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Generated workspaces depend on `asciidoc-abundant-tree` `^0.2.6`. Releases 0.2.2–0.2.6 correct facts the reader pages are built from: edges with no source heading in included files, bibliography references, the Asciidoctor binding (`href`, reftext) of xrefs in descriptions and list items, and the span of description lists. The type of the attributes an include resolver receives now admits `null` (a blank positional attribute), as the runtime already passed it.
|
|
8
|
+
|
|
9
|
+
### Upgrading an existing workspace
|
|
10
|
+
|
|
11
|
+
Set `"asciidoc-abundant-tree": "^0.2.6"` in your `package.json` and run `pnpm install`. `tools/adoc-books.mjs` is unchanged in behavior; there is no need to copy it.
|
|
12
|
+
|
|
13
|
+
## 0.5.0 - 2026-10-21
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `adoc-books.config.mjs` accepts `documentRoot`: how far books may include files, relative to the workspace root (default `.`). A workspace that lives inside a project repository declares the project root (`".."`) so books can include the project's own code. Reader source paths and the `// file:` markers of copied source are relative to it. It must be a path string that contains the workspace root.
|
|
18
|
+
- `adoc-books.config.mjs` accepts `includeResolvers`: `{ prefix, resolve }` objects in the shape `asciidoc-abundant-tree` 0.2.1 accepts. One resolver serves both the HTML build and the reader data, so `include::<prefix>…[]` shows the same lines in both. The runtime checks that a resolver returns real lines of a file inside `documentRoot`; the Configure section of the default workspace README lists the rules a resolver follows.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Generated workspaces depend on `asciidoc-abundant-tree` `^0.2.1` (Asciidoctor.js 4 inside, asynchronous parsing). HTML is still rendered by `@asciidoctor/core` 3.
|
|
23
|
+
- A book whose include graph `asciidoc-abundant-tree` cannot parse now fails `build` and `check`, and the error starts with the book id. Previously the runtime printed one warning and fell back to a cover-only reader page while the build succeeded. If a book includes files outside the workspace, declare `documentRoot`.
|
|
24
|
+
- Xref targets and anchors are checked only on the generated HTML. The source-level codes `XREF_TARGET_MISSING` and `MISSING_ANCHOR` are gone; a missing target or anchor is reported as `HTML_RESOURCE_MISSING` or `HTML_ANCHOR_MISSING`. An xref to a non-entry `.adoc` file of another book (for example `parts/…/x.adoc`) now fails, because no HTML page is generated for it; link `book.adoc#<id>` instead.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Cross-book xrefs to IDs declared as `[.role#id]`, `[[id]]`, `[id=id]`, or generated from a title no longer fail `check` with `MISSING_ANCHOR`.
|
|
29
|
+
|
|
30
|
+
### Upgrading an existing workspace
|
|
31
|
+
|
|
32
|
+
A generated workspace owns its `tools/adoc-books.mjs`. To adopt this release, create a temporary workspace with `pnpm create asciidoc-multi-book-workspace@0.5.0 tmp-books`, copy its `tools/adoc-books.mjs` over yours, set `"asciidoc-abundant-tree": "^0.2.1"` in your `package.json`, and run `pnpm install`.
|
|
33
|
+
|
|
3
34
|
## 0.4.0 - 2026-10-21
|
|
4
35
|
|
|
5
36
|
### Added
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
3
4
|
import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
@@ -8,8 +9,6 @@ import { parseAbundantTree } from "asciidoc-abundant-tree";
|
|
|
8
9
|
const require = createRequire(import.meta.url);
|
|
9
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;
|
|
10
11
|
const CATALOG_BOOK_XREF_PATTERN = /xref:books\/([^/\]]+)\/book\.adoc(?:#[^\[]+)?\[/g;
|
|
11
|
-
const XREF_PATTERN = /xref:([^\[#]+)(?:#([A-Za-z0-9_-]+))?\[/g;
|
|
12
|
-
const ANCHOR_PATTERN = /^\[#([A-Za-z0-9_-]+)(?:[.,][^\]]*)?\]$/gm;
|
|
13
12
|
const LOCAL_TARGET_PATTERN = /\b(href|src)="([^"]+)"/g;
|
|
14
13
|
const HTML_ANCHOR_PATTERN = /\b(?:id|name)="([^"]+)"/g;
|
|
15
14
|
const SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
|
|
@@ -35,15 +34,12 @@ function stringValue(value, fallback) {
|
|
|
35
34
|
}
|
|
36
35
|
async function loadRuntimeConfig(rootDir) {
|
|
37
36
|
const configPath = path.join(rootDir, CONFIG_FILE);
|
|
38
|
-
|
|
39
|
-
return DEFAULT_CONFIG;
|
|
40
|
-
const configUrl = pathToFileURL(configPath);
|
|
41
|
-
configUrl.search = `mtime=${(await stat(configPath)).mtimeMs}`;
|
|
42
|
-
const module = await import(configUrl.href);
|
|
43
|
-
const rawConfig = objectValue(module.default ?? module);
|
|
37
|
+
const rawConfig = await existsFile(configPath) ? await importConfig(configPath) : {};
|
|
44
38
|
const rawRootIndex = objectValue(rawConfig.rootIndex);
|
|
45
39
|
const rawHomeLink = objectValue(rawConfig.homeLink);
|
|
46
40
|
return {
|
|
41
|
+
documentRoot: documentRootValue(rootDir, rawConfig.documentRoot),
|
|
42
|
+
includeResolvers: includeResolversValue(rawConfig.includeResolvers),
|
|
47
43
|
rootIndex: {
|
|
48
44
|
redirectTo: stringValue(rawRootIndex.redirectTo, DEFAULT_CONFIG.rootIndex.redirectTo),
|
|
49
45
|
title: stringValue(rawRootIndex.title, DEFAULT_CONFIG.rootIndex.title)
|
|
@@ -54,6 +50,41 @@ async function loadRuntimeConfig(rootDir) {
|
|
|
54
50
|
}
|
|
55
51
|
};
|
|
56
52
|
}
|
|
53
|
+
async function importConfig(configPath) {
|
|
54
|
+
const configUrl = pathToFileURL(configPath);
|
|
55
|
+
configUrl.search = `mtime=${(await stat(configPath)).mtimeMs}`;
|
|
56
|
+
const module = await import(configUrl.href);
|
|
57
|
+
return objectValue(module.default ?? module);
|
|
58
|
+
}
|
|
59
|
+
// 相对工作区根解析:配置文件就在工作区根,".." 即项目根。书要 include ../../shared/attributes.adoc,所以根必须
|
|
60
|
+
// 包含工作区;不合规的值直接报错,悄悄退回默认值时,使用者只会看到 include 越界,想不到是配置没生效。
|
|
61
|
+
function documentRootValue(rootDir, value) {
|
|
62
|
+
if (value === undefined)
|
|
63
|
+
return rootDir;
|
|
64
|
+
if (typeof value !== "string" || value === "") {
|
|
65
|
+
throw new Error(`${CONFIG_FILE}: documentRoot must be a path string relative to the workspace root`);
|
|
66
|
+
}
|
|
67
|
+
const documentRoot = path.resolve(rootDir, value);
|
|
68
|
+
if (!isWithin(documentRoot, rootDir)) {
|
|
69
|
+
throw new Error(`${CONFIG_FILE}: documentRoot must contain the workspace root ${rootDir}, got ${documentRoot}`);
|
|
70
|
+
}
|
|
71
|
+
return documentRoot;
|
|
72
|
+
}
|
|
73
|
+
function isWithin(root, filePath) {
|
|
74
|
+
const relative = path.relative(root, filePath);
|
|
75
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
76
|
+
}
|
|
77
|
+
function includeResolversValue(value) {
|
|
78
|
+
if (value === undefined)
|
|
79
|
+
return [];
|
|
80
|
+
const valid = Array.isArray(value) && value.every((resolver) => {
|
|
81
|
+
const entry = objectValue(resolver);
|
|
82
|
+
return typeof entry.prefix === "string" && entry.prefix !== "" && typeof entry.resolve === "function";
|
|
83
|
+
});
|
|
84
|
+
if (!valid)
|
|
85
|
+
throw new Error(`${CONFIG_FILE}: includeResolvers must be an array of { prefix, resolve } objects`);
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
57
88
|
async function existsFile(filePath) {
|
|
58
89
|
try {
|
|
59
90
|
const stats = await stat(filePath);
|
|
@@ -138,6 +169,111 @@ function createAsciidoctor(loadKroki) {
|
|
|
138
169
|
}
|
|
139
170
|
return asciidoctor;
|
|
140
171
|
}
|
|
172
|
+
// 书架登记的 include 解析器,在 HTML 这一侧由 Asciidoctor 的 include 处理器调用;阅读数据一侧由 aat 调用同一份,
|
|
173
|
+
// 两侧读同一份行(DESIGN.adoc 的 Workspace Configuration)。每次构建单独一个 registry:Asciidoctor 3 的全局扩展
|
|
174
|
+
// 在进程里一直留着,登记在全局会把上一个工作区的解析器带进下一个。
|
|
175
|
+
function includeResolverRegistry(asciidoctor, config) {
|
|
176
|
+
if (config.includeResolvers.length === 0)
|
|
177
|
+
return asciidoctor.Extensions.create();
|
|
178
|
+
const claiming = (target) => config.includeResolvers.find((resolver) => target.startsWith(resolver.prefix));
|
|
179
|
+
// 以分组登记:registry 每绑定一个文档就重置一次,只有分组会重新登记。catalog 与各本书共用这一个 registry,
|
|
180
|
+
// 直接调 registry.includeProcessor 时只有第一个文档用得上;之后的文档把 include::前缀:… 当作 URI,
|
|
181
|
+
// 悄悄转成链接,不报任何消息。
|
|
182
|
+
return asciidoctor.Extensions.create(function () {
|
|
183
|
+
this.includeProcessor(function () {
|
|
184
|
+
this.handles((target) => claiming(target) !== undefined);
|
|
185
|
+
this.process((document, reader, target, attributes) => {
|
|
186
|
+
const resolver = claiming(target);
|
|
187
|
+
const cursor = reader.getCursor();
|
|
188
|
+
const sourcePath = cursor.getFile();
|
|
189
|
+
// 游标已越过 directive 这一行。
|
|
190
|
+
const location = `include::${target}[] in ${sourcePath}: line ${cursor.getLineNumber() - 1}`;
|
|
191
|
+
let resolution;
|
|
192
|
+
try {
|
|
193
|
+
resolution = resolver.resolve({
|
|
194
|
+
target: target.slice(resolver.prefix.length),
|
|
195
|
+
attributes: includeAttributes(attributes),
|
|
196
|
+
sourcePath
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
201
|
+
throw new Error(`include resolver '${resolver.prefix}' failed for ${location}: ${message}`, { cause: error });
|
|
202
|
+
}
|
|
203
|
+
if (typeof objectValue(resolution).then === "function") {
|
|
204
|
+
// 不再等它:它若 reject,没人接住就成了进程级的未处理 rejection。
|
|
205
|
+
Promise.resolve(resolution).catch(() => { });
|
|
206
|
+
throw new Error(`include resolver '${resolver.prefix}' returned a Promise for ${location}; ` +
|
|
207
|
+
"the HTML build runs on Asciidoctor.js 3, whose include processors are synchronous");
|
|
208
|
+
}
|
|
209
|
+
const { text, absolutePath, startLine } = checkedResolution(resolution, config.documentRoot, `include resolver '${resolver.prefix}'`, location);
|
|
210
|
+
// 显示用的路径照 Asciidoctor 自己 include 文件时的写法,相对文档的 base_dir。
|
|
211
|
+
const displayPath = path.relative(document.getBaseDir(), absolutePath).split(path.sep).join("/");
|
|
212
|
+
reader.pushInclude(text, absolutePath, displayPath, startLine, attributes);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
// 解析器交回的是来源文件里从 startLine 起连续的整行。这里逐行核对:两侧读同一份行的前提是 origin 就是 text 的
|
|
218
|
+
// 出处——aat 把 origin 指向的文件全文收进复制纯文本,HTML 只用 text,两者对不上时构建也会成功,读者却看到两份
|
|
219
|
+
// 不同的东西(aat 对此只报 warning)。
|
|
220
|
+
function checkedResolution(resolution, documentRoot, who, location) {
|
|
221
|
+
const shaped = objectValue(resolution);
|
|
222
|
+
const origin = objectValue(shaped.origin);
|
|
223
|
+
const startLine = origin.startLine;
|
|
224
|
+
if (typeof shaped.text !== "string" || typeof origin.path !== "string" || !Number.isInteger(startLine) || startLine < 1) {
|
|
225
|
+
throw new Error(`${who} returned an invalid result for ${location}; expected { text, origin: { path, startLine } }`);
|
|
226
|
+
}
|
|
227
|
+
const absolutePath = path.resolve(documentRoot, origin.path);
|
|
228
|
+
// 来源是根之内的一个文件,不能是根本身;aat 同样拒绝。
|
|
229
|
+
if (absolutePath === documentRoot || !isWithin(documentRoot, absolutePath)) {
|
|
230
|
+
throw new Error(`${who} returned a source outside documentRoot ${documentRoot} for ${location}: ${absolutePath}`);
|
|
231
|
+
}
|
|
232
|
+
const text = withoutFinalNewline(shaped.text);
|
|
233
|
+
if (text !== "") {
|
|
234
|
+
const lines = text.split(/\r?\n/u);
|
|
235
|
+
let source;
|
|
236
|
+
try {
|
|
237
|
+
source = readFileSync(absolutePath, "utf8");
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
241
|
+
throw new Error(`${who} returned a source that cannot be read for ${location}: ${message}`, { cause: error });
|
|
242
|
+
}
|
|
243
|
+
const sourceLines = withoutFinalNewline(source).split(/\r?\n/u);
|
|
244
|
+
const first = startLine - 1;
|
|
245
|
+
const mismatch = lines.findIndex((line, index) => sourceLines[first + index] !== line);
|
|
246
|
+
if (mismatch !== -1) {
|
|
247
|
+
const lineNumber = first + mismatch + 1;
|
|
248
|
+
const detail = lineNumber > sourceLines.length
|
|
249
|
+
? `the file has only ${sourceLines.length} lines`
|
|
250
|
+
: `line ${lineNumber} is ${shown(sourceLines[lineNumber - 1])}, the resolver returned ${shown(lines[mismatch])}`;
|
|
251
|
+
throw new Error(`${who} returned text that is not lines ${first + 1}-${first + lines.length} of ${absolutePath} for ${location}: ${detail}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { text, absolutePath, startLine: startLine };
|
|
255
|
+
}
|
|
256
|
+
// 末尾的换行是最后一行的结束,不是多出来的一行:留着会让 Reader 多切出一个空行(aat 同样去掉一个)。来源文件按同一
|
|
257
|
+
// 规则切行,文件最后一行之后才不会多出一个并不存在的空行。
|
|
258
|
+
function withoutFinalNewline(text) {
|
|
259
|
+
return text.replace(/\r?\n$/u, "");
|
|
260
|
+
}
|
|
261
|
+
// 消息里的一行:截短,BOM 与控制字符写成可见的转义,行尾空白看得出来。
|
|
262
|
+
function shown(line) {
|
|
263
|
+
const clipped = line.length > 60 ? `${line.slice(0, 60)}…` : line;
|
|
264
|
+
return JSON.stringify(clipped).replaceAll("", "\\uFEFF");
|
|
265
|
+
}
|
|
266
|
+
// Asciidoctor.js 3 把位置属性放在 $positional 列表里;aat 跑在 4 上,位置属性以 "1"、"2" 为键。
|
|
267
|
+
// 解析器只认后一种形状,同一个解析器才能服务两侧。留空的位置属性([,x])在 3 里是 undefined、在 4 里是 null,
|
|
268
|
+
// 这里交 null;中间留空([a,,b])时 3 已把空位丢掉、编号与 4 不同,无从还原,规约写明位置属性不能留空。
|
|
269
|
+
function includeAttributes(attributes) {
|
|
270
|
+
const { $positional, ...named } = attributes;
|
|
271
|
+
const positional = Array.isArray($positional) ? $positional : [];
|
|
272
|
+
return {
|
|
273
|
+
...Object.fromEntries(positional.map((value, index) => [String(index + 1), value === undefined ? null : String(value)])),
|
|
274
|
+
...Object.fromEntries(Object.entries(named).map(([name, value]) => [name, String(value)]))
|
|
275
|
+
};
|
|
276
|
+
}
|
|
141
277
|
function shouldFetchDiagrams() {
|
|
142
278
|
const value = process.env[FETCH_DIAGRAMS_ENV];
|
|
143
279
|
return value === "1" || value === "true";
|
|
@@ -181,7 +317,7 @@ function formatAsciidoctorMessage(message) {
|
|
|
181
317
|
const text = messageText(message);
|
|
182
318
|
return location ? `${severity}: ${location}: ${text}` : `${severity}: ${text}`;
|
|
183
319
|
}
|
|
184
|
-
function convertFile(asciidoctor, input, outputFile, baseDir, attributes = {}) {
|
|
320
|
+
function convertFile(asciidoctor, extensionRegistry, input, outputFile, baseDir, attributes = {}) {
|
|
185
321
|
const loggerManager = asciidoctor.LoggerManager;
|
|
186
322
|
const defaultLogger = loggerManager.getLogger();
|
|
187
323
|
const memoryLogger = asciidoctor.MemoryLogger.create();
|
|
@@ -192,6 +328,7 @@ function convertFile(asciidoctor, input, outputFile, baseDir, attributes = {}) {
|
|
|
192
328
|
base_dir: baseDir,
|
|
193
329
|
to_file: outputFile,
|
|
194
330
|
mkdirs: true,
|
|
331
|
+
extension_registry: extensionRegistry,
|
|
195
332
|
attributes
|
|
196
333
|
});
|
|
197
334
|
}
|
|
@@ -203,13 +340,13 @@ function convertFile(asciidoctor, input, outputFile, baseDir, attributes = {}) {
|
|
|
203
340
|
throw new Error(`AsciiDoc strict check failed for ${input}\n${messages.map(formatAsciidoctorMessage).join("\n")}`);
|
|
204
341
|
}
|
|
205
342
|
}
|
|
206
|
-
async function buildHtml(rootDir, books, asciidoctor, useKroki, fetchDiagrams) {
|
|
343
|
+
async function buildHtml(rootDir, books, asciidoctor, extensionRegistry, useKroki, fetchDiagrams) {
|
|
207
344
|
await mkdir(path.join(rootDir, "build", "html"), { recursive: true });
|
|
208
|
-
convertFile(asciidoctor, path.join(rootDir, "catalog.adoc"), path.join(rootDir, "build", "html", "catalog.html"), rootDir);
|
|
345
|
+
convertFile(asciidoctor, extensionRegistry, path.join(rootDir, "catalog.adoc"), path.join(rootDir, "build", "html", "catalog.html"), rootDir);
|
|
209
346
|
await pruneStaleBookHtmlDirs(rootDir, books);
|
|
210
347
|
for (const book of books) {
|
|
211
348
|
await mkdir(book.htmlOutputDir, { recursive: true });
|
|
212
|
-
convertFile(asciidoctor, book.input, path.join(book.htmlOutputDir, "book.html"), book.bookDir, {
|
|
349
|
+
convertFile(asciidoctor, extensionRegistry, book.input, path.join(book.htmlOutputDir, "book.html"), book.bookDir, {
|
|
213
350
|
...(useKroki && fetchDiagrams ? { "kroki-fetch-diagram": "", "kroki-http-method": "post" } : {})
|
|
214
351
|
});
|
|
215
352
|
}
|
|
@@ -316,50 +453,29 @@ function assertNoToolErrors(bookId, diagnostics) {
|
|
|
316
453
|
}
|
|
317
454
|
}
|
|
318
455
|
}
|
|
319
|
-
function sourceBundleFromDocument(document
|
|
456
|
+
function sourceBundleFromDocument(document) {
|
|
320
457
|
const sourceFiles = Array.isArray(document.sourceFiles) ? document.sourceFiles : [];
|
|
321
458
|
const files = sourceFiles.filter((file) => typeof file.relativePath === "string" && typeof file.raw === "string");
|
|
322
|
-
if (files.length === 0)
|
|
323
|
-
return fallbackSource;
|
|
324
459
|
return files.map((file) => {
|
|
325
460
|
const body = file.raw?.endsWith("\n") ? file.raw : `${file.raw ?? ""}\n`;
|
|
326
|
-
return `// file: ${file.relativePath
|
|
461
|
+
return `// file: ${file.relativePath}\n${body}`;
|
|
327
462
|
}).join("\n");
|
|
328
463
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
return {
|
|
333
|
-
pageMap: {
|
|
334
|
-
version: 1,
|
|
335
|
-
book: {
|
|
336
|
-
id: book.bookId,
|
|
337
|
-
title: book.bookId,
|
|
338
|
-
entry
|
|
339
|
-
},
|
|
340
|
-
pages: [{
|
|
341
|
-
id: "cover",
|
|
342
|
-
kind: "cover",
|
|
343
|
-
title: book.bookId,
|
|
344
|
-
anchors: [],
|
|
345
|
-
toc: []
|
|
346
|
-
}]
|
|
347
|
-
},
|
|
348
|
-
sourceBundle: fallbackSource
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
function buildReaderBookData(rootDir, book, fallbackSource) {
|
|
464
|
+
// 解析失败就让构建失败,不退回只有封面的阅读页:退回时 include 越过 documentRoot 只剩一条 warning,
|
|
465
|
+
// 构建照常成功,没有人发现阅读页坏了(DESIGN.adoc 的 Workspace Contract Checks)。
|
|
466
|
+
async function buildReaderBookData(config, book) {
|
|
352
467
|
let document;
|
|
353
468
|
try {
|
|
354
|
-
document = parseAbundantTree({
|
|
469
|
+
document = await parseAbundantTree({
|
|
355
470
|
sourcePath: book.input,
|
|
356
471
|
mode: "book-entry",
|
|
357
|
-
documentRoot:
|
|
472
|
+
documentRoot: config.documentRoot,
|
|
473
|
+
includeResolvers: config.includeResolvers
|
|
358
474
|
});
|
|
359
475
|
}
|
|
360
476
|
catch (error) {
|
|
361
477
|
const message = error instanceof Error ? error.message : String(error);
|
|
362
|
-
|
|
478
|
+
throw new Error(`${book.bookId}: ${message}`, { cause: error });
|
|
363
479
|
}
|
|
364
480
|
const diagnostics = Array.isArray(document.toolDiagnostics) ? document.toolDiagnostics : [];
|
|
365
481
|
assertNoToolErrors(book.bookId, diagnostics);
|
|
@@ -421,13 +537,13 @@ function buildReaderBookData(rootDir, book, fallbackSource) {
|
|
|
421
537
|
book: {
|
|
422
538
|
id: book.bookId,
|
|
423
539
|
title: pages[0].title,
|
|
424
|
-
entry: path.relative(
|
|
540
|
+
entry: path.relative(config.documentRoot, book.input).split(path.sep).join("/")
|
|
425
541
|
},
|
|
426
542
|
pages
|
|
427
543
|
};
|
|
428
544
|
return {
|
|
429
545
|
pageMap,
|
|
430
|
-
sourceBundle: sourceBundleFromDocument(document
|
|
546
|
+
sourceBundle: sourceBundleFromDocument(document)
|
|
431
547
|
};
|
|
432
548
|
}
|
|
433
549
|
function readerStyles() {
|
|
@@ -943,15 +1059,14 @@ ${readerStyles()}
|
|
|
943
1059
|
<script>${readerScript()}</script>`;
|
|
944
1060
|
return `${html.slice(0, insertAt)}${withReaderScript}${html.slice(insertAt)}`;
|
|
945
1061
|
}
|
|
946
|
-
async function addHomeLinks(rootDir, books,
|
|
1062
|
+
async function addHomeLinks(rootDir, books, config) {
|
|
947
1063
|
const catalog = path.join(rootDir, "build", "html", "catalog.html");
|
|
948
1064
|
for (const book of books) {
|
|
949
1065
|
const htmlFile = path.join(book.htmlOutputDir, "book.html");
|
|
950
1066
|
const html = await readFile(htmlFile, "utf8");
|
|
951
1067
|
const href = path.relative(path.dirname(htmlFile), catalog);
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
await writeFile(htmlFile, addBookControlsToBookHtml(html, href, homeLink, readerData.sourceBundle, readerData.pageMap, book.bookId), "utf8");
|
|
1068
|
+
const readerData = await buildReaderBookData(config, book);
|
|
1069
|
+
await writeFile(htmlFile, addBookControlsToBookHtml(html, href, config.homeLink, readerData.sourceBundle, readerData.pageMap, book.bookId), "utf8");
|
|
955
1070
|
}
|
|
956
1071
|
}
|
|
957
1072
|
async function writeRootIndex(rootDir, rootIndex) {
|
|
@@ -977,27 +1092,12 @@ function extractCatalogBookIds(catalogSource) {
|
|
|
977
1092
|
ids.add(match[1]);
|
|
978
1093
|
return [...ids].sort((a, b) => a.localeCompare(b));
|
|
979
1094
|
}
|
|
980
|
-
function explicitAnchors(source) {
|
|
981
|
-
const anchors = new Set();
|
|
982
|
-
for (const match of source.matchAll(ANCHOR_PATTERN))
|
|
983
|
-
anchors.add(match[1]);
|
|
984
|
-
return anchors;
|
|
985
|
-
}
|
|
986
|
-
function attributesFromBookSource(bookSource) {
|
|
987
|
-
const attributes = new Map();
|
|
988
|
-
for (const line of bookSource.split(/\r?\n/)) {
|
|
989
|
-
const match = line.match(/^:([A-Za-z0-9_-]+):\s+(.+)$/);
|
|
990
|
-
if (match)
|
|
991
|
-
attributes.set(match[1], match[2]);
|
|
992
|
-
}
|
|
993
|
-
return attributes;
|
|
994
|
-
}
|
|
995
|
-
function expandXrefTarget(target, attributes) {
|
|
996
|
-
return target.replaceAll(/\{([A-Za-z0-9_-]+)\}/g, (_, name) => attributes.get(name) ?? `{${name}}`);
|
|
997
|
-
}
|
|
998
1095
|
function issue(code, detail) {
|
|
999
1096
|
return { code, detail };
|
|
1000
1097
|
}
|
|
1098
|
+
// xref 的目标与锚点不在这里查:Asciidoctor 把它们写成 HTML 里的链接与 ID,missingLocalResources 在生成的 HTML 上
|
|
1099
|
+
// 核对(DESIGN.adoc 的 Workspace Contract Checks)。ID 有 [#id]、[.role#id]、[[id]]、[id=id] 与标题生成等写法,
|
|
1100
|
+
// 只有 Asciidoctor 认得全。
|
|
1001
1101
|
async function workspaceContractIssues(rootDir, books) {
|
|
1002
1102
|
const issues = [];
|
|
1003
1103
|
const catalogSource = await readIfExists(path.join(rootDir, "catalog.adoc"));
|
|
@@ -1014,31 +1114,8 @@ async function workspaceContractIssues(rootDir, books) {
|
|
|
1014
1114
|
}
|
|
1015
1115
|
for (const book of books) {
|
|
1016
1116
|
const bookSource = await readFile(book.input, "utf8");
|
|
1017
|
-
const allSource = await combinedBookSource(book.bookDir);
|
|
1018
|
-
const attributes = attributesFromBookSource(bookSource);
|
|
1019
1117
|
if (!/^:doctype:\s+book$/m.test(bookSource))
|
|
1020
1118
|
issues.push(issue("MISSING_DOCTYPE", book.bookId));
|
|
1021
|
-
for (const match of allSource.matchAll(XREF_PATTERN)) {
|
|
1022
|
-
const [, target, anchor] = match;
|
|
1023
|
-
if (/^https?:/.test(target))
|
|
1024
|
-
continue;
|
|
1025
|
-
const expandedTarget = expandXrefTarget(target, attributes);
|
|
1026
|
-
if (!expandedTarget.endsWith(".adoc"))
|
|
1027
|
-
continue;
|
|
1028
|
-
const resolved = path.resolve(book.bookDir, expandedTarget);
|
|
1029
|
-
if (!await existsFile(resolved)) {
|
|
1030
|
-
issues.push(issue("XREF_TARGET_MISSING", `${book.bookId} -> ${expandedTarget}`));
|
|
1031
|
-
if (anchor)
|
|
1032
|
-
issues.push(issue("MISSING_ANCHOR", `${book.bookId} -> ${expandedTarget}#${anchor}`));
|
|
1033
|
-
continue;
|
|
1034
|
-
}
|
|
1035
|
-
if (anchor) {
|
|
1036
|
-
const targetSource = await combinedBookSource(path.dirname(resolved));
|
|
1037
|
-
if (!explicitAnchors(targetSource).has(anchor)) {
|
|
1038
|
-
issues.push(issue("MISSING_ANCHOR", `${book.bookId} -> ${expandedTarget}#${anchor}`));
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
1119
|
}
|
|
1043
1120
|
return issues;
|
|
1044
1121
|
}
|
|
@@ -1125,9 +1202,9 @@ export async function buildWorkspace(rootDir = process.cwd()) {
|
|
|
1125
1202
|
const useKroki = await workspaceUsesDiagrams(rootDir, books);
|
|
1126
1203
|
const fetchDiagrams = shouldFetchDiagrams();
|
|
1127
1204
|
const asciidoctor = createAsciidoctor(useKroki);
|
|
1128
|
-
await buildHtml(rootDir, books, asciidoctor, useKroki, fetchDiagrams);
|
|
1205
|
+
await buildHtml(rootDir, books, asciidoctor, includeResolverRegistry(asciidoctor, config), useKroki, fetchDiagrams);
|
|
1129
1206
|
await copyAssets(rootDir, books);
|
|
1130
|
-
await addHomeLinks(rootDir, books, config
|
|
1207
|
+
await addHomeLinks(rootDir, books, config);
|
|
1131
1208
|
await writeRootIndex(rootDir, config.rootIndex);
|
|
1132
1209
|
await assertNoIssues("HTML local resource check", await missingLocalResources(rootDir));
|
|
1133
1210
|
await assertNoIssues("workspace contract check", await workspaceContractIssues(rootDir, books));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-asciidoc-multi-book-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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.
|
|
57
|
+
"asciidoc-abundant-tree": "^0.2.6",
|
|
58
58
|
"asciidoctor-kroki": "^0.18.1",
|
|
59
59
|
"playwright": "^1.59.1",
|
|
60
60
|
"typescript": "^5.9.3"
|
|
@@ -35,7 +35,50 @@ ADOC_BOOKS_FETCH_DIAGRAMS=1 pnpm run build
|
|
|
35
35
|
pnpm run check
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
This checks the current catalog, existing books, book doctypes, cross-book xrefs, anchors, and local HTML resources.
|
|
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).
|
|
39
|
+
|
|
40
|
+
## Configure
|
|
41
|
+
|
|
42
|
+
An optional `adoc-books.config.mjs` at the workspace root adjusts the build. Every key is optional:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
export default {
|
|
46
|
+
// Where build/html/index.html redirects, and its page title.
|
|
47
|
+
rootIndex: { redirectTo: "catalog.html", title: "My Books" },
|
|
48
|
+
// The catalog link at the top of every book.
|
|
49
|
+
homeLink: { label: "Back to catalog", subtitle: "My Books" },
|
|
50
|
+
// How far books may include files, relative to this directory. Default: "." (this workspace).
|
|
51
|
+
// documentRoot: "..",
|
|
52
|
+
};
|
|
53
|
+
```
|
|
54
|
+
|
|
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.
|
|
56
|
+
|
|
57
|
+
`includeResolvers` lets books include lines chosen by your own code, for example one function out of a source file:
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
import path from "node:path";
|
|
61
|
+
|
|
62
|
+
export default {
|
|
63
|
+
includeResolvers: [{
|
|
64
|
+
prefix: "symbol:",
|
|
65
|
+
// include::symbol:src/math.ts[add] arrives as { target: "src/math.ts", attributes: { 1: "add" }, sourcePath }.
|
|
66
|
+
resolve({ target, attributes, sourcePath }) {
|
|
67
|
+
const file = path.resolve(import.meta.dirname, target); // this resolver reads targets from the workspace root
|
|
68
|
+
// ...read file and find the lines of the function named attributes[1]...
|
|
69
|
+
return { text, origin: { path: file, startLine } };
|
|
70
|
+
},
|
|
71
|
+
}],
|
|
72
|
+
};
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The same resolver serves the HTML build and the reader, so both show the same lines. When you write one:
|
|
76
|
+
|
|
77
|
+
- End `prefix` with a separator such as `:`. Prefixes match the start of the include target, so `snip` would also take `include::snippets/x.adoc[]`.
|
|
78
|
+
- Decide what `target` is relative to. The example uses the workspace root; for Asciidoctor's own rule use `path.dirname(sourcePath)`.
|
|
79
|
+
- Return whole consecutive lines of one file inside `documentRoot`, that file's path (absolute, or relative to `documentRoot`), and the line number (from 1) where the text starts. The build checks that the text is exactly those lines of that file, trailing spaces and a leading byte order mark included, and names the first line that differs. Build messages about those lines point into the file. Copied source includes that whole file.
|
|
80
|
+
- Return the result directly, not a Promise, and return the same result every time: `resolve` runs twice per directive, once for the HTML and once for the reader.
|
|
81
|
+
- Attribute values are strings, except that an empty leading positional attribute (`[,x]`) is `null`. Do not leave a positional attribute empty between others (`[a,,b]`); the two sides would number them differently.
|
|
39
82
|
|
|
40
83
|
## Clean
|
|
41
84
|
|
|
@@ -17,6 +17,22 @@ To fetch diagram images into the local HTML output instead of linking Kroki URLs
|
|
|
17
17
|
ADOC_BOOKS_FETCH_DIAGRAMS=1 pnpm run build
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
## Configure
|
|
21
|
+
|
|
22
|
+
An optional `adoc-books.config.mjs` at the workspace root adjusts the build. When this workspace lives inside a
|
|
23
|
+
project repository, declare the project root so books can include the project's own code:
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
export default {
|
|
27
|
+
documentRoot: "..", // relative to this directory; default "."
|
|
28
|
+
};
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The file also accepts `rootIndex: { redirectTo, title }`, `homeLink: { label, subtitle }`, and
|
|
32
|
+
`includeResolvers: [{ prefix, resolve }]` for include directives whose lines your own code selects.
|
|
33
|
+
The rules a resolver follows are in the
|
|
34
|
+
[Configure section of the default workspace README](https://github.com/MichengLiang/asciidoc-multi-book-workspace/blob/main/templates/default-workspace/README.md#configure).
|
|
35
|
+
|
|
20
36
|
## Add A Book
|
|
21
37
|
|
|
22
38
|
Copy `books/01-structured-writing-conventions` to `books/<NN-book-id>`, edit its `book.adoc`, then add an
|