create-asciidoc-multi-book-workspace 0.3.1 → 0.5.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 +34 -0
- package/README.md +28 -0
- package/dist/create.js +11 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/init-workspace.d.ts +1 -0
- package/dist/init-workspace.js +15 -13
- package/dist/runtime/adoc-books.mjs +169 -91
- package/dist/template-manifest.d.ts +9 -2
- package/dist/template-manifest.js +24 -9
- package/package.json +2 -2
- package/templates/default-workspace/README.md +44 -1
- package/templates/default-workspace/books/07-structured-writing-conventions/backmatter/appendix-a.adoc +6 -0
- package/templates/default-workspace/books/07-structured-writing-conventions/backmatter/appendix-b-decision-trees.adoc +32 -2
- package/templates/default-workspace/books/07-structured-writing-conventions/backmatter/glossary.adoc +1 -0
- package/templates/default-workspace/books/07-structured-writing-conventions/book.adoc +2 -0
- package/templates/default-workspace/books/07-structured-writing-conventions/parts/010-source-surface/020-heading-address-and-xref.adoc +1 -1
- package/templates/default-workspace/books/07-structured-writing-conventions/parts/020-names-roles-and-relations/020-heading-roles.adoc +2 -0
- package/templates/default-workspace/books/07-structured-writing-conventions/parts/020-names-roles-and-relations/030-relation-predicate.adoc +2 -0
- package/templates/default-workspace/books/07-structured-writing-conventions/parts/030-fields-and-terminology/010-surface-fields.adoc +25 -1
- package/templates/default-workspace/books/07-structured-writing-conventions/parts/030-fields-and-terminology/030-declaring-conventions.adoc +70 -0
- package/templates/default-workspace/books/07-structured-writing-conventions/parts/030-fields-and-terminology/_partintro.adoc +1 -1
- package/templates/default-workspace/package.json.template +1 -1
- package/templates/solo-workspace/README.md +40 -0
- package/templates/solo-workspace/catalog.adoc +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 - 2026-10-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `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.
|
|
8
|
+
- `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.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Generated workspaces depend on `asciidoc-abundant-tree` `^0.2.1` (Asciidoctor.js 4 inside, asynchronous parsing). HTML is still rendered by `@asciidoctor/core` 3.
|
|
13
|
+
- 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`.
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Cross-book xrefs to IDs declared as `[.role#id]`, `[[id]]`, `[id=id]`, or generated from a title no longer fail `check` with `MISSING_ANCHOR`.
|
|
19
|
+
|
|
20
|
+
### Upgrading an existing workspace
|
|
21
|
+
|
|
22
|
+
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`.
|
|
23
|
+
|
|
24
|
+
## 0.4.0 - 2026-10-21
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Add `--solo` to the `create` command and export `initSoloWorkspace` from the package root: generate a workspace that contains only the structured writing conventions book, shelved as book 01.
|
|
29
|
+
- Add the chapter "声明本书的约定" (declaring the book's conventions) to the structured writing conventions sample: `.convention` headings with `declares=roles|relations|values|fields` write a book's controlled vocabularies in the book itself, so readers, check scripts, and query tools read one source. A `fields` declaration can carry `id-prefix` for roles whose address labels are existing technical addresses.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Show in the structured writing conventions sample how a field value attaches a code block or table with `+`, and list every way a description list stops counting as fields: an unattached block, another block between the heading and the list, block attributes on the list, or a nested description list. It also shows how to keep a description list under a heading as body text.
|
|
34
|
+
- State that a heading's role scope is a reading context: projection, checks, and query tools read only the role written on each heading.
|
|
35
|
+
- Add a decision tree for declaring and maintaining conventions, and update the quick reference, glossary, and part introductions.
|
|
36
|
+
|
|
3
37
|
## 0.3.1 - 2026-10-15
|
|
4
38
|
|
|
5
39
|
### Changed
|
package/README.md
CHANGED
|
@@ -13,6 +13,14 @@ pnpm install
|
|
|
13
13
|
pnpm run build
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
For a workspace with only the structured writing conventions book (shelved as book 01), add `--solo`:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pnpm create asciidoc-multi-book-workspace my-book --solo
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
See [Solo Workspace](#solo-workspace).
|
|
23
|
+
|
|
16
24
|
npm users can run:
|
|
17
25
|
|
|
18
26
|
```bash
|
|
@@ -36,6 +44,8 @@ console.log(result.targetDir);
|
|
|
36
44
|
|
|
37
45
|
The host CLI owns command parsing and user-facing output. This package owns the workspace files it creates.
|
|
38
46
|
|
|
47
|
+
`initSoloWorkspace` takes the same options and creates the [solo workspace](#solo-workspace).
|
|
48
|
+
|
|
39
49
|
The generated workspace contains:
|
|
40
50
|
|
|
41
51
|
```text
|
|
@@ -58,6 +68,23 @@ shared/images/workspace-map.svg
|
|
|
58
68
|
|
|
59
69
|
It does not include this repository's `test/`, `.github/`, `src/`, `dist/`, `DESIGN.adoc`, or maintainer release checks.
|
|
60
70
|
|
|
71
|
+
## Solo Workspace
|
|
72
|
+
|
|
73
|
+
The solo workspace holds one book: sample book `07-structured-writing-conventions`, shelved as
|
|
74
|
+
`books/01-structured-writing-conventions`, with a short catalog and README written for it:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
README.md
|
|
78
|
+
catalog.adoc
|
|
79
|
+
package.json
|
|
80
|
+
.gitignore
|
|
81
|
+
tools/adoc-books.mjs
|
|
82
|
+
books/01-structured-writing-conventions
|
|
83
|
+
shared/attributes.adoc
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The other sample books stay in the template and are generated only by the default workspace.
|
|
87
|
+
|
|
61
88
|
## Generated Workspace
|
|
62
89
|
|
|
63
90
|
The generated `package.json` directly lists the AsciiDoc toolchain:
|
|
@@ -81,6 +108,7 @@ pnpm run clean
|
|
|
81
108
|
```text
|
|
82
109
|
src/ # TypeScript initializer and user runtime source
|
|
83
110
|
templates/default-workspace/ # true source for the generated user workspace
|
|
111
|
+
templates/solo-workspace/ # catalog and README written for the solo workspace
|
|
84
112
|
test/ # maintainer tests
|
|
85
113
|
DESIGN.adoc # maintainer design specification
|
|
86
114
|
.github/ # maintainer CI
|
package/dist/create.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { initWorkspace } from "./init-workspace.js";
|
|
3
|
+
import { initSoloWorkspace, initWorkspace } from "./init-workspace.js";
|
|
4
4
|
function usage() {
|
|
5
5
|
return [
|
|
6
6
|
"Usage:",
|
|
7
|
-
" create-asciidoc-multi-book-workspace <target>",
|
|
7
|
+
" create-asciidoc-multi-book-workspace <target> [--solo] [--force]",
|
|
8
|
+
"",
|
|
9
|
+
"Options:",
|
|
10
|
+
" --solo one book only: the structured writing conventions sample, shelved as",
|
|
11
|
+
" books/01-structured-writing-conventions",
|
|
12
|
+
" --force write into a non-empty target; template output paths are overwritten,",
|
|
13
|
+
" other files are kept",
|
|
8
14
|
"",
|
|
9
15
|
"Examples:",
|
|
10
16
|
" pnpm create asciidoc-multi-book-workspace my-books",
|
|
17
|
+
" pnpm create asciidoc-multi-book-workspace my-book --solo",
|
|
11
18
|
" npm create asciidoc-multi-book-workspace@latest my-books"
|
|
12
19
|
].join("\n");
|
|
13
20
|
}
|
|
@@ -23,7 +30,8 @@ async function main() {
|
|
|
23
30
|
process.exitCode = 1;
|
|
24
31
|
return;
|
|
25
32
|
}
|
|
26
|
-
const
|
|
33
|
+
const init = args.includes("--solo") ? initSoloWorkspace : initWorkspace;
|
|
34
|
+
const result = await init({ targetDir: target, force: args.includes("--force") });
|
|
27
35
|
const displayTarget = path.relative(process.cwd(), result.targetDir) || ".";
|
|
28
36
|
console.log(`Created AsciiDoc multi-book workspace in ${displayTarget}`);
|
|
29
37
|
console.log("");
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { initWorkspace } from "./init-workspace.js";
|
|
1
|
+
export { initSoloWorkspace, initWorkspace } from "./init-workspace.js";
|
|
2
2
|
export type { InitWorkspaceOptions, InitWorkspaceResult } from "./init-workspace.js";
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { initWorkspace } from "./init-workspace.js";
|
|
1
|
+
export { initSoloWorkspace, initWorkspace } from "./init-workspace.js";
|
package/dist/init-workspace.d.ts
CHANGED
package/dist/init-workspace.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { cp, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import {
|
|
4
|
+
import { defaultWorkspaceCopies, packageJsonTemplate, soloWorkspaceCopies, templatePackageNamePlaceholder } from "./template-manifest.js";
|
|
5
5
|
const sourceDir = path.dirname(fileURLToPath(import.meta.url));
|
|
6
6
|
const packageRoot = path.resolve(sourceDir, "..");
|
|
7
|
-
const
|
|
7
|
+
const templatesRoot = path.join(packageRoot, "templates");
|
|
8
8
|
const runtimeScript = path.join(packageRoot, "dist", "runtime", "adoc-books.mjs");
|
|
9
9
|
async function exists(filePath) {
|
|
10
10
|
try {
|
|
@@ -32,27 +32,29 @@ function safePackageName(rawName) {
|
|
|
32
32
|
.replaceAll(/^-+|-+$/g, "");
|
|
33
33
|
return normalized || "asciidoc-books";
|
|
34
34
|
}
|
|
35
|
-
async function
|
|
36
|
-
await cp(path.join(templateRoot, name), path.join(targetDir, name), {
|
|
37
|
-
force: true,
|
|
38
|
-
recursive: true
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
export async function initWorkspace(options) {
|
|
35
|
+
async function createWorkspace(options, copies) {
|
|
42
36
|
const targetDir = path.resolve(options.targetDir);
|
|
43
37
|
const targetExists = await exists(targetDir);
|
|
44
38
|
if (targetExists && !options.force && !await isEmptyDirectory(targetDir)) {
|
|
45
39
|
throw new Error(`target directory is not empty: ${targetDir}`);
|
|
46
40
|
}
|
|
47
41
|
await mkdir(targetDir, { recursive: true });
|
|
48
|
-
for (const
|
|
49
|
-
await
|
|
42
|
+
for (const { from, to } of copies) {
|
|
43
|
+
await cp(path.join(templatesRoot, from), path.join(targetDir, to), {
|
|
44
|
+
force: true,
|
|
45
|
+
recursive: true
|
|
46
|
+
});
|
|
50
47
|
}
|
|
51
|
-
const packageTemplate = await readFile(path.join(
|
|
48
|
+
const packageTemplate = await readFile(path.join(templatesRoot, packageJsonTemplate), "utf8");
|
|
52
49
|
const packageName = safePackageName(path.basename(targetDir));
|
|
53
50
|
await writeFile(path.join(targetDir, "package.json"), packageTemplate.replaceAll(templatePackageNamePlaceholder, packageName), "utf8");
|
|
54
|
-
await writeFile(path.join(targetDir, ".gitignore"), await readFile(path.join(templateRoot, "gitignore.template"), "utf8"), "utf8");
|
|
55
51
|
await mkdir(path.join(targetDir, "tools"), { recursive: true });
|
|
56
52
|
await cp(runtimeScript, path.join(targetDir, "tools", "adoc-books.mjs"), { force: true });
|
|
57
53
|
return { targetDir, packageName };
|
|
58
54
|
}
|
|
55
|
+
export async function initWorkspace(options) {
|
|
56
|
+
return createWorkspace(options, defaultWorkspaceCopies);
|
|
57
|
+
}
|
|
58
|
+
export async function initSoloWorkspace(options) {
|
|
59
|
+
return createWorkspace(options, soloWorkspaceCopies);
|
|
60
|
+
}
|
|
@@ -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,112 @@ 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
|
+
// aat 一侧对留空的位置属性同样交 null,它的类型声明没有写出来。
|
|
196
|
+
attributes: includeAttributes(attributes),
|
|
197
|
+
sourcePath
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
202
|
+
throw new Error(`include resolver '${resolver.prefix}' failed for ${location}: ${message}`, { cause: error });
|
|
203
|
+
}
|
|
204
|
+
if (typeof objectValue(resolution).then === "function") {
|
|
205
|
+
// 不再等它:它若 reject,没人接住就成了进程级的未处理 rejection。
|
|
206
|
+
Promise.resolve(resolution).catch(() => { });
|
|
207
|
+
throw new Error(`include resolver '${resolver.prefix}' returned a Promise for ${location}; ` +
|
|
208
|
+
"the HTML build runs on Asciidoctor.js 3, whose include processors are synchronous");
|
|
209
|
+
}
|
|
210
|
+
const { text, absolutePath, startLine } = checkedResolution(resolution, config.documentRoot, `include resolver '${resolver.prefix}'`, location);
|
|
211
|
+
// 显示用的路径照 Asciidoctor 自己 include 文件时的写法,相对文档的 base_dir。
|
|
212
|
+
const displayPath = path.relative(document.getBaseDir(), absolutePath).split(path.sep).join("/");
|
|
213
|
+
reader.pushInclude(text, absolutePath, displayPath, startLine, attributes);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// 解析器交回的是来源文件里从 startLine 起连续的整行。这里逐行核对:两侧读同一份行的前提是 origin 就是 text 的
|
|
219
|
+
// 出处——aat 把 origin 指向的文件全文收进复制纯文本,HTML 只用 text,两者对不上时构建也会成功,读者却看到两份
|
|
220
|
+
// 不同的东西(aat 对此只报 warning)。
|
|
221
|
+
function checkedResolution(resolution, documentRoot, who, location) {
|
|
222
|
+
const shaped = objectValue(resolution);
|
|
223
|
+
const origin = objectValue(shaped.origin);
|
|
224
|
+
const startLine = origin.startLine;
|
|
225
|
+
if (typeof shaped.text !== "string" || typeof origin.path !== "string" || !Number.isInteger(startLine) || startLine < 1) {
|
|
226
|
+
throw new Error(`${who} returned an invalid result for ${location}; expected { text, origin: { path, startLine } }`);
|
|
227
|
+
}
|
|
228
|
+
const absolutePath = path.resolve(documentRoot, origin.path);
|
|
229
|
+
// 来源是根之内的一个文件,不能是根本身;aat 同样拒绝。
|
|
230
|
+
if (absolutePath === documentRoot || !isWithin(documentRoot, absolutePath)) {
|
|
231
|
+
throw new Error(`${who} returned a source outside documentRoot ${documentRoot} for ${location}: ${absolutePath}`);
|
|
232
|
+
}
|
|
233
|
+
const text = withoutFinalNewline(shaped.text);
|
|
234
|
+
if (text !== "") {
|
|
235
|
+
const lines = text.split(/\r?\n/u);
|
|
236
|
+
let source;
|
|
237
|
+
try {
|
|
238
|
+
source = readFileSync(absolutePath, "utf8");
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
242
|
+
throw new Error(`${who} returned a source that cannot be read for ${location}: ${message}`, { cause: error });
|
|
243
|
+
}
|
|
244
|
+
const sourceLines = withoutFinalNewline(source).split(/\r?\n/u);
|
|
245
|
+
const first = startLine - 1;
|
|
246
|
+
const mismatch = lines.findIndex((line, index) => sourceLines[first + index] !== line);
|
|
247
|
+
if (mismatch !== -1) {
|
|
248
|
+
const lineNumber = first + mismatch + 1;
|
|
249
|
+
const detail = lineNumber > sourceLines.length
|
|
250
|
+
? `the file has only ${sourceLines.length} lines`
|
|
251
|
+
: `line ${lineNumber} is ${shown(sourceLines[lineNumber - 1])}, the resolver returned ${shown(lines[mismatch])}`;
|
|
252
|
+
throw new Error(`${who} returned text that is not lines ${first + 1}-${first + lines.length} of ${absolutePath} for ${location}: ${detail}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { text, absolutePath, startLine: startLine };
|
|
256
|
+
}
|
|
257
|
+
// 末尾的换行是最后一行的结束,不是多出来的一行:留着会让 Reader 多切出一个空行(aat 同样去掉一个)。来源文件按同一
|
|
258
|
+
// 规则切行,文件最后一行之后才不会多出一个并不存在的空行。
|
|
259
|
+
function withoutFinalNewline(text) {
|
|
260
|
+
return text.replace(/\r?\n$/u, "");
|
|
261
|
+
}
|
|
262
|
+
// 消息里的一行:截短,BOM 与控制字符写成可见的转义,行尾空白看得出来。
|
|
263
|
+
function shown(line) {
|
|
264
|
+
const clipped = line.length > 60 ? `${line.slice(0, 60)}…` : line;
|
|
265
|
+
return JSON.stringify(clipped).replaceAll("", "\\uFEFF");
|
|
266
|
+
}
|
|
267
|
+
// Asciidoctor.js 3 把位置属性放在 $positional 列表里;aat 跑在 4 上,位置属性以 "1"、"2" 为键。
|
|
268
|
+
// 解析器只认后一种形状,同一个解析器才能服务两侧。留空的位置属性([,x])在 3 里是 undefined、在 4 里是 null,
|
|
269
|
+
// 这里交 null;中间留空([a,,b])时 3 已把空位丢掉、编号与 4 不同,无从还原,规约写明位置属性不能留空。
|
|
270
|
+
function includeAttributes(attributes) {
|
|
271
|
+
const { $positional, ...named } = attributes;
|
|
272
|
+
const positional = Array.isArray($positional) ? $positional : [];
|
|
273
|
+
return {
|
|
274
|
+
...Object.fromEntries(positional.map((value, index) => [String(index + 1), value === undefined ? null : String(value)])),
|
|
275
|
+
...Object.fromEntries(Object.entries(named).map(([name, value]) => [name, String(value)]))
|
|
276
|
+
};
|
|
277
|
+
}
|
|
141
278
|
function shouldFetchDiagrams() {
|
|
142
279
|
const value = process.env[FETCH_DIAGRAMS_ENV];
|
|
143
280
|
return value === "1" || value === "true";
|
|
@@ -181,7 +318,7 @@ function formatAsciidoctorMessage(message) {
|
|
|
181
318
|
const text = messageText(message);
|
|
182
319
|
return location ? `${severity}: ${location}: ${text}` : `${severity}: ${text}`;
|
|
183
320
|
}
|
|
184
|
-
function convertFile(asciidoctor, input, outputFile, baseDir, attributes = {}) {
|
|
321
|
+
function convertFile(asciidoctor, extensionRegistry, input, outputFile, baseDir, attributes = {}) {
|
|
185
322
|
const loggerManager = asciidoctor.LoggerManager;
|
|
186
323
|
const defaultLogger = loggerManager.getLogger();
|
|
187
324
|
const memoryLogger = asciidoctor.MemoryLogger.create();
|
|
@@ -192,6 +329,7 @@ function convertFile(asciidoctor, input, outputFile, baseDir, attributes = {}) {
|
|
|
192
329
|
base_dir: baseDir,
|
|
193
330
|
to_file: outputFile,
|
|
194
331
|
mkdirs: true,
|
|
332
|
+
extension_registry: extensionRegistry,
|
|
195
333
|
attributes
|
|
196
334
|
});
|
|
197
335
|
}
|
|
@@ -203,13 +341,13 @@ function convertFile(asciidoctor, input, outputFile, baseDir, attributes = {}) {
|
|
|
203
341
|
throw new Error(`AsciiDoc strict check failed for ${input}\n${messages.map(formatAsciidoctorMessage).join("\n")}`);
|
|
204
342
|
}
|
|
205
343
|
}
|
|
206
|
-
async function buildHtml(rootDir, books, asciidoctor, useKroki, fetchDiagrams) {
|
|
344
|
+
async function buildHtml(rootDir, books, asciidoctor, extensionRegistry, useKroki, fetchDiagrams) {
|
|
207
345
|
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);
|
|
346
|
+
convertFile(asciidoctor, extensionRegistry, path.join(rootDir, "catalog.adoc"), path.join(rootDir, "build", "html", "catalog.html"), rootDir);
|
|
209
347
|
await pruneStaleBookHtmlDirs(rootDir, books);
|
|
210
348
|
for (const book of books) {
|
|
211
349
|
await mkdir(book.htmlOutputDir, { recursive: true });
|
|
212
|
-
convertFile(asciidoctor, book.input, path.join(book.htmlOutputDir, "book.html"), book.bookDir, {
|
|
350
|
+
convertFile(asciidoctor, extensionRegistry, book.input, path.join(book.htmlOutputDir, "book.html"), book.bookDir, {
|
|
213
351
|
...(useKroki && fetchDiagrams ? { "kroki-fetch-diagram": "", "kroki-http-method": "post" } : {})
|
|
214
352
|
});
|
|
215
353
|
}
|
|
@@ -316,50 +454,29 @@ function assertNoToolErrors(bookId, diagnostics) {
|
|
|
316
454
|
}
|
|
317
455
|
}
|
|
318
456
|
}
|
|
319
|
-
function sourceBundleFromDocument(document
|
|
457
|
+
function sourceBundleFromDocument(document) {
|
|
320
458
|
const sourceFiles = Array.isArray(document.sourceFiles) ? document.sourceFiles : [];
|
|
321
459
|
const files = sourceFiles.filter((file) => typeof file.relativePath === "string" && typeof file.raw === "string");
|
|
322
|
-
if (files.length === 0)
|
|
323
|
-
return fallbackSource;
|
|
324
460
|
return files.map((file) => {
|
|
325
461
|
const body = file.raw?.endsWith("\n") ? file.raw : `${file.raw ?? ""}\n`;
|
|
326
|
-
return `// file: ${file.relativePath
|
|
462
|
+
return `// file: ${file.relativePath}\n${body}`;
|
|
327
463
|
}).join("\n");
|
|
328
464
|
}
|
|
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) {
|
|
465
|
+
// 解析失败就让构建失败,不退回只有封面的阅读页:退回时 include 越过 documentRoot 只剩一条 warning,
|
|
466
|
+
// 构建照常成功,没有人发现阅读页坏了(DESIGN.adoc 的 Workspace Contract Checks)。
|
|
467
|
+
async function buildReaderBookData(config, book) {
|
|
352
468
|
let document;
|
|
353
469
|
try {
|
|
354
|
-
document = parseAbundantTree({
|
|
470
|
+
document = await parseAbundantTree({
|
|
355
471
|
sourcePath: book.input,
|
|
356
472
|
mode: "book-entry",
|
|
357
|
-
documentRoot:
|
|
473
|
+
documentRoot: config.documentRoot,
|
|
474
|
+
includeResolvers: config.includeResolvers
|
|
358
475
|
});
|
|
359
476
|
}
|
|
360
477
|
catch (error) {
|
|
361
478
|
const message = error instanceof Error ? error.message : String(error);
|
|
362
|
-
|
|
479
|
+
throw new Error(`${book.bookId}: ${message}`, { cause: error });
|
|
363
480
|
}
|
|
364
481
|
const diagnostics = Array.isArray(document.toolDiagnostics) ? document.toolDiagnostics : [];
|
|
365
482
|
assertNoToolErrors(book.bookId, diagnostics);
|
|
@@ -421,13 +538,13 @@ function buildReaderBookData(rootDir, book, fallbackSource) {
|
|
|
421
538
|
book: {
|
|
422
539
|
id: book.bookId,
|
|
423
540
|
title: pages[0].title,
|
|
424
|
-
entry: path.relative(
|
|
541
|
+
entry: path.relative(config.documentRoot, book.input).split(path.sep).join("/")
|
|
425
542
|
},
|
|
426
543
|
pages
|
|
427
544
|
};
|
|
428
545
|
return {
|
|
429
546
|
pageMap,
|
|
430
|
-
sourceBundle: sourceBundleFromDocument(document
|
|
547
|
+
sourceBundle: sourceBundleFromDocument(document)
|
|
431
548
|
};
|
|
432
549
|
}
|
|
433
550
|
function readerStyles() {
|
|
@@ -943,15 +1060,14 @@ ${readerStyles()}
|
|
|
943
1060
|
<script>${readerScript()}</script>`;
|
|
944
1061
|
return `${html.slice(0, insertAt)}${withReaderScript}${html.slice(insertAt)}`;
|
|
945
1062
|
}
|
|
946
|
-
async function addHomeLinks(rootDir, books,
|
|
1063
|
+
async function addHomeLinks(rootDir, books, config) {
|
|
947
1064
|
const catalog = path.join(rootDir, "build", "html", "catalog.html");
|
|
948
1065
|
for (const book of books) {
|
|
949
1066
|
const htmlFile = path.join(book.htmlOutputDir, "book.html");
|
|
950
1067
|
const html = await readFile(htmlFile, "utf8");
|
|
951
1068
|
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");
|
|
1069
|
+
const readerData = await buildReaderBookData(config, book);
|
|
1070
|
+
await writeFile(htmlFile, addBookControlsToBookHtml(html, href, config.homeLink, readerData.sourceBundle, readerData.pageMap, book.bookId), "utf8");
|
|
955
1071
|
}
|
|
956
1072
|
}
|
|
957
1073
|
async function writeRootIndex(rootDir, rootIndex) {
|
|
@@ -977,27 +1093,12 @@ function extractCatalogBookIds(catalogSource) {
|
|
|
977
1093
|
ids.add(match[1]);
|
|
978
1094
|
return [...ids].sort((a, b) => a.localeCompare(b));
|
|
979
1095
|
}
|
|
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
1096
|
function issue(code, detail) {
|
|
999
1097
|
return { code, detail };
|
|
1000
1098
|
}
|
|
1099
|
+
// xref 的目标与锚点不在这里查:Asciidoctor 把它们写成 HTML 里的链接与 ID,missingLocalResources 在生成的 HTML 上
|
|
1100
|
+
// 核对(DESIGN.adoc 的 Workspace Contract Checks)。ID 有 [#id]、[.role#id]、[[id]]、[id=id] 与标题生成等写法,
|
|
1101
|
+
// 只有 Asciidoctor 认得全。
|
|
1001
1102
|
async function workspaceContractIssues(rootDir, books) {
|
|
1002
1103
|
const issues = [];
|
|
1003
1104
|
const catalogSource = await readIfExists(path.join(rootDir, "catalog.adoc"));
|
|
@@ -1014,31 +1115,8 @@ async function workspaceContractIssues(rootDir, books) {
|
|
|
1014
1115
|
}
|
|
1015
1116
|
for (const book of books) {
|
|
1016
1117
|
const bookSource = await readFile(book.input, "utf8");
|
|
1017
|
-
const allSource = await combinedBookSource(book.bookDir);
|
|
1018
|
-
const attributes = attributesFromBookSource(bookSource);
|
|
1019
1118
|
if (!/^:doctype:\s+book$/m.test(bookSource))
|
|
1020
1119
|
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
1120
|
}
|
|
1043
1121
|
return issues;
|
|
1044
1122
|
}
|
|
@@ -1125,9 +1203,9 @@ export async function buildWorkspace(rootDir = process.cwd()) {
|
|
|
1125
1203
|
const useKroki = await workspaceUsesDiagrams(rootDir, books);
|
|
1126
1204
|
const fetchDiagrams = shouldFetchDiagrams();
|
|
1127
1205
|
const asciidoctor = createAsciidoctor(useKroki);
|
|
1128
|
-
await buildHtml(rootDir, books, asciidoctor, useKroki, fetchDiagrams);
|
|
1206
|
+
await buildHtml(rootDir, books, asciidoctor, includeResolverRegistry(asciidoctor, config), useKroki, fetchDiagrams);
|
|
1129
1207
|
await copyAssets(rootDir, books);
|
|
1130
|
-
await addHomeLinks(rootDir, books, config
|
|
1208
|
+
await addHomeLinks(rootDir, books, config);
|
|
1131
1209
|
await writeRootIndex(rootDir, config.rootIndex);
|
|
1132
1210
|
await assertNoIssues("HTML local resource check", await missingLocalResources(rootDir));
|
|
1133
1211
|
await assertNoIssues("workspace contract check", await workspaceContractIssues(rootDir, books));
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
-
export
|
|
1
|
+
export interface TemplateCopy {
|
|
2
|
+
/** Path under `templates/`. */
|
|
3
|
+
from: string;
|
|
4
|
+
/** Path inside the generated workspace. */
|
|
5
|
+
to: string;
|
|
6
|
+
}
|
|
2
7
|
export declare const templatePackageNamePlaceholder = "{{name}}";
|
|
3
|
-
export declare const
|
|
8
|
+
export declare const packageJsonTemplate = "default-workspace/package.json.template";
|
|
9
|
+
export declare const defaultWorkspaceCopies: readonly TemplateCopy[];
|
|
10
|
+
export declare const soloWorkspaceCopies: readonly TemplateCopy[];
|
|
@@ -1,11 +1,26 @@
|
|
|
1
|
-
export const defaultWorkspaceTemplate = "default-workspace";
|
|
2
1
|
export const templatePackageNamePlaceholder = "{{name}}";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"
|
|
2
|
+
// Rendered with the target's package name instead of copied, so it stays outside the copy
|
|
3
|
+
// tables; every workspace shape renders this same file.
|
|
4
|
+
export const packageJsonTemplate = "default-workspace/package.json.template";
|
|
5
|
+
// A copy table is the complete list of template content a workspace shape receives;
|
|
6
|
+
// the initializer adds only the rendered package.json and tools/adoc-books.mjs.
|
|
7
|
+
// `gitignore.template` exists because npm drops `.gitignore` files from published packages.
|
|
8
|
+
export const defaultWorkspaceCopies = [
|
|
9
|
+
{ from: "default-workspace/README.md", to: "README.md" },
|
|
10
|
+
{ from: "default-workspace/catalog.adoc", to: "catalog.adoc" },
|
|
11
|
+
{ from: "default-workspace/books", to: "books" },
|
|
12
|
+
{ from: "default-workspace/shared", to: "shared" },
|
|
13
|
+
{ from: "default-workspace/gitignore.template", to: ".gitignore" }
|
|
14
|
+
];
|
|
15
|
+
// One book on its own shelf: sample book 07 is shelved as 01. The catalog and README are
|
|
16
|
+
// written for this shelf; shared/images/ stays behind because only the sample catalog uses it.
|
|
17
|
+
export const soloWorkspaceCopies = [
|
|
18
|
+
{ from: "solo-workspace/README.md", to: "README.md" },
|
|
19
|
+
{ from: "solo-workspace/catalog.adoc", to: "catalog.adoc" },
|
|
20
|
+
{
|
|
21
|
+
from: "default-workspace/books/07-structured-writing-conventions",
|
|
22
|
+
to: "books/01-structured-writing-conventions"
|
|
23
|
+
},
|
|
24
|
+
{ from: "default-workspace/shared/attributes.adoc", to: "shared/attributes.adoc" },
|
|
25
|
+
{ from: "default-workspace/gitignore.template", to: ".gitignore" }
|
|
11
26
|
];
|
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.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.1
|
|
57
|
+
"asciidoc-abundant-tree": "^0.2.1",
|
|
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
|
|
|
@@ -44,9 +44,15 @@
|
|
|
44
44
|
|`[.example, owner=writing-team]`
|
|
45
45
|
|role 和 named attributes 保留标题的作用与附加字段。
|
|
46
46
|
|
|
47
|
+
|`[.convention, declares=roles]`
|
|
48
|
+
|约定声明。标题下的描述列表是本书的 role 词表;`declares` 还可以取 `relations`、`values`(配 `for` 与 `field`)和 `fields`(配 `for`)。
|
|
49
|
+
|
|
47
50
|
|`summary::` + 下一行说明文本
|
|
48
51
|
|标题下描述列表字段。用于较多字段或多行字段值。
|
|
49
52
|
|
|
53
|
+
|字段文字之后的 `+` + 块
|
|
54
|
+
|把代码块、表格等挂到字段上,块属于字段值。字段之间不插入不带 `+` 的块。
|
|
55
|
+
|
|
50
56
|
|`pass:[xref:标题[显示文本]]`
|
|
51
57
|
|显示为链接,投影为 references。
|
|
52
58
|
|
|
@@ -68,7 +68,8 @@
|
|
|
68
68
|
└─ 是:评估语境连续性。
|
|
69
69
|
|
|
70
70
|
2. 当前子标题承载的认知角色是否与父标题声明的 role 相同?
|
|
71
|
-
├─
|
|
71
|
+
├─ 是:写作时可以不重复声明 role,读者按语境理解为同一角色范围。
|
|
72
|
+
│ 工具只读标题自己写下的 role:想让检查核对它的字段、让查询列出它,就自己写上 role。流程结束。
|
|
72
73
|
└─ 否:从受控词表中选取匹配新语境的 role,显式写入当前标题 attrlist。
|
|
73
74
|
该声明完成语境切换。流程结束。
|
|
74
75
|
----
|
|
@@ -116,8 +117,37 @@
|
|
|
116
117
|
│ 例如 `[owner=writing-team, status=active]`。流程结束。
|
|
117
118
|
└─ 字段较多,或包含多行字段值:
|
|
118
119
|
写入紧随标题下方的第一个连续描述列表。
|
|
119
|
-
例如 `key:: value
|
|
120
|
+
例如 `key:: value`。进入块评估。
|
|
121
|
+
|
|
122
|
+
3. 字段值是否需要附带代码块、表格或另一段正文?
|
|
123
|
+
├─ 是:在字段文字之后写一行 `+`,再写块;块属于字段值。
|
|
124
|
+
│ 字段之间不插入不带 `+` 的块,否则字段列表在块前结束。流程结束。
|
|
125
|
+
└─ 否:流程结束。
|
|
126
|
+
----
|
|
127
|
+
|
|
128
|
+
== 约定的声明与维护
|
|
129
|
+
|
|
130
|
+
触发场景:: 作者为本书引入或修改 role、rel 谓词、字段取值或字段。
|
|
131
|
+
|
|
132
|
+
[source,text]
|
|
120
133
|
----
|
|
134
|
+
1. 本书是否已有对应的约定声明?
|
|
135
|
+
├─ 是:在该声明的描述列表中增加、修改或删除 term;进入类型评估。
|
|
136
|
+
└─ 否:新建声明标题,放在读者最先读到约定的地方(例如前言里专设一节“本书的书写约定”),
|
|
137
|
+
写 `[.convention, declares=...]`;进入类型评估。
|
|
138
|
+
|
|
139
|
+
2. 声明的是什么?
|
|
140
|
+
├─ role:`declares=roles`。
|
|
141
|
+
├─ rel 谓词:`declares=relations`。
|
|
142
|
+
├─ 某个 role 的附加字段取值:`declares=values, for=<role>, field=<字段名>`。
|
|
143
|
+
└─ 某个 role 的标题下描述列表字段:`declares=fields, for=<role>`。
|
|
144
|
+
|
|
145
|
+
3. term 是否写成写作时使用的那个词?
|
|
146
|
+
├─ 否:去掉点号与反引号,例如 `.concept` 写成 `concept`;回到本步重新判断。
|
|
147
|
+
└─ 是:在描述中写出这个词的含义。流程结束。
|
|
148
|
+
----
|
|
149
|
+
|
|
150
|
+
约定变化时只改声明。检查脚本和查询工具从声明读出词表,不另外维护一份。
|
|
121
151
|
|
|
122
152
|
== 源文件物理编排与编号运算
|
|
123
153
|
|
package/templates/default-workspace/books/07-structured-writing-conventions/backmatter/glossary.adoc
CHANGED
|
@@ -16,3 +16,4 @@ relation predicate:: xref `rel` 字段选择的边谓词。
|
|
|
16
16
|
source heading:: xref 所在的标题。
|
|
17
17
|
target heading:: xref selector 指向的标题。
|
|
18
18
|
surface field:: 源文档表面的附加字段。标题字段可来自标题 attrlist 或标题下描述列表;xref 边字段来自 xref named attributes。
|
|
19
|
+
约定声明:: 带 `.convention` role 的标题。`declares` 字段说明它声明 role、rel 谓词、字段取值还是字段;标题下的描述列表是词表,term 是词,描述是含义。
|
|
@@ -60,6 +60,8 @@ include::parts/030-fields-and-terminology/010-surface-fields.adoc[]
|
|
|
60
60
|
|
|
61
61
|
include::parts/030-fields-and-terminology/020-glossary.adoc[]
|
|
62
62
|
|
|
63
|
+
include::parts/030-fields-and-terminology/030-declaring-conventions.adoc[]
|
|
64
|
+
|
|
63
65
|
[appendix]
|
|
64
66
|
include::backmatter/appendix-a.adoc[]
|
|
65
67
|
|
|
@@ -30,7 +30,31 @@ summary::
|
|
|
30
30
|
示例规则正文从这里开始。
|
|
31
31
|
----
|
|
32
32
|
|
|
33
|
-
`priority:: normal` 和 `summary::` 属于 `示例规则` 标题的附加字段。投影时它们成为标题字段,例如 `aat:priority` 和 `aat:summary
|
|
33
|
+
`priority:: normal` 和 `summary::` 属于 `示例规则` 标题的附加字段。投影时它们成为标题字段,例如 `aat:priority` 和 `aat:summary`;`情况` 这样不是 ASCII 标识符的字段名投影为 `aat:field-` 加百分号编码。
|
|
34
|
+
|
|
35
|
+
字段值需要附带代码块、表格或另一段正文时,用 `+` 把块挂到字段上。挂上来的块属于这个字段的值。
|
|
36
|
+
|
|
37
|
+
[source,asciidoc]
|
|
38
|
+
------
|
|
39
|
+
[.rule, verdict=adapt]
|
|
40
|
+
== 关闭标签
|
|
41
|
+
|
|
42
|
+
情况:: 关闭当前标签。
|
|
43
|
+
规则:: 先结束输入法的组字,再关闭。
|
|
44
|
+
出处:: `editorCommands.ts:835-839`。
|
|
45
|
+
+
|
|
46
|
+
[source,ts]
|
|
47
|
+
----
|
|
48
|
+
closeActiveEditor()
|
|
49
|
+
----
|
|
50
|
+
判定:: 改写。
|
|
51
|
+
------
|
|
52
|
+
|
|
53
|
+
`出处` 的值是出处那一行文字和挂上来的代码块。字段值按作者写下的原文保存:反引号、xref 和 include 指令都保留原样。
|
|
54
|
+
|
|
55
|
+
没有 `+` 的块不属于字段。字段列表在那个块之前结束,块和它之后的内容都是标题的正文;块之后再写的描述列表也不再是字段。字段之间需要代码时,用 `+` 挂上,不要直接插入代码块。
|
|
56
|
+
|
|
57
|
+
另有三种写法让整串描述列表都不算字段,标题一个字段也得不到:标题与描述列表之间有别的块(段落、提示块、代码块、图片等);描述列表带 style(如 `[horizontal]`)、ID、块标题或其他块属性;某个条目里嵌套了描述列表。这时描述列表是标题的正文。反过来,要让标题正下方的描述列表是正文而不是字段,就在它前面放一段导语,或给它加 `[horizontal]` 等 style。
|
|
34
58
|
|
|
35
59
|
字段名和字段值来自本书自己的约定。同一本书使用附加字段时,应保持字段写法和字段含义稳定。
|
|
36
60
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
[#declaring-conventions]
|
|
2
|
+
== 声明本书的约定
|
|
3
|
+
|
|
4
|
+
一本书采用的 role、rel、字段取值和字段构成它的约定。约定写在书里:读者从这里知道每个词的含义,检查脚本和查询工具从同一处读出受控词表。词表只写这一处,书和工具不会各说各的。
|
|
5
|
+
|
|
6
|
+
声明约定的标题带 `.convention` role 和 `declares` 字段。声明的内容写成标题下的第一个连续描述列表:每个 term 是一个词,描述是这个词的含义。
|
|
7
|
+
|
|
8
|
+
[source,asciidoc]
|
|
9
|
+
----
|
|
10
|
+
[.convention, declares=roles]
|
|
11
|
+
=== 标题的 role
|
|
12
|
+
|
|
13
|
+
concept:: 概念:本书使用的一个词及其含义。
|
|
14
|
+
rule:: 规则:一条可检查的写法规定。
|
|
15
|
+
example:: 示例:用一个具体场景说明抽象判断。
|
|
16
|
+
convention:: 约定:声明本书的 role、rel、字段取值或字段。
|
|
17
|
+
|
|
18
|
+
[.convention, declares=relations]
|
|
19
|
+
=== 关系谓词
|
|
20
|
+
|
|
21
|
+
depends-on:: 当前标题的判断、规则或操作以目标标题为依据。
|
|
22
|
+
illustrates:: 当前标题提供目标标题的示例。
|
|
23
|
+
----
|
|
24
|
+
|
|
25
|
+
`declares` 的取值说明描述列表里的 term 是什么:
|
|
26
|
+
|
|
27
|
+
[horizontal]
|
|
28
|
+
roles:: role 名称。写作时用在 `[.concept]` 的位置。
|
|
29
|
+
relations:: rel 谓词。写作时用在 `rel=depends-on` 的位置。
|
|
30
|
+
values:: 某个 role 的某个附加字段允许的取值。`for` 写这个 role,`field` 写字段名。
|
|
31
|
+
fields:: 某个 role 的标题下描述列表要写出的字段。`for` 写这个 role。这个 role 的标题带显式寻址标签、标签本身又是已有的技术地址时(例如规则的标签同时是测试的名字),可以再写 `id-prefix`,规定标签的开头,例如 `id-prefix=rule-`;一旦写了,这个 role 的每个标题都要写出以它开头的标签。
|
|
32
|
+
|
|
33
|
+
字段取值和字段用同样的写法声明:
|
|
34
|
+
|
|
35
|
+
[source,asciidoc]
|
|
36
|
+
----
|
|
37
|
+
[.convention, declares=values, for=rule, field=verdict]
|
|
38
|
+
==== 规则的判定
|
|
39
|
+
|
|
40
|
+
adopt:: 照搬:理由成立,形式也照搬。
|
|
41
|
+
adapt:: 改写:理由成立,形式换成本项目更顺手的写法。
|
|
42
|
+
skip:: 不搬:只服务于原来的处境。
|
|
43
|
+
|
|
44
|
+
[.convention, declares=fields, for=rule]
|
|
45
|
+
==== 规则的格式
|
|
46
|
+
|
|
47
|
+
情况:: 在什么情况下。
|
|
48
|
+
规则:: 发生什么。
|
|
49
|
+
出处:: 依据的源文件与行号。
|
|
50
|
+
----
|
|
51
|
+
|
|
52
|
+
`规则的判定` 说明:带 `.rule` 的标题写 `verdict` 字段,取值是 `adopt`、`adapt` 或 `skip`。`规则的格式` 说明:带 `.rule` 的标题,标题下的描述列表写出 `情况`、`规则` 和 `出处`。xref:surface-fields[附加字段] 中的 `关闭标签` 就是一条符合这两项声明的规则;它多写的 `判定` 字段不在声明里,声明只规定必须写出的字段。
|
|
53
|
+
|
|
54
|
+
声明的写法:
|
|
55
|
+
|
|
56
|
+
* term 写词本身:写 `concept`,不写 `.concept`,也不加反引号。term 就是写作时使用的那个词。
|
|
57
|
+
* 声明的描述列表紧跟在声明标题之下,与标题之间没有别的块(段落、提示块、图片等);它不带 `[horizontal]` 等 style,也不带 ID、块标题或其他块属性,条目里不嵌套描述列表。违反任何一条,整串描述列表都是正文,声明标题读不出任何词。导语写在上一级标题里。
|
|
58
|
+
* 一个声明标题只声明一种内容。同一个 role 的取值和字段分开声明。
|
|
59
|
+
* `convention` 本身也写进 role 词表,因为声明标题使用这个 role。
|
|
60
|
+
|
|
61
|
+
声明通常放在读者最先读到约定的地方,例如前言里的“本书的书写约定”一节;某个 role 的格式也可以放在最先使用这个 role 的部分。工具按 `.convention` role 找到声明,不依赖位置。
|
|
62
|
+
|
|
63
|
+
检查脚本读出声明以后,对每个带 role 的标题核对,不论它写没写寻址标签:
|
|
64
|
+
|
|
65
|
+
* role 在 role 词表中。
|
|
66
|
+
* `declares=values` 声明的字段写了,取值在列表中。没有声明的附加字段(如 `owner`)不受约束;`.convention` 标题例外,它只带 `declares`、`for`、`field` 和 `id-prefix`。
|
|
67
|
+
* `declares=fields` 声明的字段全部写出。
|
|
68
|
+
* 声明了 `id-prefix` 时,标题写出寻址标签,标签以它开头。
|
|
69
|
+
|
|
70
|
+
正文中的每个 xref,若带 `rel`,谓词在关系谓词词表中。约定变化时只改声明,检查跟着变。
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
字段补充标题与引用已经表达的内容;术语表统一全书采用的名称;约定声明把本书的 role、rel 和字段写成工具可读的词表。
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# My Books
|
|
2
|
+
|
|
3
|
+
An AsciiDoc multi-book workspace that starts with one book: `books/01-structured-writing-conventions`.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm install
|
|
9
|
+
pnpm run build # writes build/html/index.html
|
|
10
|
+
pnpm run check # checks the catalog, books, xrefs, anchors, and local HTML resources
|
|
11
|
+
pnpm run clean # removes build/
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
To fetch diagram images into the local HTML output instead of linking Kroki URLs:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
ADOC_BOOKS_FETCH_DIAGRAMS=1 pnpm run build
|
|
18
|
+
```
|
|
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
|
+
|
|
36
|
+
## Add A Book
|
|
37
|
+
|
|
38
|
+
Copy `books/01-structured-writing-conventions` to `books/<NN-book-id>`, edit its `book.adoc`, then add an
|
|
39
|
+
`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.
|