create-asciidoc-multi-book-workspace 0.1.12 → 0.2.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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-06-23
4
+
5
+ ### Added
6
+
7
+ - Make generated workspace builds fail when Asciidoctor reports parser diagnostics, including missing `include::` targets that previously only appeared on stderr.
8
+ - Extend generated HTML validation to fail on missing same-page and cross-page fragment anchors, not just missing local files.
9
+
10
+ ### Fixed
11
+
12
+ - Escape quick-reference `xref` syntax examples in the structured writing sample so example text does not render as broken links.
13
+
14
+ ## 0.1.13 - 2026-06-21
15
+
16
+ ### Changed
17
+
18
+ - Expand the structured writing conventions sample with a decision-tree appendix for heading topology, stable IDs, roles, xrefs, surface fields, source numbering, index terms, and syntax pass-through choices.
19
+ - Update the structured writing quick reference and default semantics guidance to point authors toward the new decision-tree workflow.
20
+
3
21
  ## 0.1.12 - 2026-06-19
4
22
 
5
23
  ### Changed
@@ -10,7 +10,8 @@ const DIAGRAM_BLOCK_PATTERN = /^\[(?:actdiag|blockdiag|bpmn|bytefield|c4plantuml
10
10
  const CATALOG_BOOK_XREF_PATTERN = /xref:books\/([^/\]]+)\/book\.adoc(?:#[^\[]+)?\[/g;
11
11
  const XREF_PATTERN = /xref:([^\[#]+)(?:#([A-Za-z0-9_-]+))?\[/g;
12
12
  const ANCHOR_PATTERN = /^\[#([A-Za-z0-9_-]+)(?:[.,][^\]]*)?\]$/gm;
13
- const LOCAL_TARGET_PATTERN = /\b(?:href|src)="([^"]+)"/g;
13
+ const LOCAL_TARGET_PATTERN = /\b(href|src)="([^"]+)"/g;
14
+ const HTML_ANCHOR_PATTERN = /\b(?:id|name)="([^"]+)"/g;
14
15
  const SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
15
16
  const HOME_MARKER = "data-multi-book-home";
16
17
  const CONTROLS_MARKER = "data-multi-book-controls";
@@ -153,14 +154,54 @@ async function pruneStaleBookHtmlDirs(rootDir, books) {
153
154
  }
154
155
  }
155
156
  }
157
+ function messageText(message) {
158
+ if (typeof message?.getText === "function")
159
+ return message.getText();
160
+ if (typeof message?.getMessage === "function")
161
+ return message.getMessage();
162
+ return String(message);
163
+ }
164
+ function messageLocation(message) {
165
+ const sourceLocation = typeof message?.getSourceLocation === "function" ? message.getSourceLocation() : undefined;
166
+ if (!sourceLocation)
167
+ return "";
168
+ const sourcePath = typeof sourceLocation.getPath === "function" ? sourceLocation.getPath() : undefined;
169
+ const lineNumber = typeof sourceLocation.getLineNumber === "function" ? sourceLocation.getLineNumber() : undefined;
170
+ if (sourcePath && lineNumber)
171
+ return `${sourcePath}: line ${lineNumber}`;
172
+ if (sourcePath)
173
+ return sourcePath;
174
+ if (lineNumber)
175
+ return `line ${lineNumber}`;
176
+ return "";
177
+ }
178
+ function formatAsciidoctorMessage(message) {
179
+ const severity = typeof message?.getSeverity === "function" ? message.getSeverity() : "UNKNOWN";
180
+ const location = messageLocation(message);
181
+ const text = messageText(message);
182
+ return location ? `${severity}: ${location}: ${text}` : `${severity}: ${text}`;
183
+ }
156
184
  function convertFile(asciidoctor, input, outputFile, baseDir, attributes = {}) {
157
- asciidoctor.convertFile(input, {
158
- safe: "unsafe",
159
- base_dir: baseDir,
160
- to_file: outputFile,
161
- mkdirs: true,
162
- attributes
163
- });
185
+ const loggerManager = asciidoctor.LoggerManager;
186
+ const defaultLogger = loggerManager.getLogger();
187
+ const memoryLogger = asciidoctor.MemoryLogger.create();
188
+ loggerManager.setLogger(memoryLogger);
189
+ try {
190
+ asciidoctor.convertFile(input, {
191
+ safe: "unsafe",
192
+ base_dir: baseDir,
193
+ to_file: outputFile,
194
+ mkdirs: true,
195
+ attributes
196
+ });
197
+ }
198
+ finally {
199
+ loggerManager.setLogger(defaultLogger);
200
+ }
201
+ const messages = memoryLogger.getMessages();
202
+ if (messages.length > 0) {
203
+ throw new Error(`AsciiDoc strict check failed for ${input}\n${messages.map(formatAsciidoctorMessage).join("\n")}`);
204
+ }
164
205
  }
