@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.
Files changed (58) hide show
  1. package/README.md +40 -0
  2. package/dist/cli.d.ts +4 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +354 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/detect.d.ts +47 -0
  7. package/dist/detect.d.ts.map +1 -0
  8. package/dist/detect.js +73 -0
  9. package/dist/detect.js.map +1 -0
  10. package/dist/index.d.ts +10 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +9 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/inline-walker.d.ts +17 -0
  15. package/dist/inline-walker.d.ts.map +1 -0
  16. package/dist/inline-walker.js +167 -0
  17. package/dist/inline-walker.js.map +1 -0
  18. package/dist/loaders/antora.d.ts +3 -0
  19. package/dist/loaders/antora.d.ts.map +1 -0
  20. package/dist/loaders/antora.js +874 -0
  21. package/dist/loaders/antora.js.map +1 -0
  22. package/dist/loaders/asciibinder.d.ts +3 -0
  23. package/dist/loaders/asciibinder.d.ts.map +1 -0
  24. package/dist/loaders/asciibinder.js +229 -0
  25. package/dist/loaders/asciibinder.js.map +1 -0
  26. package/dist/loaders/dominance.d.ts +15 -0
  27. package/dist/loaders/dominance.d.ts.map +1 -0
  28. package/dist/loaders/dominance.js +86 -0
  29. package/dist/loaders/dominance.js.map +1 -0
  30. package/dist/loaders/master-adoc.d.ts +3 -0
  31. package/dist/loaders/master-adoc.d.ts.map +1 -0
  32. package/dist/loaders/master-adoc.js +178 -0
  33. package/dist/loaders/master-adoc.js.map +1 -0
  34. package/dist/loaders/plain.d.ts +3 -0
  35. package/dist/loaders/plain.d.ts.map +1 -0
  36. package/dist/loaders/plain.js +58 -0
  37. package/dist/loaders/plain.js.map +1 -0
  38. package/dist/loaders/types.d.ts +117 -0
  39. package/dist/loaders/types.d.ts.map +1 -0
  40. package/dist/loaders/types.js +2 -0
  41. package/dist/loaders/types.js.map +1 -0
  42. package/dist/loaders/util.d.ts +15 -0
  43. package/dist/loaders/util.d.ts.map +1 -0
  44. package/dist/loaders/util.js +49 -0
  45. package/dist/loaders/util.js.map +1 -0
  46. package/dist/parse.d.ts +178 -0
  47. package/dist/parse.d.ts.map +1 -0
  48. package/dist/parse.js +150 -0
  49. package/dist/parse.js.map +1 -0
  50. package/dist/tree.d.ts +2 -0
  51. package/dist/tree.d.ts.map +1 -0
  52. package/dist/tree.js +2 -0
  53. package/dist/tree.js.map +1 -0
  54. package/dist/walker.d.ts +8 -0
  55. package/dist/walker.d.ts.map +1 -0
  56. package/dist/walker.js +210 -0
  57. package/dist/walker.js.map +1 -0
  58. 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