@dogsbay/format-asciidoc 0.2.0-beta.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +354 -0
- package/dist/cli.js.map +1 -0
- package/dist/detect.d.ts +47 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +73 -0
- package/dist/detect.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/inline-walker.d.ts +17 -0
- package/dist/inline-walker.d.ts.map +1 -0
- package/dist/inline-walker.js +167 -0
- package/dist/inline-walker.js.map +1 -0
- package/dist/loaders/antora.d.ts +3 -0
- package/dist/loaders/antora.d.ts.map +1 -0
- package/dist/loaders/antora.js +874 -0
- package/dist/loaders/antora.js.map +1 -0
- package/dist/loaders/asciibinder.d.ts +3 -0
- package/dist/loaders/asciibinder.d.ts.map +1 -0
- package/dist/loaders/asciibinder.js +229 -0
- package/dist/loaders/asciibinder.js.map +1 -0
- package/dist/loaders/dominance.d.ts +15 -0
- package/dist/loaders/dominance.d.ts.map +1 -0
- package/dist/loaders/dominance.js +86 -0
- package/dist/loaders/dominance.js.map +1 -0
- package/dist/loaders/master-adoc.d.ts +3 -0
- package/dist/loaders/master-adoc.d.ts.map +1 -0
- package/dist/loaders/master-adoc.js +178 -0
- package/dist/loaders/master-adoc.js.map +1 -0
- package/dist/loaders/plain.d.ts +3 -0
- package/dist/loaders/plain.d.ts.map +1 -0
- package/dist/loaders/plain.js +58 -0
- package/dist/loaders/plain.js.map +1 -0
- package/dist/loaders/types.d.ts +117 -0
- package/dist/loaders/types.d.ts.map +1 -0
- package/dist/loaders/types.js +2 -0
- package/dist/loaders/types.js.map +1 -0
- package/dist/loaders/util.d.ts +15 -0
- package/dist/loaders/util.d.ts.map +1 -0
- package/dist/loaders/util.js +49 -0
- package/dist/loaders/util.js.map +1 -0
- package/dist/parse.d.ts +178 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +150 -0
- package/dist/parse.js.map +1 -0
- package/dist/tree.d.ts +2 -0
- package/dist/tree.d.ts.map +1 -0
- package/dist/tree.js +2 -0
- package/dist/tree.js.map +1 -0
- package/dist/walker.d.ts +8 -0
- package/dist/walker.d.ts.map +1 -0
- package/dist/walker.js +210 -0
- package/dist/walker.js.map +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Antora corpus loader — component mode AND playbook mode (#067).
|
|
3
|
+
*
|
|
4
|
+
* Component mode detects `antora.yml` at the source root. Playbook mode
|
|
5
|
+
* detects `antora-playbook.yml` at the root with local `start_path`s and
|
|
6
|
+
* loads every component (see the PlaybookYml doc below). Reads the
|
|
7
|
+
* component manifest (name + version + nav file list + asciidoc
|
|
8
|
+
* attribute defaults), walks `modules/<name>/pages/**\/*.adoc`, and
|
|
9
|
+
* builds nav from the referenced `nav.adoc` files.
|
|
10
|
+
*
|
|
11
|
+
* **What's covered** (validated on couchbase docs-server + RHSI
|
|
12
|
+
* solution pattern — see docs-dev/antora-support.md for results):
|
|
13
|
+
*
|
|
14
|
+
* - Single-component mode (one antora.yml).
|
|
15
|
+
* - All modules under `modules/<name>/pages/` — ROOT pages get no
|
|
16
|
+
* slug prefix; other modules' pages get a `<module-name>/` prefix.
|
|
17
|
+
* `_`-prefixed files/dirs under pages/ are skipped (Antora publish
|
|
18
|
+
* rule — they're include targets like `_attributes.adoc`).
|
|
19
|
+
* - nav.adoc parsing: depth-based list items (leading whitespace
|
|
20
|
+
* tolerated), `.Section` block titles as top-level groups,
|
|
21
|
+
* `include::[mod:]partial$nav.adoc[]` expansion with per-line module
|
|
22
|
+
* context, module-coordinate + cross-component + external-URL
|
|
23
|
+
* targets, `#fragment` preservation, `{counter:…}` label cleanup.
|
|
24
|
+
* - Per-page attributes: `antora.yml` `asciidoc.attributes:` (soft-set
|
|
25
|
+
* `@` suffix stripped) + the module's `pages/_attributes.adoc` seed
|
|
26
|
+
* + content-rooted `imagesdir` (modern `images/` or legacy
|
|
27
|
+
* `assets/images/` layout, fs-probed per module).
|
|
28
|
+
* - Component name + version surfaced on `LoaderResult.component`.
|
|
29
|
+
* - Body-content translation (module-coordinate xrefs, `family$`
|
|
30
|
+
* resource includes, tag filtering) lives in the engine behind its
|
|
31
|
+
* `antora: true` option — cli.ts sets it when this loader is picked.
|
|
32
|
+
*
|
|
33
|
+
* **Deliberately out of scope (deferred):**
|
|
34
|
+
*
|
|
35
|
+
* - REMOTE playbook sources (git URLs) — a single-repo build cannot
|
|
36
|
+
* fetch them; they warn by name and are skipped. Local start_paths
|
|
37
|
+
* (the circleci shape) are fully supported.
|
|
38
|
+
* - Version axis emission into format-astro's version-axis machinery.
|
|
39
|
+
* Playbook mode instead lays versions out the way Antora's own URLs
|
|
40
|
+
* do (latest bare, older under `<component>/<version>/`), so a
|
|
41
|
+
* reader's links resolve; wiring the axis is a follow-up.
|
|
42
|
+
*
|
|
43
|
+
* Sample antora.yml shape:
|
|
44
|
+
*
|
|
45
|
+
* name: product
|
|
46
|
+
* title: My Product
|
|
47
|
+
* version: '2.0' # null / ~ also valid (unversioned)
|
|
48
|
+
* nav:
|
|
49
|
+
* - modules/ROOT/nav.adoc
|
|
50
|
+
* - modules/admin/nav.adoc
|
|
51
|
+
* asciidoc:
|
|
52
|
+
* attributes:
|
|
53
|
+
* page-component-name: product
|
|
54
|
+
*
|
|
55
|
+
* Sample nav.adoc:
|
|
56
|
+
*
|
|
57
|
+
* * xref:index.adoc[Introduction]
|
|
58
|
+
* * Getting Started
|
|
59
|
+
* ** xref:install.adoc[]
|
|
60
|
+
* ** xref:configure.adoc[Configuration]
|
|
61
|
+
*
|
|
62
|
+
* See plans/asciidoc-corpus-loaders.md "Antora" section.
|
|
63
|
+
*/
|
|
64
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
65
|
+
import { basename, join, relative } from "node:path";
|
|
66
|
+
import YAML from "yaml";
|
|
67
|
+
import { prettifyName } from "./util.js";
|
|
68
|
+
const ANTORA_YML = "antora.yml";
|
|
69
|
+
const ROOT_MODULE = "ROOT";
|
|
70
|
+
const PLAYBOOK_NAMES = ["antora-playbook.yml", "antora-playbook.yaml"];
|
|
71
|
+
function walkPagesUnderModule(modulesDir, moduleName) {
|
|
72
|
+
const pagesDir = join(modulesDir, moduleName, "pages");
|
|
73
|
+
if (!existsSync(pagesDir))
|
|
74
|
+
return [];
|
|
75
|
+
const out = [];
|
|
76
|
+
function walk(dir) {
|
|
77
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
78
|
+
// Antora convention: files and directories whose names start with
|
|
79
|
+
// an underscore under pages/ are not published (e.g. the
|
|
80
|
+
// `_attributes.adoc` include target every page pulls in). They
|
|
81
|
+
// still convert as fragments downstream — just not as pages.
|
|
82
|
+
if (entry.name.startsWith("_"))
|
|
83
|
+
continue;
|
|
84
|
+
const full = join(dir, entry.name);
|
|
85
|
+
if (entry.isDirectory()) {
|
|
86
|
+
walk(full);
|
|
87
|
+
}
|
|
88
|
+
else if (entry.name.endsWith(".adoc")) {
|
|
89
|
+
const relFromPages = relative(pagesDir, full).replace(/\\/g, "/");
|
|
90
|
+
const pageSlug = relFromPages.replace(/\.adoc$/, "");
|
|
91
|
+
// ROOT pages get no module prefix; other modules get one.
|
|
92
|
+
const slug = moduleName === ROOT_MODULE ? pageSlug : `${moduleName}/${pageSlug}`;
|
|
93
|
+
out.push({ filePath: full, slug });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
walk(pagesDir);
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Locate a module's images directory, if any — modern `images/` or the
|
|
102
|
+
* legacy `assets/images/` layout. Returns the content-rooted imagesdir
|
|
103
|
+
* value the engine should prepend to relative image targets (the
|
|
104
|
+
* migrated content tree mirrors the source layout under `modules/`, and
|
|
105
|
+
* format-astro serves every content image at its content-relative URL),
|
|
106
|
+
* or undefined when the module ships no images.
|
|
107
|
+
*/
|
|
108
|
+
function moduleImagesdir(modulesDir, moduleName) {
|
|
109
|
+
if (existsSync(join(modulesDir, moduleName, "images"))) {
|
|
110
|
+
return `/modules/${moduleName}/images`;
|
|
111
|
+
}
|
|
112
|
+
if (existsSync(join(modulesDir, moduleName, "assets", "images"))) {
|
|
113
|
+
return `/modules/${moduleName}/assets/images`;
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Parse simple `:name: value` attribute entries from a module's
|
|
119
|
+
* `pages/_attributes.adoc` (the de-facto Antora starter convention:
|
|
120
|
+
* every page opens with `include::_attributes.adoc[]`). Values keep
|
|
121
|
+
* raw `{ref}` syntax — Minja resolves nested refs downstream. A
|
|
122
|
+
* trailing `@` (Antora's soft-set marker) is stripped.
|
|
123
|
+
*
|
|
124
|
+
* Only plain value entries are read; unsetting (`:!name:`), continuation
|
|
125
|
+
* lines, and conditionals are skipped — this is a best-effort context
|
|
126
|
+
* seed so titles and inline refs resolve at import time, not a full
|
|
127
|
+
* AsciiDoc header parser.
|
|
128
|
+
*/
|
|
129
|
+
function parsePageAttributesFile(path) {
|
|
130
|
+
const out = {};
|
|
131
|
+
let content;
|
|
132
|
+
try {
|
|
133
|
+
content = readFileSync(path, "utf-8");
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
for (const line of content.split("\n")) {
|
|
139
|
+
const m = line.match(/^:([A-Za-z0-9_][A-Za-z0-9_-]*):\s*(.*?)\s*$/);
|
|
140
|
+
if (!m)
|
|
141
|
+
continue;
|
|
142
|
+
out[m[1]] = stripSoftSetSuffix(m[2]);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/** Strip Antora's soft-set `@` suffix from an attribute value. */
|
|
147
|
+
function stripSoftSetSuffix(value) {
|
|
148
|
+
return typeof value === "string" && value.endsWith("@")
|
|
149
|
+
? value.slice(0, -1)
|
|
150
|
+
: value;
|
|
151
|
+
}
|
|
152
|
+
function listModules(sourceDir) {
|
|
153
|
+
const modulesDir = join(sourceDir, "modules");
|
|
154
|
+
if (!existsSync(modulesDir))
|
|
155
|
+
return [];
|
|
156
|
+
try {
|
|
157
|
+
return readdirSync(modulesDir, { withFileTypes: true })
|
|
158
|
+
.filter((e) => e.isDirectory())
|
|
159
|
+
.map((e) => e.name);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const NAV_INCLUDE_MAX_DEPTH = 5;
|
|
166
|
+
/**
|
|
167
|
+
* Expand `include::[module:]partial$<rel>[…]` directives in nav content
|
|
168
|
+
* into the included files' lines — the Antora convention for composing
|
|
169
|
+
* the ROOT nav out of per-module `partials/nav.adoc` files (couchbase's
|
|
170
|
+
* ROOT nav pulls in 15 of them). Missing files warn and drop; module
|
|
171
|
+
* context switches for the included lines so their xrefs resolve under
|
|
172
|
+
* the right module.
|
|
173
|
+
*/
|
|
174
|
+
function expandNavLines(content, moduleName, modulesDir, depth = 0) {
|
|
175
|
+
const out = [];
|
|
176
|
+
if (depth > NAV_INCLUDE_MAX_DEPTH) {
|
|
177
|
+
console.warn("antora loader: nav include depth exceeded, truncating");
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
const includeRe = /^include::(?:([^:\s]+):)?partial\$([^\[\s]+)\[[^\]]*\]\s*$/;
|
|
181
|
+
for (const rawLine of content.split("\n")) {
|
|
182
|
+
const m = rawLine.match(includeRe);
|
|
183
|
+
if (!m) {
|
|
184
|
+
out.push({ line: rawLine, module: moduleName });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const incModule = m[1] || moduleName;
|
|
188
|
+
const incPath = join(modulesDir, incModule, "partials", m[2]);
|
|
189
|
+
if (!existsSync(incPath)) {
|
|
190
|
+
console.warn(`antora loader: nav include not found, skipping: ${incModule}:partial$${m[2]}`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const incContent = readFileSync(incPath, "utf-8");
|
|
195
|
+
out.push(...expandNavLines(incContent, incModule, modulesDir, depth + 1));
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
199
|
+
console.warn(`antora loader: cannot read nav include ${incPath}: ${msg}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Parse expanded nav lines into a NavItem[] tree.
|
|
206
|
+
*
|
|
207
|
+
* Recognised shapes:
|
|
208
|
+
* - `.Section Title` → new top-level group; following items nest
|
|
209
|
+
* - `*+ xref:…[label]` → leaf (leading whitespace tolerated; depth
|
|
210
|
+
* = number of `*`)
|
|
211
|
+
* - `*+ https://…[label]` → external link leaf
|
|
212
|
+
* - `*+ Plain text` → group header (no link)
|
|
213
|
+
*
|
|
214
|
+
* Algorithm: stack-of-parents per depth; a `.Section` line resets the
|
|
215
|
+
* stack with the new group as the root container. Anything else
|
|
216
|
+
* (blank lines, prose, comments) is ignored.
|
|
217
|
+
*/
|
|
218
|
+
function parseNavAdoc(navLines, hrefPrefix) {
|
|
219
|
+
const root = [];
|
|
220
|
+
// Stack[d] = the children-array to push into for a depth-(d+1) item.
|
|
221
|
+
// stack[0] is the current top-level container (root, or the children
|
|
222
|
+
// of the active `.Section` group).
|
|
223
|
+
let stack = [root];
|
|
224
|
+
const itemRe = /^\s*(\*+)\s+(.+)$/;
|
|
225
|
+
const sectionRe = /^\.([^.\s].*)$/;
|
|
226
|
+
for (const { line: rawLine, module } of navLines) {
|
|
227
|
+
const line = rawLine.replace(/\s+$/, "");
|
|
228
|
+
if (line.startsWith("//"))
|
|
229
|
+
continue;
|
|
230
|
+
const sec = line.match(sectionRe);
|
|
231
|
+
if (sec) {
|
|
232
|
+
const group = { label: cleanNavLabel(sec[1]), children: [] };
|
|
233
|
+
root.push(group);
|
|
234
|
+
stack = [group.children];
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const m = line.match(itemRe);
|
|
238
|
+
if (!m)
|
|
239
|
+
continue;
|
|
240
|
+
const depth = m[1].length;
|
|
241
|
+
const text = m[2].trim();
|
|
242
|
+
const item = parseNavItem(text, module, hrefPrefix);
|
|
243
|
+
if (!item)
|
|
244
|
+
continue;
|
|
245
|
+
// Find the parent array — if depth exceeds current stack length,
|
|
246
|
+
// we're going deeper than any actual parent; clamp to current top.
|
|
247
|
+
const parentIdx = Math.min(depth - 1, stack.length - 1);
|
|
248
|
+
const parent = stack[parentIdx];
|
|
249
|
+
parent.push(item);
|
|
250
|
+
// This item becomes the parent for depth+1 items. Allocate its
|
|
251
|
+
// children array up-front (so we can push later); if it stays
|
|
252
|
+
// empty, we strip it before returning.
|
|
253
|
+
if (!item.children)
|
|
254
|
+
item.children = [];
|
|
255
|
+
// Trim deeper-than-current entries; replace stack[depth] with our children.
|
|
256
|
+
stack.length = depth;
|
|
257
|
+
stack.push(item.children);
|
|
258
|
+
}
|
|
259
|
+
// Clean up: drop empty `children: []` arrays so leaves don't look
|
|
260
|
+
// like empty groups to downstream consumers.
|
|
261
|
+
function strip(items) {
|
|
262
|
+
for (const it of items) {
|
|
263
|
+
if (it.children && it.children.length === 0) {
|
|
264
|
+
delete it.children;
|
|
265
|
+
}
|
|
266
|
+
else if (it.children) {
|
|
267
|
+
strip(it.children);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
strip(root);
|
|
272
|
+
return root;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Clean presentation noise out of a nav label: `{counter:x}` /
|
|
276
|
+
* `{counter2:x}` auto-numbering tokens (resolved at render time by
|
|
277
|
+
* Antora — pure numbering, dropped here), any leading run of leftover
|
|
278
|
+
* `{ref}` tokens + separator punctuation that the counter strip
|
|
279
|
+
* orphans (`{module}.{counter:sub} Use cases` → `Use cases`), and a
|
|
280
|
+
* trailing `^` (Antora's open-in-new-window marker).
|
|
281
|
+
*/
|
|
282
|
+
function cleanNavLabel(label) {
|
|
283
|
+
let out = label.replace(/\{counter2?:[^}]*\}/g, "");
|
|
284
|
+
out = out.replace(/^(?:\{[^}]*\}|[\s.)\-:])+/, "");
|
|
285
|
+
out = out.replace(/\^$/, "");
|
|
286
|
+
return out.trim();
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Parse one nav item's text. Returns null for items that should be
|
|
290
|
+
* dropped entirely (currently nothing — cross-component xrefs degrade
|
|
291
|
+
* to label-only entries instead, so the nav structure survives).
|
|
292
|
+
*/
|
|
293
|
+
function parseNavItem(text, moduleName, hrefPrefix) {
|
|
294
|
+
// External link: `https://…[label]` or `link:https://…[label]`.
|
|
295
|
+
const urlMatch = text.match(/^(?:link:)?(https?:\/\/[^\[\s]+)\[([^\]]*)\]$/);
|
|
296
|
+
if (urlMatch) {
|
|
297
|
+
const label = cleanNavLabel(urlMatch[2]) || urlMatch[1];
|
|
298
|
+
return { label, href: urlMatch[1] };
|
|
299
|
+
}
|
|
300
|
+
// xref with any coordinate shape: xref:[version@][component:][module:]path[label]
|
|
301
|
+
const xrefMatch = text.match(/^xref:([^\[\s]+)\[([^\]]*)\]$/);
|
|
302
|
+
if (!xrefMatch) {
|
|
303
|
+
// Plain text — group header.
|
|
304
|
+
return { label: cleanNavLabel(text) };
|
|
305
|
+
}
|
|
306
|
+
let target = xrefMatch[1];
|
|
307
|
+
const rawLabel = xrefMatch[2];
|
|
308
|
+
// Split off a #fragment before extension handling (`index.adoc#use-cases`).
|
|
309
|
+
const hashIdx = target.indexOf("#");
|
|
310
|
+
const frag = hashIdx === -1 ? "" : target.substring(hashIdx);
|
|
311
|
+
if (hashIdx !== -1)
|
|
312
|
+
target = target.substring(0, hashIdx);
|
|
313
|
+
// Drop a version@ prefix (single-version corpus).
|
|
314
|
+
const atIdx = target.indexOf("@");
|
|
315
|
+
if (atIdx !== -1 && target.indexOf(":", atIdx) !== -1) {
|
|
316
|
+
target = target.substring(atIdx + 1);
|
|
317
|
+
}
|
|
318
|
+
const segs = target.split(":");
|
|
319
|
+
const label = cleanNavLabel(rawLabel) ||
|
|
320
|
+
prettifyName(basename(segs[segs.length - 1].replace(/\.adoc$/, "")));
|
|
321
|
+
if (segs.length > 2) {
|
|
322
|
+
// component:module:path — another component. Unresolvable in a
|
|
323
|
+
// single-component corpus (label-only, counted for the summary
|
|
324
|
+
// warning); in PLAYBOOK mode loadPlaybook resolves it against the
|
|
325
|
+
// loaded set (#067 review: 11 dead nav leaves on circleci), so the
|
|
326
|
+
// coordinate rides along on a private marker it strips afterwards.
|
|
327
|
+
crossComponentNavItems += 1;
|
|
328
|
+
const xrefPath = segs[segs.length - 1].replace(/^\/+/, "").replace(/\.adoc$/, "");
|
|
329
|
+
const item = { label };
|
|
330
|
+
if (xrefPath)
|
|
331
|
+
item._xref = { component: segs[0], module: segs[1] || ROOT_MODULE, path: xrefPath, frag };
|
|
332
|
+
return item;
|
|
333
|
+
}
|
|
334
|
+
const xrefModule = segs.length === 2 && segs[0] ? segs[0] : moduleName;
|
|
335
|
+
// A leading slash after the coordinate (`xref:mod:/page.adoc`) is
|
|
336
|
+
// author noise Antora normalizes away — joining it verbatim produced
|
|
337
|
+
// `mod//page` hrefs (#053 B).
|
|
338
|
+
const xrefPath = segs[segs.length - 1]
|
|
339
|
+
.replace(/^\/+/, "")
|
|
340
|
+
.replace(/\.adoc$/, "");
|
|
341
|
+
if (!xrefPath)
|
|
342
|
+
return { label };
|
|
343
|
+
const slug = xrefModule === ROOT_MODULE ? xrefPath : `${xrefModule}/${xrefPath}`;
|
|
344
|
+
return { label, href: `${hrefPrefix}/${slug}${frag}` };
|
|
345
|
+
}
|
|
346
|
+
// Count of nav entries that referenced another component this load()
|
|
347
|
+
// run — reset per load, reported once as a summary warning.
|
|
348
|
+
let crossComponentNavItems = 0;
|
|
349
|
+
export const antoraLoader = {
|
|
350
|
+
name: "antora",
|
|
351
|
+
detect(sourceDir) {
|
|
352
|
+
if (!existsSync(sourceDir))
|
|
353
|
+
return false;
|
|
354
|
+
try {
|
|
355
|
+
const st = statSync(sourceDir);
|
|
356
|
+
if (!st.isDirectory())
|
|
357
|
+
return false;
|
|
358
|
+
// Component mode (root antora.yml) OR playbook mode (#067: a
|
|
359
|
+
// playbook at the root with at least one LOCAL start_path).
|
|
360
|
+
if (existsSync(join(sourceDir, ANTORA_YML)))
|
|
361
|
+
return true;
|
|
362
|
+
const pb = findPlaybook(sourceDir);
|
|
363
|
+
if (!pb)
|
|
364
|
+
return false;
|
|
365
|
+
const parsed = (YAML.parse(readFileSync(pb, "utf-8")) ?? {});
|
|
366
|
+
return playbookLocalStartPaths(sourceDir, parsed).local.length > 0;
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
async load(sourceDir, opts) {
|
|
373
|
+
const playbook = findPlaybook(sourceDir);
|
|
374
|
+
if (!existsSync(join(sourceDir, ANTORA_YML)) && playbook) {
|
|
375
|
+
return loadPlaybook(sourceDir, playbook, opts);
|
|
376
|
+
}
|
|
377
|
+
return loadSingleComponent(sourceDir, opts);
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
/** Path of the playbook file at `sourceDir`, or null. */
|
|
381
|
+
function findPlaybook(sourceDir) {
|
|
382
|
+
for (const n of PLAYBOOK_NAMES) {
|
|
383
|
+
const p = join(sourceDir, n);
|
|
384
|
+
if (existsSync(p))
|
|
385
|
+
return p;
|
|
386
|
+
}
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Local `start_path`s declared by a playbook — every source whose `url`
|
|
391
|
+
* is `.`/`./…`/an existing local dir. Remote sources are returned in
|
|
392
|
+
* `remote` so the caller can warn by name.
|
|
393
|
+
*/
|
|
394
|
+
function playbookLocalStartPaths(sourceDir, playbook) {
|
|
395
|
+
const local = [];
|
|
396
|
+
const remote = [];
|
|
397
|
+
for (const src of playbook.content?.sources ?? []) {
|
|
398
|
+
const url = (src.url ?? ".").trim();
|
|
399
|
+
const isLocal = url === "." || url.startsWith("./") || url.startsWith("../") ||
|
|
400
|
+
(!/^[a-z][a-z0-9+.-]*:/i.test(url) && existsSync(join(sourceDir, url)));
|
|
401
|
+
const base = url === "." ? sourceDir : join(sourceDir, url);
|
|
402
|
+
if (!isLocal) {
|
|
403
|
+
remote.push(url);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
// `start_paths` may be a list or a comma-separated string; a comma
|
|
407
|
+
// INSIDE braces (`docs/{a,b}`) is brace syntax, not a separator.
|
|
408
|
+
const sps = src.start_paths !== undefined
|
|
409
|
+
? Array.isArray(src.start_paths) ? src.start_paths : splitOutsideBraces(String(src.start_paths))
|
|
410
|
+
: [src.start_path ?? "."];
|
|
411
|
+
for (const sp of sps) {
|
|
412
|
+
if (!sp)
|
|
413
|
+
continue;
|
|
414
|
+
// Antora allows brace-expansion in start_paths (`docs/{a,b}`).
|
|
415
|
+
for (const expanded of expandBraces(sp)) {
|
|
416
|
+
const dir = join(base, expanded);
|
|
417
|
+
// A start_path that ESCAPES the corpus root (`url: ../sibling`)
|
|
418
|
+
// would load a component whose includes the #027 containment then
|
|
419
|
+
// refuses — half-working. Skip it, loudly (review).
|
|
420
|
+
const rel = relative(sourceDir, dir);
|
|
421
|
+
if (rel.startsWith("..")) {
|
|
422
|
+
// eslint-disable-next-line no-console
|
|
423
|
+
console.warn(`antora loader: playbook start_path escapes the corpus root, skipping: ${dir}`);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
local.push(dir);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return { local, remote };
|
|
431
|
+
}
|
|
432
|
+
/** Split on commas that are not inside `{…}`. */
|
|
433
|
+
function splitOutsideBraces(v) {
|
|
434
|
+
const out = [];
|
|
435
|
+
let depth = 0, cur = "";
|
|
436
|
+
for (const ch of v) {
|
|
437
|
+
if (ch === "{")
|
|
438
|
+
depth++;
|
|
439
|
+
else if (ch === "}")
|
|
440
|
+
depth = Math.max(0, depth - 1);
|
|
441
|
+
if (ch === "," && depth === 0) {
|
|
442
|
+
out.push(cur.trim());
|
|
443
|
+
cur = "";
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
cur += ch;
|
|
447
|
+
}
|
|
448
|
+
if (cur.trim())
|
|
449
|
+
out.push(cur.trim());
|
|
450
|
+
return out;
|
|
451
|
+
}
|
|
452
|
+
/** `docs/{a,b}` → [`docs/a`, `docs/b`]; no braces → [input]. */
|
|
453
|
+
function expandBraces(p) {
|
|
454
|
+
const m = p.match(/^(.*)\{([^}]*)\}(.*)$/);
|
|
455
|
+
if (!m)
|
|
456
|
+
return [p];
|
|
457
|
+
return m[2].split(",").flatMap((alt) => expandBraces(`${m[1]}${alt.trim()}${m[3]}`));
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Semver-ish version compare for "which is latest": numeric segments
|
|
461
|
+
* compare numerically (`4.10` > `4.9`), a common non-numeric prefix
|
|
462
|
+
* (`server-`) is ignored, unversioned (`~`/undefined) sorts LOWEST.
|
|
463
|
+
*/
|
|
464
|
+
function compareVersions(a, b) {
|
|
465
|
+
if (!a && !b)
|
|
466
|
+
return 0;
|
|
467
|
+
if (!a)
|
|
468
|
+
return -1;
|
|
469
|
+
if (!b)
|
|
470
|
+
return 1;
|
|
471
|
+
const parts = (v) => v.split(/[.\-]/).filter(Boolean);
|
|
472
|
+
const pa = parts(a), pb = parts(b);
|
|
473
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
474
|
+
const x = pa[i], y = pb[i];
|
|
475
|
+
if (x === undefined)
|
|
476
|
+
return -1;
|
|
477
|
+
if (y === undefined)
|
|
478
|
+
return 1;
|
|
479
|
+
const nx = /^\d+$/.test(x) ? Number(x) : NaN;
|
|
480
|
+
const ny = /^\d+$/.test(y) ? Number(y) : NaN;
|
|
481
|
+
if (!Number.isNaN(nx) && !Number.isNaN(ny)) {
|
|
482
|
+
if (nx !== ny)
|
|
483
|
+
return nx - ny;
|
|
484
|
+
}
|
|
485
|
+
else if (x !== y) {
|
|
486
|
+
return x < y ? -1 : 1;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return 0;
|
|
490
|
+
}
|
|
491
|
+
async function loadPlaybook(sourceDir, playbookPath, opts) {
|
|
492
|
+
let playbook;
|
|
493
|
+
try {
|
|
494
|
+
playbook = (YAML.parse(readFileSync(playbookPath, "utf-8")) ?? {});
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
498
|
+
throw new Error(`antora loader: cannot parse ${basename(playbookPath)}: ${msg}`);
|
|
499
|
+
}
|
|
500
|
+
const { local, remote } = playbookLocalStartPaths(sourceDir, playbook);
|
|
501
|
+
for (const r of remote) {
|
|
502
|
+
// eslint-disable-next-line no-console
|
|
503
|
+
console.warn(`antora loader: playbook source is remote and cannot be loaded in a single-repo build, skipping: ${r}`);
|
|
504
|
+
}
|
|
505
|
+
if (local.length === 0) {
|
|
506
|
+
throw new Error(`antora loader: ${basename(playbookPath)} declares no local content sources (nothing to load)`);
|
|
507
|
+
}
|
|
508
|
+
const playbookAttrs = {};
|
|
509
|
+
for (const [k, v] of Object.entries(playbook.asciidoc?.attributes ?? {})) {
|
|
510
|
+
playbookAttrs[k] = stripSoftSetSuffix(v);
|
|
511
|
+
}
|
|
512
|
+
// Load every local component. Slugs from loadSingleComponent are
|
|
513
|
+
// component-relative; we prefix them below once versions are known.
|
|
514
|
+
const loads = [];
|
|
515
|
+
for (const dir of local) {
|
|
516
|
+
if (!existsSync(join(dir, ANTORA_YML))) {
|
|
517
|
+
// eslint-disable-next-line no-console
|
|
518
|
+
console.warn(`antora loader: start_path has no ${ANTORA_YML}, skipping: ${relative(sourceDir, dir) || "."}`);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
// Component nav hrefs are built with NO prefix here; the component
|
|
522
|
+
// prefix is applied uniformly below (so version dirs compose).
|
|
523
|
+
const r = await loadSingleComponent(dir, { ...opts, hrefPrefix: "" }, true);
|
|
524
|
+
const name = r.component?.name ?? basename(dir);
|
|
525
|
+
const version = r.component?.version;
|
|
526
|
+
// Duplicate (name, version) — Antora rejects it outright. Skipping the
|
|
527
|
+
// whole second component keeps pages/nav/mounts/components consistent
|
|
528
|
+
// (a first-wins-per-slug merge left them disagreeing — review).
|
|
529
|
+
if (loads.some((l) => l.name === name && (l.version ?? "") === (version ?? ""))) {
|
|
530
|
+
// eslint-disable-next-line no-console
|
|
531
|
+
console.warn(`antora loader: duplicate component ${name}@${version ?? "~"} at ${relative(sourceDir, dir)} — skipping (Antora rejects duplicate versions)`);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
loads.push({
|
|
535
|
+
dir,
|
|
536
|
+
name,
|
|
537
|
+
title: r.component?.title,
|
|
538
|
+
version,
|
|
539
|
+
prerelease: r.component?.prerelease === true,
|
|
540
|
+
displayVersion: r.component?.displayVersion,
|
|
541
|
+
pages: r.pages,
|
|
542
|
+
nav: r.nav,
|
|
543
|
+
attributes: r.corpusAttributes ?? {},
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
if (loads.length === 0) {
|
|
547
|
+
throw new Error(`antora loader: none of the playbook's local start_paths contained an ${ANTORA_YML}`);
|
|
548
|
+
}
|
|
549
|
+
// Latest version per component name. Antora rule: a PRERELEASE version
|
|
550
|
+
// is never latest (the `version: main` / `next` pattern) unless every
|
|
551
|
+
// version of that name is prerelease.
|
|
552
|
+
const latest = new Map();
|
|
553
|
+
for (const l of loads) {
|
|
554
|
+
const cur = loads.find((c) => c.name === l.name && (c.version ?? "") === (latest.get(l.name) ?? "") && latest.has(l.name));
|
|
555
|
+
if (!latest.has(l.name)) {
|
|
556
|
+
latest.set(l.name, l.version);
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const curPre = cur ? cur.prerelease : false;
|
|
560
|
+
if (curPre && !l.prerelease) {
|
|
561
|
+
latest.set(l.name, l.version);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (!curPre && l.prerelease)
|
|
565
|
+
continue;
|
|
566
|
+
if (compareVersions(l.version, latest.get(l.name)) > 0)
|
|
567
|
+
latest.set(l.name, l.version);
|
|
568
|
+
}
|
|
569
|
+
const versionsOf = new Map();
|
|
570
|
+
for (const l of loads)
|
|
571
|
+
versionsOf.set(l.name, (versionsOf.get(l.name) ?? 0) + 1);
|
|
572
|
+
const hrefPrefix = opts.hrefPrefix ?? "";
|
|
573
|
+
// Slug prefix of the LATEST load per component name (cross-component
|
|
574
|
+
// nav xrefs and the engine's components map both resolve there).
|
|
575
|
+
const latestPrefixOf = new Map();
|
|
576
|
+
for (const l of loads) {
|
|
577
|
+
if (compareVersions(l.version, latest.get(l.name)) !== 0)
|
|
578
|
+
continue;
|
|
579
|
+
latestPrefixOf.set(l.name, l.name === ROOT_MODULE ? "" : `${l.name}/`);
|
|
580
|
+
}
|
|
581
|
+
const pages = [];
|
|
582
|
+
const nav = [];
|
|
583
|
+
// Group nav by component name, keeping first-seen playbook order.
|
|
584
|
+
const navGroups = new Map();
|
|
585
|
+
const seenSlugs = new Set();
|
|
586
|
+
for (const l of loads) {
|
|
587
|
+
const isRoot = l.name === ROOT_MODULE;
|
|
588
|
+
const isLatest = compareVersions(l.version, latest.get(l.name)) === 0;
|
|
589
|
+
const multiVersion = (versionsOf.get(l.name) ?? 1) > 1;
|
|
590
|
+
// Slug prefix: <component>[/<version>] — latest at the bare component
|
|
591
|
+
// path (Antora's `latest` redirect target); older versions nested.
|
|
592
|
+
const segs = [];
|
|
593
|
+
if (!isRoot)
|
|
594
|
+
segs.push(l.name);
|
|
595
|
+
if (!isLatest && l.version)
|
|
596
|
+
segs.push(l.version);
|
|
597
|
+
const prefix = segs.length ? segs.join("/") + "/" : "";
|
|
598
|
+
for (const p of l.pages) {
|
|
599
|
+
const slug = prefix + p.slug;
|
|
600
|
+
if (seenSlugs.has(slug)) {
|
|
601
|
+
// eslint-disable-next-line no-console
|
|
602
|
+
console.warn(`antora loader: duplicate page slug across components, keeping first: ${slug}`);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
seenSlugs.add(slug);
|
|
606
|
+
const attrs = { ...playbookAttrs, ...(p.attributes ?? {}) };
|
|
607
|
+
// The component's content-rooted imagesdir (`/modules/<m>/images`)
|
|
608
|
+
// must sit under the mount prefix too — images are mirrored there.
|
|
609
|
+
if (prefix && typeof attrs.imagesdir === "string" && attrs.imagesdir.startsWith("/modules/")) {
|
|
610
|
+
attrs.imagesdir = `/${prefix}${attrs.imagesdir.slice(1)}`;
|
|
611
|
+
}
|
|
612
|
+
pages.push({
|
|
613
|
+
...p,
|
|
614
|
+
slug,
|
|
615
|
+
...(Object.keys(attrs).length ? { attributes: attrs } : {}),
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
// Nav: rewrite this component's hrefs under its prefix, and resolve
|
|
619
|
+
// cross-component coordinates against the loaded set (own name →
|
|
620
|
+
// this prefix; another name → its LATEST prefix; unknown → label-only).
|
|
621
|
+
const rewritten = resolveNavXrefs(prefixNavHrefs(l.nav, hrefPrefix, prefix), l.name, prefix, latestPrefixOf, hrefPrefix);
|
|
622
|
+
const label = l.title ?? l.name;
|
|
623
|
+
if (multiVersion) {
|
|
624
|
+
let group = navGroups.get(l.name);
|
|
625
|
+
if (!group) {
|
|
626
|
+
group = { label, children: [] };
|
|
627
|
+
navGroups.set(l.name, group);
|
|
628
|
+
nav.push(group);
|
|
629
|
+
}
|
|
630
|
+
const versionItem = {
|
|
631
|
+
label: (l.displayVersion ?? l.version) ? (isLatest ? `${l.displayVersion ?? l.version} (latest)` : (l.displayVersion ?? l.version)) : "unversioned",
|
|
632
|
+
children: rewritten,
|
|
633
|
+
};
|
|
634
|
+
versionItem._v = l.version;
|
|
635
|
+
versionItem._latest = isLatest;
|
|
636
|
+
group.children.push(versionItem);
|
|
637
|
+
// LATEST first (whatever its version string — a prerelease `main`
|
|
638
|
+
// would otherwise string-sort above the numbers), then the rest
|
|
639
|
+
// descending by the REAL version (labels may be display_version
|
|
640
|
+
// strings). Markers stripped after.
|
|
641
|
+
// (Markers are stripped once, after ALL loads — deleting them per push
|
|
642
|
+
// left earlier siblings keyless and inverted the order; review.)
|
|
643
|
+
}
|
|
644
|
+
else if (isRoot) {
|
|
645
|
+
// ROOT component's nav sits at the top level, first.
|
|
646
|
+
nav.unshift(...rewritten);
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
nav.push({ label, children: rewritten });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// Order every multi-version group: LATEST first (whatever its version
|
|
653
|
+
// string — a prerelease `main` would string-sort above the numbers),
|
|
654
|
+
// then descending by the REAL version (labels may be display_version
|
|
655
|
+
// strings). Then strip the private markers.
|
|
656
|
+
for (const group of navGroups.values()) {
|
|
657
|
+
const kids = group.children;
|
|
658
|
+
kids.sort((a, b) => {
|
|
659
|
+
const la = a._latest ? 1 : 0, lb = b._latest ? 1 : 0;
|
|
660
|
+
if (la !== lb)
|
|
661
|
+
return lb - la;
|
|
662
|
+
return compareVersions(b._v, a._v);
|
|
663
|
+
});
|
|
664
|
+
for (const c of kids) {
|
|
665
|
+
delete c._v;
|
|
666
|
+
delete c._latest;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// Site-wide attribute values: playbook first, then each LATEST
|
|
670
|
+
// component's own (a site has ONE attributes block; a value two
|
|
671
|
+
// components disagree on stays with the first and is warned — the
|
|
672
|
+
// per-page attributes on PageSource still carry each component's own).
|
|
673
|
+
const corpusAttributes = { ...playbookAttrs };
|
|
674
|
+
const attrOwner = new Map();
|
|
675
|
+
for (const k of Object.keys(playbookAttrs))
|
|
676
|
+
attrOwner.set(k, "playbook");
|
|
677
|
+
for (const l of loads) {
|
|
678
|
+
if (compareVersions(l.version, latest.get(l.name)) !== 0)
|
|
679
|
+
continue;
|
|
680
|
+
for (const [k, v] of Object.entries(l.attributes)) {
|
|
681
|
+
if (!(k in corpusAttributes)) {
|
|
682
|
+
corpusAttributes[k] = v;
|
|
683
|
+
attrOwner.set(k, l.name);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (corpusAttributes[k] !== v && attrOwner.get(k) !== "playbook") {
|
|
687
|
+
// eslint-disable-next-line no-console
|
|
688
|
+
console.warn(`antora loader: attribute "${k}" differs between components ${attrOwner.get(k)} and ${l.name}; site config keeps ${attrOwner.get(k)}'s value`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const unresolved = countLabelOnlyLeaves(nav);
|
|
693
|
+
if (unresolved > 0) {
|
|
694
|
+
// eslint-disable-next-line no-console
|
|
695
|
+
console.warn(`antora loader: ${unresolved} nav entr${unresolved === 1 ? "y" : "ies"} reference components not in this playbook (kept as label-only items)`);
|
|
696
|
+
}
|
|
697
|
+
const result = { pages, nav };
|
|
698
|
+
if (Object.keys(corpusAttributes).length > 0)
|
|
699
|
+
result.corpusAttributes = corpusAttributes;
|
|
700
|
+
// Surface the FIRST component as `component` for callers that read
|
|
701
|
+
// it (single-component consumers); the full set is in `components`.
|
|
702
|
+
result.component = {
|
|
703
|
+
name: loads[0].name,
|
|
704
|
+
...(loads[0].title ? { title: loads[0].title } : {}),
|
|
705
|
+
...(loads[0].version ? { version: loads[0].version } : {}),
|
|
706
|
+
};
|
|
707
|
+
result.components = loads.map((l) => ({
|
|
708
|
+
name: l.name,
|
|
709
|
+
...(l.title ? { title: l.title } : {}),
|
|
710
|
+
...(l.version ? { version: l.version } : {}),
|
|
711
|
+
startPath: relative(sourceDir, l.dir).replace(/\\/g, "/") || ".",
|
|
712
|
+
latest: compareVersions(l.version, latest.get(l.name)) === 0,
|
|
713
|
+
}));
|
|
714
|
+
return result;
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Resolve `_xref` markers (cross-component nav coordinates) now that every
|
|
718
|
+
* component is loaded, then strip the marker. Own component → this load's
|
|
719
|
+
* prefix; another loaded component → its latest prefix; unknown → stays
|
|
720
|
+
* label-only (the summary warning already counted it).
|
|
721
|
+
*/
|
|
722
|
+
function resolveNavXrefs(items, ownName, ownPrefix, latestPrefixOf, hrefPrefix) {
|
|
723
|
+
return items.map((it) => {
|
|
724
|
+
const marked = it;
|
|
725
|
+
const out = { ...it };
|
|
726
|
+
delete out._xref;
|
|
727
|
+
const x = marked._xref;
|
|
728
|
+
if (x && out.href === undefined) {
|
|
729
|
+
const prefix = x.component === ownName ? ownPrefix : latestPrefixOf.get(x.component);
|
|
730
|
+
if (prefix !== undefined) {
|
|
731
|
+
const slug = x.module === ROOT_MODULE ? x.path : `${x.module}/${x.path}`;
|
|
732
|
+
out.href = `${hrefPrefix}/${prefix}${slug}${x.frag}`;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
if (it.children)
|
|
736
|
+
out.children = resolveNavXrefs(it.children, ownName, ownPrefix, latestPrefixOf, hrefPrefix);
|
|
737
|
+
return out;
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
/** Leaves (no children) with no href — cross-component refs that stayed unresolved. */
|
|
741
|
+
function countLabelOnlyLeaves(items) {
|
|
742
|
+
let n = 0;
|
|
743
|
+
for (const it of items) {
|
|
744
|
+
if (it.children && it.children.length)
|
|
745
|
+
n += countLabelOnlyLeaves(it.children);
|
|
746
|
+
else if (it.href === undefined)
|
|
747
|
+
n += 1;
|
|
748
|
+
}
|
|
749
|
+
return n;
|
|
750
|
+
}
|
|
751
|
+
/** Strip `_xref` markers from a single-component nav (nothing to resolve them against). */
|
|
752
|
+
function stripNavXrefs(items) {
|
|
753
|
+
return items.map((it) => {
|
|
754
|
+
const out = { ...it };
|
|
755
|
+
delete out._xref;
|
|
756
|
+
if (it.children)
|
|
757
|
+
out.children = stripNavXrefs(it.children);
|
|
758
|
+
return out;
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
/** Prepend `<hrefPrefix>/<prefix>` to every content href in a nav tree. */
|
|
762
|
+
function prefixNavHrefs(items, hrefPrefix, prefix) {
|
|
763
|
+
return items.map((it) => {
|
|
764
|
+
const out = { ...it };
|
|
765
|
+
if (typeof it.href === "string" && !/^(?:https?:\/\/|mailto:|tel:)/i.test(it.href)) {
|
|
766
|
+
// Component-relative hrefs from loadSingleComponent are `/<slug>`.
|
|
767
|
+
const bare = it.href.replace(/^\/+/, "");
|
|
768
|
+
out.href = `${hrefPrefix}/${prefix}${bare}`;
|
|
769
|
+
}
|
|
770
|
+
if (it.children)
|
|
771
|
+
out.children = prefixNavHrefs(it.children, hrefPrefix, prefix);
|
|
772
|
+
return out;
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
async function loadSingleComponent(sourceDir, opts, keepNavXrefMarkers = false) {
|
|
776
|
+
const ymlPath = join(sourceDir, ANTORA_YML);
|
|
777
|
+
if (!existsSync(ymlPath)) {
|
|
778
|
+
throw new Error(`antora loader: ${ANTORA_YML} not found in ${sourceDir}`);
|
|
779
|
+
}
|
|
780
|
+
let parsed;
|
|
781
|
+
try {
|
|
782
|
+
parsed = YAML.parse(readFileSync(ymlPath, "utf-8"));
|
|
783
|
+
}
|
|
784
|
+
catch (err) {
|
|
785
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
786
|
+
throw new Error(`antora loader: cannot parse ${ANTORA_YML}: ${msg}`);
|
|
787
|
+
}
|
|
788
|
+
if (!parsed || typeof parsed !== "object" || !parsed.name) {
|
|
789
|
+
throw new Error(`antora loader: ${ANTORA_YML} must declare at least a 'name' field`);
|
|
790
|
+
}
|
|
791
|
+
const hrefPrefix = opts.hrefPrefix ?? "";
|
|
792
|
+
const componentAttrs = {};
|
|
793
|
+
for (const [k, v] of Object.entries(parsed.asciidoc?.attributes ?? {})) {
|
|
794
|
+
// Antora's soft-set marker (`value@`) is a precedence hint for the
|
|
795
|
+
// playbook merge — meaningless downstream; strip it.
|
|
796
|
+
componentAttrs[k] = stripSoftSetSuffix(v);
|
|
797
|
+
}
|
|
798
|
+
// Walk every module's pages/ to assemble PageSources. We don't
|
|
799
|
+
// restrict to modules listed in nav: — a corpus can have pages
|
|
800
|
+
// not referenced by nav (orphans), and the audit will surface
|
|
801
|
+
// them; the loader's job is to enumerate them all.
|
|
802
|
+
const modulesDir = join(sourceDir, "modules");
|
|
803
|
+
const moduleNames = listModules(sourceDir);
|
|
804
|
+
const pages = [];
|
|
805
|
+
for (const moduleName of moduleNames) {
|
|
806
|
+
// Per-module attribute context: component attrs, then the module's
|
|
807
|
+
// conventional `pages/_attributes.adoc` seed (so titles and inline
|
|
808
|
+
// refs resolve at import time), then the content-rooted imagesdir
|
|
809
|
+
// (so relative image targets resolve against the mirrored module
|
|
810
|
+
// assets — see plans/antora-support-gaps.md G4).
|
|
811
|
+
const moduleAttrs = { ...componentAttrs };
|
|
812
|
+
const attrsFile = join(modulesDir, moduleName, "pages", "_attributes.adoc");
|
|
813
|
+
if (existsSync(attrsFile)) {
|
|
814
|
+
Object.assign(moduleAttrs, parsePageAttributesFile(attrsFile));
|
|
815
|
+
}
|
|
816
|
+
const imagesdir = moduleImagesdir(modulesDir, moduleName);
|
|
817
|
+
if (imagesdir)
|
|
818
|
+
moduleAttrs.imagesdir = imagesdir;
|
|
819
|
+
for (const { filePath, slug } of walkPagesUnderModule(modulesDir, moduleName)) {
|
|
820
|
+
const page = { filePath, slug };
|
|
821
|
+
if (Object.keys(moduleAttrs).length > 0) {
|
|
822
|
+
page.attributes = { ...moduleAttrs };
|
|
823
|
+
}
|
|
824
|
+
pages.push(page);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
// Build nav by parsing each referenced nav.adoc file. Concatenate
|
|
828
|
+
// multiple nav files at the top level — that's Antora's
|
|
829
|
+
// convention (each nav.adoc is a "section" of the overall nav).
|
|
830
|
+
// `include::[mod:]partial$nav.adoc[]` directives inside a nav are
|
|
831
|
+
// expanded first (couchbase composes its ROOT nav from 15 module
|
|
832
|
+
// nav partials).
|
|
833
|
+
crossComponentNavItems = 0;
|
|
834
|
+
const nav = [];
|
|
835
|
+
for (const navPath of parsed.nav ?? []) {
|
|
836
|
+
const navFullPath = join(sourceDir, navPath);
|
|
837
|
+
if (!existsSync(navFullPath)) {
|
|
838
|
+
// eslint-disable-next-line no-console
|
|
839
|
+
console.warn(`antora loader: nav file not found, skipping: ${navPath}`);
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
// Module name from the nav file's path — `modules/<name>/nav.adoc`.
|
|
843
|
+
const navRelative = relative(modulesDir, navFullPath).replace(/\\/g, "/");
|
|
844
|
+
const moduleName = navRelative.split("/")[0] ?? ROOT_MODULE;
|
|
845
|
+
try {
|
|
846
|
+
const content = readFileSync(navFullPath, "utf-8");
|
|
847
|
+
const navLines = expandNavLines(content, moduleName, modulesDir);
|
|
848
|
+
nav.push(...parseNavAdoc(navLines, hrefPrefix));
|
|
849
|
+
}
|
|
850
|
+
catch (err) {
|
|
851
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
852
|
+
// eslint-disable-next-line no-console
|
|
853
|
+
console.warn(`antora loader: cannot read ${navPath}: ${msg}`);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (crossComponentNavItems > 0 && !keepNavXrefMarkers) {
|
|
857
|
+
// (Playbook mode resolves these against the loaded set and reports
|
|
858
|
+
// only what stays label-only — see loadPlaybook.)
|
|
859
|
+
// eslint-disable-next-line no-console
|
|
860
|
+
console.warn(`antora loader: ${crossComponentNavItems} nav entr${crossComponentNavItems === 1 ? "y" : "ies"} reference other components (kept as label-only items)`);
|
|
861
|
+
}
|
|
862
|
+
const result = { pages, nav: keepNavXrefMarkers ? nav : stripNavXrefs(nav) };
|
|
863
|
+
if (Object.keys(componentAttrs).length > 0)
|
|
864
|
+
result.corpusAttributes = { ...componentAttrs };
|
|
865
|
+
result.component = {
|
|
866
|
+
name: parsed.name,
|
|
867
|
+
...(parsed.title ? { title: parsed.title } : {}),
|
|
868
|
+
...(parsed.version ? { version: String(parsed.version) } : {}),
|
|
869
|
+
...(parsed.prerelease ? { prerelease: true } : {}),
|
|
870
|
+
...(parsed.display_version ? { displayVersion: String(parsed.display_version) } : {}),
|
|
871
|
+
};
|
|
872
|
+
return result;
|
|
873
|
+
}
|
|
874
|
+
//# sourceMappingURL=antora.js.map
|