165
206
  async function buildHtml(rootDir, books, asciidoctor, useKroki, fetchDiagrams) {
166
207
  await mkdir(path.join(rootDir, "build", "html"), { recursive: true });
@@ -1007,27 +1048,60 @@ async function collectHtmlFiles(dir) {
1007
1048
  function extractLocalTargets(html) {
1008
1049
  const targets = [];
1009
1050
  for (const match of html.matchAll(LOCAL_TARGET_PATTERN)) {
1010
- const rawTarget = match[1];
1011
- if (rawTarget === "" || rawTarget.startsWith("#") || rawTarget.startsWith("//") || SCHEME_PATTERN.test(rawTarget)) {
1051
+ const [, attribute, rawTarget] = match;
1052
+ if (rawTarget === "" || rawTarget === "#" || rawTarget.startsWith("//") || SCHEME_PATTERN.test(rawTarget)) {
1012
1053
  continue;
1013
1054
  }
1014
1055
  const targetWithoutFragment = rawTarget.split("#", 1)[0].split("?", 1)[0];
1015
- if (targetWithoutFragment !== "")
1016
- targets.push(rawTarget);
1056
+ const fragment = rawTarget.includes("#") ? rawTarget.split("#", 2)[1].split("?", 1)[0] : "";
1057
+ if (targetWithoutFragment !== "" || fragment !== "")
1058
+ targets.push({ attribute, rawTarget });
1017
1059
  }
1018
1060
  return targets;
1019
1061
  }
1062
+ function decodeUrlFragment(fragment) {
1063
+ try {
1064
+ return decodeURIComponent(fragment);
1065
+ }
1066
+ catch {
1067
+ return fragment;
1068
+ }
1069
+ }
1070
+ function htmlAnchors(html) {
1071
+ const anchors = new Set();
1072
+ for (const match of html.matchAll(HTML_ANCHOR_PATTERN))
1073
+ anchors.add(match[1]);
1074
+ return anchors;
1075
+ }
1076
+ async function anchorsForHtmlFile(filePath, cache) {
1077
+ const cached = cache.get(filePath);
1078
+ if (cached)
1079
+ return cached;
1080
+ const anchors = htmlAnchors(await readFile(filePath, "utf8"));
1081
+ cache.set(filePath, anchors);
1082
+ return anchors;
1083
+ }
1020
1084
  async function missingLocalResources(rootDir) {
1021
1085
  const htmlDir = path.join(rootDir, "build", "html");
1022
1086
  const htmlFiles = await collectHtmlFiles(htmlDir);
1023
1087
  const issues = [];
1088
+ const anchorCache = new Map();
1024
1089
  for (const htmlFile of htmlFiles) {
1025
1090
  const html = await readFile(htmlFile, "utf8");
1026
1091
  for (const target of extractLocalTargets(html)) {
1027
- const targetPath = target.split("#", 1)[0].split("?", 1)[0];
1028
- const resolved = path.resolve(path.dirname(htmlFile), targetPath);
1029
- if (!await existsFile(resolved)) {
1030
- issues.push(issue("HTML_RESOURCE_MISSING", `${path.relative(htmlDir, htmlFile)} -> ${target}`));
1092
+ const targetPath = target.rawTarget.split("#", 1)[0].split("?", 1)[0];
1093
+ const fragment = target.rawTarget.includes("#") ? target.rawTarget.split("#", 2)[1].split("?", 1)[0] : "";
1094
+ const resolved = targetPath === "" ? htmlFile : path.resolve(path.dirname(htmlFile), targetPath);
1095
+ if (targetPath !== "" && !await existsFile(resolved)) {
1096
+ issues.push(issue("HTML_RESOURCE_MISSING", `${path.relative(htmlDir, htmlFile)} -> ${target.rawTarget}`));
1097
+ continue;
1098
+ }
1099
+ if (target.attribute === "href" && fragment !== "" && (targetPath === "" || resolved.endsWith(".html"))) {
1100
+ const anchors = await anchorsForHtmlFile(resolved, anchorCache);
1101
+ const decodedFragment = decodeUrlFragment(fragment);
1102
+ if (!anchors.has(fragment) && !anchors.has(decodedFragment)) {
1103
+ issues.push(issue("HTML_ANCHOR_MISSING", `${path.relative(htmlDir, htmlFile)} -> ${target.rawTarget}`));
1104
+ }
1031
1105
  }
1032
1106
  }
1033
1107
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-asciidoc-multi-book-workspace",
3
- "version": "0.1.12",
3
+ "version": "0.2.0",
4
4
  "description": "Create a complete AsciiDoc multi-book workspace.",
5
5
  "license": "Apache-2.0",
6
6
  "author": {
@@ -1,3 +1,4 @@
1
+ [#structured-writing-quick-reference]
1
2
  = 附录 A:结构化写法速查
2
3
 
3
4
  [cols="1,2", options="header"]
@@ -47,10 +48,10 @@
47
48
  |`summary::` + 下一行说明文本
48
49
  |标题下描述列表字段。用于较多字段或多行字段值。
49
50
 
50
- |`xref:stable-id[显示文本]`
51
+ |`pass:[xref:stable-id[显示文本]]`
51
52
  |显示为链接,投影为 references。
52
53
 
53
- |`xref:stable-id[显示文本, rel=depends-on]`
54
+ |`pass:[xref:stable-id[显示文本, rel=depends-on]]`
54
55
  |显示为链接,同时声明 depends-on 关系。
55
56
 
56
57
  |`indexterm2:[role]`
@@ -76,4 +77,4 @@ role 描述标题身份。rel 描述 xref 边上的关系谓词。附加字段
76
77
 
77
78
  如果遇到“代码/日志”与其“文字解释”需要分离的场景,可以考虑使用 Asciidoc 的 Callouts。
78
79
 
79
- TIP: 在你写作的过程中,你可以思考有哪些 Asciidoc 语法/表达 适合你当前的所撰写的对象,然后选择合适的并应用。
80
+ 结构化写法的选择由当前对象、引用需求、投影需求和维护成本共同决定。具体判定流程见 xref:structured-writing-decision-trees[附录 B:结构化写法决策树]。
@@ -0,0 +1,166 @@
1
+ [#structured-writing-decision-trees]
2
+ = 附录 B:结构化写法决策树
3
+
4
+ == 标题生成与层级拓扑控制
5
+
6
+ 触发场景:: 作者准备在源文档中创建新的标题节点。
7
+
8
+ [source,text]
9
+ ----
10
+ 1. 当前标题是否为文档大标题?
11
+ ├─ 是:使用 `= 书名`,并放置于文档首行。流程结束。
12
+ └─ 否:评估逻辑层级。
13
+
14
+ 2. 当前标题与紧邻上级标题的层级关系是否连续?
15
+ ├─ 否:补充中间层级标题,保持标题层级连续;回到节点 2 重新评估。
16
+ └─ 是:评估区域划分。
17
+
18
+ 3. 当前标题下的文本区域后续是否需要被精准指向或提及?
19
+ ├─ 是:使用更小一级标题将该区域独立划分,后续用 xref 指向该标题。流程结束。
20
+ └─ 否:评估 section style。
21
+
22
+ 4. 当前标题是否承载附录、术语表、参考文献或索引等后置结构?
23
+ ├─ 是:在等号标题上方声明对应 section style:
24
+ │ `[appendix]`、`[glossary]`、`[bibliography]` 或 `[index]`。
25
+ │ section style 改变紧随其后 section 的语义;标题层级仍由 `=` 数量决定。
26
+ │ 流程结束。
27
+ └─ 否:写入标准等号标题。流程结束。
28
+ ----
29
+
30
+ == stable ID 的生命周期与寻址机制
31
+
32
+ 触发场景:: 作者评估是否需要为当前标题显式声明寻址 ID。
33
+
34
+ [source,text]
35
+ ----
36
+ 1. 当前标题文本是否已是领域内唯一且稳定的标识符?
37
+ ├─ 是:不声明 `[#stable-id]`;直接使用标题文本作为引用地址。流程结束。
38
+ └─ 否:评估交叉引用状态。
39
+
40
+ 2. 该标题在全书范围内是否被至少一个 `xref` 交叉引用?
41
+ ├─ 否:不声明 `[#stable-id]`。流程结束。
42
+ └─ 是:在等号标题上方的 attrlist 中显式声明 `[#stable-id]`。流程结束。
43
+ ----
44
+
45
+ 触发场景:: 项目全局构建或审查阶段检查 stable ID 消费状态。
46
+
47
+ [source,text]
48
+ ----
49
+ 1. 扫描源文件中所有手工显式声明的 `[#stable-id]`。
50
+ ├─ 每个 ID 对应的 xref 引用次数 >= 1:保留声明,审查通过。
51
+ └─ 某个 ID 对应的 xref 引用次数 == 0:移除该 `[#stable-id]` 声明。
52
+ ----
53
+
54
+ == role 的作用域与语境管理
55
+
56
+ 触发场景:: 作者为标题标记认知功能与身份语义。
57
+ 前提:: 当前书稿已经约定 role 受控词表,例如 `.concept`、`.rule`、`.example`。
58
+
59
+ [source,text]
60
+ ----
61
+ 1. 当前标题是否为其父标题的下级标题?
62
+ ├─ 否:从受控词表中选取匹配当前语境的 role,写入标题 attrlist。流程结束。
63
+ └─ 是:评估语境连续性。
64
+
65
+ 2. 当前子标题承载的认知角色是否与父标题声明的 role 相同?
66
+ ├─ 是:不重复声明 role;下级标题默认属于同一角色范围。流程结束。
67
+ └─ 否:从受控词表中选取匹配新语境的 role,显式写入当前标题 attrlist。
68
+ 该声明完成语境切换。流程结束。
69
+ ----
70
+
71
+ == xref 与 rel 关系谓词建立
72
+
73
+ 触发场景:: 作者在正文中写入指向其他标题节点的交叉引用。
74
+
75
+ [source,text]
76
+ ----
77
+ 1. 当前引用是否跨越 `= 书名`、`= 卷/部`、`== 章` 等大跨度指代?
78
+ ├─ 是:手动写入 `xref`,平衡隐式上下文的阅读压力;进入关系评估。
79
+ └─ 否:评估文件位置。
80
+
81
+ 2. 目标标题是否位于不同的物理源文件中?
82
+ ├─ 是:在文档头部定义文档属性存储基础路径;
83
+ │ 后续通过该属性调用基础路径进行跨文件引用;进入关系评估。
84
+ └─ 否:进入关系评估。
85
+
86
+ 3. 引用目的是什么?
87
+ ├─ 表达阅读顺序、先读建议或普通参见:
88
+ │ 写入普通 xref,例如 `xref:target-id[显示文本]`。
89
+ │ 投影为默认谓词 `aat:references`。流程结束。
90
+ └─ 声明明确的逻辑关系边:选取 `rel` 谓词。
91
+
92
+ 4. 根据全书约定的关系谓词选择 `rel`。
93
+ ├─ 当前标题的判断、规则或操作以目标标题为依据:使用 `rel=depends-on`。
94
+ ├─ 当前标题提供目标标题的示例:使用 `rel=illustrates`。
95
+ ├─ 当前标题给出目标标题所代表对象的定义:使用 `rel=defines`。
96
+ └─ 当前标题对目标标题的合法写法或范围施加约束:使用 `rel=constrains`。
97
+ ----
98
+
99
+ == surface field 落位策略
100
+
101
+ 触发场景:: 作者为 heading 节点或 xref 边挂载键值信息。
102
+
103
+ [source,text]
104
+ ----
105
+ 1. 字段描述的主体是什么?
106
+ ├─ xref 引用边:将字段写入 xref 的 named attributes 中。流程结束。
107
+ └─ heading 标题节点:评估字段体积。
108
+
109
+ 2. heading 字段数量和长度是否适合写入标题 attrlist?
110
+ ├─ 字段较少,且字段值较短:写入标题前方 attrlist。
111
+ │ 例如 `[owner=writing-team, status=active]`。流程结束。
112
+ └─ 字段较多,或包含多行字段值:
113
+ 写入紧随标题下方的第一个连续描述列表。
114
+ 例如 `key:: value`。流程结束。
115
+ ----
116
+
117
+ == 源文件物理编排与编号运算
118
+
119
+ 触发场景:: 作者为 Part 目录或 Chapter 源文件生成文件编排号。
120
+ 前提:: Part 目录和 Chapter 文件统一使用三位数 `0X0` 格式;初始序列步长为 10,例如 `010`、`020`、`030`。
121
+
122
+ [source,text]
123
+ ----
124
+ 1. 新文件在逻辑序列中的位置是什么?
125
+ ├─ 位于当前既有序列的最后方:执行末尾追加。
126
+ └─ 位于当前既有文件 A 与文件 B 之间:执行中间插入。
127
+
128
+ 2. 末尾追加:
129
+ 提取当前同级列表中的最大编号,将其十位数字加 1,个位保持 0。
130
+ 流程结束。
131
+
132
+ 3. 中间插入:
133
+ 提取上文文件 A 和下文文件 B 的编号,计算两者的算术中间值。
134
+ 例如在 `010` 与 `020` 之间插入,使用 `015`。
135
+ 流程结束。
136
+ ----
137
+
138
+ == index term 落位
139
+
140
+ 触发场景:: 作者为读者检索和书后索引添加索引入口。
141
+
142
+ [source,text]
143
+ ----
144
+ 1. 索引目标词是否已经自然出现在当前正文句子中?
145
+ ├─ 是:使用正文可见索引词宏 `indexterm2:[<primary>]` 包裹该词。
146
+ │ 正文保持可见,同时提取为一级索引项。流程结束。
147
+ └─ 否:使用正文隐藏索引词宏 `indexterm:[<primary>]`。
148
+ 按需使用多级形式,例如 `indexterm:[<primary>, <secondary>]`。
149
+ 隐藏索引词贴近相关段落内容,放在相关句子末尾。流程结束。
150
+ ----
151
+
152
+ == 语法透传与 callouts
153
+
154
+ 触发场景:: 作者书写代码、日志或工具语法文本。
155
+
156
+ [source,text]
157
+ ----
158
+ 1. 正文中是否出现 AsciiDoc 原生语法字符,且需要阻止其被解析引擎渲染?
159
+ ├─ 是:使用 `pass:[]` 宏包裹该内容。
160
+ │ 若需要代码字体样式,在外层叠加反引号包裹。流程结束。
161
+ └─ 否:评估解释关联。
162
+
163
+ 2. 当前是否为代码或日志结构,且配有单独的文字解释?
164
+ ├─ 是:使用 AsciiDoc callouts 标注代码或日志与解释之间的关联。流程结束。
165
+ └─ 否:正常书写。流程结束。
166
+ ----
@@ -61,6 +61,9 @@ include::parts/030-fields-and-lookup/020-index-and-glossary.adoc[]
61
61
  [appendix]
62
62
  include::backmatter/appendix-a.adoc[]
63
63
 
64
+ [appendix]
65
+ include::backmatter/appendix-b-decision-trees.adoc[]
66
+
64
67
  [glossary]
65
68
  include::backmatter/glossary.adoc[]
66
69
 
@@ -27,7 +27,7 @@
27
27
 
28
28
  [TIP]
29
29
  ====
30
- 1. 需要保证全书里所有出现的稳定地址都最少有1个 `xref` 消费,也就是说可以在需要进行交叉引用时,判断引用目标当前在全书中是否已经有稳定的标识,再确认是否需要手动添加上稳定地址。详细判断规则见 xref:附录 A:结构化写法速查[]。
30
+ 1. 全书中手工声明的稳定地址至少需要一个 `xref` 消费。写法速查见 xref:structured-writing-quick-reference[附录 A:结构化写法速查];完整判断流程见 xref:structured-writing-decision-trees[附录 B:结构化写法决策树]。
31
31
  2. 按照书籍结构,跨越 `= 书名`、`= 卷/部`、`== 章` 这些大跨度的指代均需要使用 `xref` 手动标注,平衡隐式上下文的阅读压力。
32
32
  ====
33
33
 
@@ -45,4 +45,3 @@
45
45
  [.example]
46
46
  === 一个完整示例
47
47
  ----
48
-