@baiyibai-antora/extensions 1.7.0 → 1.8.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.
@@ -0,0 +1,770 @@
1
+ "use strict";
2
+ /**
3
+ * @baiyibai-antora/extensions - autoterm
4
+ *
5
+ * Scans the Antora content catalog for AsciiDoc index term markup and injects
6
+ * two generated AsciiDoc partials into each participating component-version:
7
+ *
8
+ * modules/ROOT/partials/generated-terminology.adoc
9
+ * modules/ROOT/partials/generated-index.adoc
10
+ *
11
+ * A component participates by containing a page that includes the partial:
12
+ *
13
+ * include::partial$generated-terminology.adoc[]
14
+ * include::partial$generated-index.adoc[]
15
+ *
16
+ * The including page's position in nav.adoc controls where the section
17
+ * appears. The terminology page carries :!sectnums: to render unnumbered.
18
+ * The index page carries :assembly-style: index so the Antora Assembler
19
+ * preserves the [index] section and stock asciidoctor-pdf renders the
20
+ * page-numbered index; the generated index partial holds the web-linked
21
+ * index guarded by ifndef::loader-assembler[] so it renders only outside
22
+ * the PDF build.
23
+ *
24
+ * Definitions come from a shared table (the autoterm-data component's
25
+ * partial) optionally overridden per component by a local partial named
26
+ * trademark-local-style: modules/ROOT/partials/terminology-local.adoc.
27
+ * Local rows replace shared rows for the same term.
28
+ *
29
+ * Configuration in antora-playbook.yml:
30
+ *
31
+ * antora:
32
+ * extensions:
33
+ * - require: '@baiyibai-antora/extensions/autoterm'
34
+ * autoterm:
35
+ * enabled: true
36
+ * # Antora resource ID or filename of the shared definitions partial.
37
+ * terminology_file: 'ROOT:partial$terminology-table.adoc'
38
+ * # Filename of a component's local override partial.
39
+ * local_file: 'terminology-local.adoc'
40
+ * # Filenames of the generated partials.
41
+ * terminology_partial: 'generated-terminology.adoc'
42
+ * index_partial: 'generated-index.adoc'
43
+ * # AsciiDoc cols attribute for each generated table.
44
+ * terminology_cols: '15,85'
45
+ * index_cols: '30,70'
46
+ * # 'combined' welds each letter into its group's first row (default);
47
+ * # 'separate' emits the letter as its own row (may orphan at a page
48
+ * # bottom in the PDF).
49
+ * letter_style: 'combined'
50
+ * # Attribution line rendered above the Terminology content outside
51
+ * # the PDF build. AsciiDoc inline markup allowed; empty disables.
52
+ * attribution: 'Generated by https://gitlab.com/baiyibai-antora/baiyibai-extensions[autoterm].'
53
+ *
54
+ * The including page overrides presentation per component through header
55
+ * attributes: :autoterm-terminology-cols:, :autoterm-index-cols:,
56
+ * :autoterm-letter-style:.
57
+ *
58
+ * The generated partials live in the content catalog and regenerate on every
59
+ * build. Setting write_to_disk: true additionally writes them into the
60
+ * component's local worktree; components loaded from a remote source skip
61
+ * the disk write, mirroring trademark-it.
62
+ *
63
+ * Per-component configuration in a component's antora.yml under
64
+ * ext.autoterm overrides local_file, terminology_cols, index_cols,
65
+ * letter_style, write_to_disk, and enabled for that component-version.
66
+ * Unlike trademark-it, the block is optional: participation comes from the
67
+ * include, not from the block.
68
+ */
69
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
70
+ if (k2 === undefined) k2 = k;
71
+ var desc = Object.getOwnPropertyDescriptor(m, k);
72
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
73
+ desc = { enumerable: true, get: function() { return m[k]; } };
74
+ }
75
+ Object.defineProperty(o, k2, desc);
76
+ }) : (function(o, m, k, k2) {
77
+ if (k2 === undefined) k2 = k;
78
+ o[k2] = m[k];
79
+ }));
80
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
81
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
82
+ }) : function(o, v) {
83
+ o["default"] = v;
84
+ });
85
+ var __importStar = (this && this.__importStar) || (function () {
86
+ var ownKeys = function(o) {
87
+ ownKeys = Object.getOwnPropertyNames || function (o) {
88
+ var ar = [];
89
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
90
+ return ar;
91
+ };
92
+ return ownKeys(o);
93
+ };
94
+ return function (mod) {
95
+ if (mod && mod.__esModule) return mod;
96
+ var result = {};
97
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
98
+ __setModuleDefault(result, mod);
99
+ return result;
100
+ };
101
+ })();
102
+ Object.defineProperty(exports, "__esModule", { value: true });
103
+ exports.parseTerminologyTable = parseTerminologyTable;
104
+ exports.mergeTerminology = mergeTerminology;
105
+ exports.stripVerbatimBlocks = stripVerbatimBlocks;
106
+ exports.scanIndexterms = scanIndexterms;
107
+ exports.buildTerminologyEntries = buildTerminologyEntries;
108
+ exports.generateTerminologyPartial = generateTerminologyPartial;
109
+ exports.generateIndexPartial = generateIndexPartial;
110
+ exports.pageTitle = pageTitle;
111
+ exports.pageAttribute = pageAttribute;
112
+ exports.register = register;
113
+ const log_time_1 = require("../../lib/log-time");
114
+ const index_1 = require("../trademark-it/index");
115
+ const fs = __importStar(require("node:fs"));
116
+ const path = __importStar(require("node:path"));
117
+ // ---------------------------------------------------------------------------
118
+ // Logging helpers (mirrors trademark-it style)
119
+ // ---------------------------------------------------------------------------
120
+ let quietMode = true;
121
+ function logEvent(message) {
122
+ console.log(`[${(0, log_time_1.nowLocal)()}] \x1b[94mEVENT\x1b[0m (autoterm): \x1b[94m${message}\x1b[0m`);
123
+ }
124
+ function makeLogger(hostLogger) {
125
+ return {
126
+ debug: (m) => { if (!quietMode) {
127
+ if (hostLogger?.debug)
128
+ hostLogger.debug(m);
129
+ else
130
+ console.debug(m);
131
+ } },
132
+ info: (m) => { if (!quietMode) {
133
+ if (hostLogger?.info)
134
+ hostLogger.info(m);
135
+ else
136
+ console.log(m);
137
+ } },
138
+ warn: (m) => { if (hostLogger?.warn)
139
+ hostLogger.warn(m);
140
+ else
141
+ console.warn(m); },
142
+ error: (m) => { if (hostLogger?.error)
143
+ hostLogger.error(m);
144
+ else
145
+ console.error(m); },
146
+ };
147
+ }
148
+ // ---------------------------------------------------------------------------
149
+ // Config helpers (mirrors trademark-it style)
150
+ // ---------------------------------------------------------------------------
151
+ function toBool(v, def) {
152
+ if (v === true)
153
+ return true;
154
+ if (v === false)
155
+ return false;
156
+ if (typeof v === 'string') {
157
+ const s = v.trim().toLowerCase();
158
+ if (['true', '1', 'yes', 'on'].includes(s))
159
+ return true;
160
+ if (['false', '0', 'no', 'off'].includes(s))
161
+ return false;
162
+ }
163
+ return def;
164
+ }
165
+ function pick(obj, keys, def) {
166
+ for (const k of keys) {
167
+ if (obj && obj[k] !== undefined)
168
+ return obj[k];
169
+ }
170
+ return def;
171
+ }
172
+ // ---------------------------------------------------------------------------
173
+ // Definitions table parser
174
+ //
175
+ // Parses the shared/local terminology table: term in the first column,
176
+ // abbreviation in the second, definition in the third. Cell layout follows
177
+ // the trademark table style: header row inline, data cells one per line.
178
+ // ---------------------------------------------------------------------------
179
+ function parseTerminologyTable(asciidocText) {
180
+ const definitions = new Map();
181
+ const tableMatch = asciidocText.match(/\|={3,}([\s\S]*?)\|={3,}/);
182
+ if (!tableMatch)
183
+ return definitions;
184
+ const tableBody = tableMatch[1];
185
+ let colCount = 3;
186
+ const colsMatch = asciidocText.match(/\[[^\]]*cols\s*=\s*["']([^"']+)["']/);
187
+ if (colsMatch)
188
+ colCount = colsMatch[1].split(',').length;
189
+ const cells = [];
190
+ for (const raw of tableBody.split('\n')) {
191
+ const line = raw.trim();
192
+ if (!line || line === '|===')
193
+ continue;
194
+ if (!line.startsWith('|')) {
195
+ // Continuation of the previous cell (wrapped definition text).
196
+ if (cells.length > 0)
197
+ cells[cells.length - 1] += ` ${line}`;
198
+ continue;
199
+ }
200
+ const parts = line.split('|').map((p) => p.trim()).filter((p) => p !== '');
201
+ if (parts.length > 1) {
202
+ cells.push(...parts);
203
+ }
204
+ else {
205
+ cells.push(line.slice(1).trim());
206
+ }
207
+ }
208
+ const rows = [];
209
+ for (let i = 0; i + colCount - 1 < cells.length; i += colCount) {
210
+ rows.push(cells.slice(i, i + colCount));
211
+ }
212
+ // Skip the header row.
213
+ for (const row of rows.slice(1)) {
214
+ const fullTerm = (row[0] ?? '').trim();
215
+ if (!fullTerm)
216
+ continue;
217
+ definitions.set(fullTerm, {
218
+ fullTerm,
219
+ abbreviation: (row[1] ?? '').trim(),
220
+ definition: (row[2] ?? '').trim(),
221
+ });
222
+ }
223
+ return definitions;
224
+ }
225
+ /**
226
+ * Merge a component-local definitions map over the shared map. A local entry
227
+ * replaces the shared entry whose full term matches exactly, mirroring the
228
+ * exact-key lookup the definitions serve; remaining local entries are added.
229
+ */
230
+ function mergeTerminology(shared, local) {
231
+ const merged = new Map(shared);
232
+ const overridden = [];
233
+ for (const [key, entry] of local) {
234
+ if (merged.has(key))
235
+ overridden.push(key);
236
+ merged.set(key, entry);
237
+ }
238
+ return { merged, overridden };
239
+ }
240
+ // ---------------------------------------------------------------------------
241
+ // Indexterm scanner
242
+ //
243
+ // Extracts index terms from raw AsciiDoc source: ((visible)), (((concealed))),
244
+ // indexterm:[...], and indexterm2:[...]. Comments and suppressed conditionals
245
+ // are stripped first. An inner parenthetical names an abbreviation:
246
+ // ((Full Term (Abbr) )) — the trailing space before the closing marks keeps
247
+ // the stock PDF index intact, but the scanner accepts both spellings.
248
+ // ---------------------------------------------------------------------------
249
+ /**
250
+ * Blank the content of listing and literal blocks (---- and .... fences).
251
+ * Asciidoctor does not process index terms in verbatim context, so the
252
+ * scanner must not either; documentation showing ((...)) syntax inside a
253
+ * listing stays inert. Line count is preserved.
254
+ */
255
+ function stripVerbatimBlocks(text) {
256
+ const out = [];
257
+ let fence = null;
258
+ for (const line of text.split('\n')) {
259
+ const trimmed = line.trimEnd();
260
+ if (fence) {
261
+ if (trimmed === fence)
262
+ fence = null;
263
+ out.push('');
264
+ continue;
265
+ }
266
+ if (/^-{4,}$/.test(trimmed) || /^\.{4,}$/.test(trimmed)) {
267
+ fence = trimmed;
268
+ out.push('');
269
+ continue;
270
+ }
271
+ out.push(line);
272
+ }
273
+ return out.join('\n');
274
+ }
275
+ function scanIndexterms(sourceText, ignoreConditionals = []) {
276
+ // Inline passthroughs display index term syntax without indexing it
277
+ // (+((Full Term))+), matching Asciidoctor's behavior; remove them and
278
+ // verbatim block content before scanning.
279
+ const text = stripVerbatimBlocks((0, index_1.stripNonContent)(sourceText, ignoreConditionals))
280
+ .replace(/\+\({2,3}[\s\S]+?\){2,3}\+/g, ' ');
281
+ const found = [];
282
+ const record = (raw) => {
283
+ const inner = raw.trim();
284
+ if (!inner)
285
+ return;
286
+ const abbrMatch = inner.match(/^(.*?)\s+\(([^()]+)\)$/);
287
+ if (abbrMatch) {
288
+ found.push({ full: abbrMatch[1].trim(), abbr: abbrMatch[2].trim() });
289
+ }
290
+ else {
291
+ found.push({ full: inner, abbr: null });
292
+ }
293
+ };
294
+ // Concealed terms first so the visible-form regex does not half-match them.
295
+ const concealedRe = /\(\(\(([\s\S]+?)\)\)\)/g;
296
+ let scrubbed = text.replace(concealedRe, (_, inner) => {
297
+ record(inner.split(',')[0]);
298
+ return ' ';
299
+ });
300
+ // Visible flow terms. The lazy match stops at the first )); when the
301
+ // captured text holds an unclosed ( and the next character is ), extend by
302
+ // one so ((Full Term (Abbr))) yields "Full Term (Abbr)".
303
+ const visibleRe = /\(\(([\s\S]+?)\)\)/g;
304
+ let m;
305
+ while ((m = visibleRe.exec(scrubbed)) !== null) {
306
+ let inner = m[1];
307
+ const opens = (inner.match(/\(/g) ?? []).length;
308
+ const closes = (inner.match(/\)/g) ?? []).length;
309
+ if (opens > closes && scrubbed[visibleRe.lastIndex] === ')') {
310
+ inner += ')';
311
+ visibleRe.lastIndex += 1;
312
+ }
313
+ record(inner);
314
+ }
315
+ // Macro forms.
316
+ const macroRe = /indexterm2?:\[([^\]]+)\]/g;
317
+ while ((m = macroRe.exec(text)) !== null) {
318
+ record(m[1].split(',')[0]);
319
+ }
320
+ return found;
321
+ }
322
+ function escapeCell(s) {
323
+ return s.replace(/\|/g, '\\|');
324
+ }
325
+ /**
326
+ * Build the terminology entries from the scanned terms and the merged
327
+ * definitions, applying the ledger rules: a term with an abbreviation always
328
+ * appears; a term without one appears only when it has a definition; the
329
+ * entry label is the abbreviation or the full term; the body is
330
+ * "Full Term – definition" for abbreviated terms, the definition alone
331
+ * otherwise; duplicate full terms keep the last-scanned occurrence.
332
+ */
333
+ function buildTerminologyEntries(terms, definitions) {
334
+ const byFull = new Map();
335
+ for (const t of terms)
336
+ byFull.set(t.full, t);
337
+ const entries = [];
338
+ for (const t of byFull.values()) {
339
+ const definition = definitions.get(t.full)?.definition ?? '';
340
+ if (!t.abbr && !definition)
341
+ continue;
342
+ const label = t.abbr ?? t.full;
343
+ const body = t.abbr
344
+ ? (definition ? `${t.full} – ${definition}` : t.full)
345
+ : definition;
346
+ const sortKey = label.toLowerCase();
347
+ entries.push({ label, body, sortKey, letter: (sortKey[0] ?? '').toUpperCase() });
348
+ }
349
+ entries.sort((a, b) => a.sortKey.localeCompare(b.sortKey, undefined, { numeric: true, sensitivity: 'base' }));
350
+ return entries;
351
+ }
352
+ function generateTerminologyPartial(entries, cols = '15,85', letterStyle = 'combined', attribution = '') {
353
+ if (entries.length === 0) {
354
+ return '// No index terms detected in this component.\n';
355
+ }
356
+ const lines = ['// Auto-generated by autoterm. Do not edit.'];
357
+ // The attribution renders only outside the PDF build, where a generated
358
+ // section without explanation reads strangely.
359
+ if (attribution) {
360
+ lines.push('ifndef::loader-assembler[]');
361
+ lines.push('[.autoterm-attribution]');
362
+ lines.push(attribution);
363
+ lines.push('endif::[]');
364
+ lines.push('');
365
+ }
366
+ lines.push(`[.autoterm-terminology,cols="${cols}",frame=none,grid=none]`, '|===');
367
+ let currentLetter = '';
368
+ for (const entry of entries) {
369
+ const newLetter = entry.letter !== currentLetter;
370
+ currentLetter = entry.letter;
371
+ if (newLetter && letterStyle === 'separate') {
372
+ lines.push(`|*${entry.letter}*`);
373
+ lines.push('|');
374
+ lines.push('');
375
+ }
376
+ if (newLetter && letterStyle === 'combined') {
377
+ lines.push(`|*${entry.letter}* +`);
378
+ lines.push(escapeCell(entry.label));
379
+ lines.push(`|{empty} +`);
380
+ lines.push(escapeCell(entry.body));
381
+ }
382
+ else {
383
+ lines.push(`|${escapeCell(entry.label)}`);
384
+ lines.push(`|${escapeCell(entry.body)}`);
385
+ }
386
+ lines.push('');
387
+ }
388
+ lines.push('|===');
389
+ lines.push('');
390
+ return lines.join('\n');
391
+ }
392
+ function generateIndexPartial(occurrences, cols = '30,70') {
393
+ if (occurrences.length === 0) {
394
+ return '// No index terms detected in this component.\n';
395
+ }
396
+ const sorted = [...occurrences].sort((a, b) => a.sortKey.localeCompare(b.sortKey, undefined, { numeric: true, sensitivity: 'base' }));
397
+ const lines = [
398
+ '// Auto-generated by autoterm. Do not edit.',
399
+ 'ifndef::loader-assembler[]',
400
+ `[.autoterm-index,cols="${cols}",frame=none,grid=none]`,
401
+ '|===',
402
+ ];
403
+ let currentLetter = '';
404
+ for (const occ of sorted) {
405
+ const xrefs = occ.pages
406
+ .map((p) => `xref:${p.module === 'ROOT' ? '' : `${p.module}:`}${p.relative}[${p.title}]`)
407
+ .join(', ');
408
+ if (occ.letter !== currentLetter) {
409
+ currentLetter = occ.letter;
410
+ lines.push(`|*${occ.letter}* +`);
411
+ lines.push(escapeCell(occ.display));
412
+ lines.push('a|{empty} +');
413
+ lines.push(xrefs);
414
+ }
415
+ else {
416
+ lines.push(`|${escapeCell(occ.display)}`);
417
+ lines.push(`a|${xrefs}`);
418
+ }
419
+ lines.push('');
420
+ }
421
+ lines.push('|===');
422
+ lines.push('endif::[]');
423
+ lines.push('');
424
+ return lines.join('\n');
425
+ }
426
+ // ---------------------------------------------------------------------------
427
+ // Page helpers
428
+ // ---------------------------------------------------------------------------
429
+ /** Extract the page title from raw AsciiDoc source. */
430
+ function pageTitle(sourceText) {
431
+ const m = sourceText.match(/^=\s+(.+)$/m);
432
+ return m ? m[1].trim() : '';
433
+ }
434
+ /** Read a :name: value from a raw page header, or null. */
435
+ function pageAttribute(sourceText, name) {
436
+ const re = new RegExp(`^:${name}:\\s*(.*)$`, 'm');
437
+ const m = sourceText.match(re);
438
+ return m ? m[1].trim() : null;
439
+ }
440
+ function includesPartial(sourceText, partialName) {
441
+ const re = new RegExp(`^include::[^\\[]*partial\\$${partialName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\[`, 'm');
442
+ return re.test(sourceText);
443
+ }
444
+ // ---------------------------------------------------------------------------
445
+ // Antora extension entry point
446
+ // ---------------------------------------------------------------------------
447
+ function register(context) {
448
+ const ctx = context ?? this;
449
+ const logger = makeLogger(ctx.getLogger ? ctx.getLogger('autoterm') : undefined);
450
+ let cfg = {
451
+ enabled: true,
452
+ quiet: true,
453
+ terminology_file: 'ROOT:partial$terminology-table.adoc',
454
+ local_file: 'terminology-local.adoc',
455
+ terminology_partial: 'generated-terminology.adoc',
456
+ index_partial: 'generated-index.adoc',
457
+ terminology_cols: '15,85',
458
+ index_cols: '30,70',
459
+ letter_style: 'combined',
460
+ attribution: 'Generated by https://gitlab.com/baiyibai-antora/baiyibai-extensions[autoterm].',
461
+ write_to_disk: false,
462
+ suppress_events: false,
463
+ };
464
+ // Per-component overrides from antora.yml ext.autoterm, captured during
465
+ // contentAggregated where ext is still present, mirroring trademark-it.
466
+ const extByCompVer = new Map();
467
+ ctx.once('contentAggregated', ({ contentAggregate }) => {
468
+ for (const item of (contentAggregate ?? [])) {
469
+ const atExt = item.ext?.['autoterm'];
470
+ if (atExt && typeof atExt === 'object') {
471
+ extByCompVer.set(`${item.name}@${item.version}`, atExt);
472
+ }
473
+ }
474
+ });
475
+ ctx.once('playbookBuilt', async ({ playbook }) => {
476
+ const extEntry = (playbook?.antora?.extensions ?? []).find((ext) => String(ext.require ?? '').includes('autoterm')) ?? {};
477
+ const raw = (extEntry.autoterm ?? extEntry.autoTerm ?? {});
478
+ cfg = {
479
+ enabled: toBool(pick(raw, ['enabled'], cfg.enabled), cfg.enabled),
480
+ quiet: toBool(pick(raw, ['quiet'], cfg.quiet), cfg.quiet),
481
+ terminology_file: String(pick(raw, ['terminology_file', 'terminologyFile'], cfg.terminology_file)),
482
+ local_file: String(pick(raw, ['local_file', 'localFile'], cfg.local_file)),
483
+ terminology_partial: String(pick(raw, ['terminology_partial', 'terminologyPartial'], cfg.terminology_partial)),
484
+ index_partial: String(pick(raw, ['index_partial', 'indexPartial'], cfg.index_partial)),
485
+ terminology_cols: String(pick(raw, ['terminology_cols', 'terminologyCols'], cfg.terminology_cols)),
486
+ index_cols: String(pick(raw, ['index_cols', 'indexCols'], cfg.index_cols)),
487
+ letter_style: pick(raw, ['letter_style', 'letterStyle'], cfg.letter_style) === 'separate' ? 'separate' : 'combined',
488
+ attribution: String(pick(raw, ['attribution'], cfg.attribution)),
489
+ write_to_disk: toBool(pick(raw, ['write_to_disk', 'writeToDisk', 'writetodisk'], cfg.write_to_disk), cfg.write_to_disk),
490
+ suppress_events: toBool(pick(raw, ['suppress_events', 'suppressEvents'], cfg.suppress_events), cfg.suppress_events),
491
+ };
492
+ quietMode = cfg.quiet;
493
+ logger.debug(`autoterm config: ${JSON.stringify(cfg)}`);
494
+ });
495
+ ctx.once('contentClassified', async ({ playbook, contentCatalog }) => {
496
+ if (!cfg.enabled) {
497
+ logger.debug('autoterm disabled (skipped)');
498
+ return;
499
+ }
500
+ if (!contentCatalog) {
501
+ logger.error('contentCatalog is missing');
502
+ return;
503
+ }
504
+ logEvent('Executing autoterm');
505
+ // -----------------------------------------------------------------------
506
+ // 1. Locate and parse the shared definitions table.
507
+ // -----------------------------------------------------------------------
508
+ const sharedText = resolveDefinitionsFile(cfg.terminology_file, contentCatalog, playbook, logger);
509
+ const sharedDefinitions = sharedText ? parseTerminologyTable(sharedText) : new Map();
510
+ if (!sharedText) {
511
+ logger.warn(`autoterm: shared definitions file "${cfg.terminology_file}" not found; entries render without definitions`);
512
+ }
513
+ else {
514
+ logger.debug(`autoterm: parsed ${sharedDefinitions.size} shared definitions`);
515
+ }
516
+ // -----------------------------------------------------------------------
517
+ // 2. Per component-version: detect participation, scan, generate, inject.
518
+ // -----------------------------------------------------------------------
519
+ const componentVersions = new Map();
520
+ for (const f of contentCatalog.getFiles()) {
521
+ if (!f.src.component || !f.src.version)
522
+ continue;
523
+ const versions = componentVersions.get(f.src.component) ?? new Set();
524
+ versions.add(f.src.version);
525
+ componentVersions.set(f.src.component, versions);
526
+ }
527
+ for (const [component, versions] of componentVersions) {
528
+ for (const version of versions) {
529
+ const files = contentCatalog.getFiles().filter((f) => f.src.component === component && f.src.version === version);
530
+ const pages = files.filter((f) => f.src.family === 'page');
531
+ let terminologyHost;
532
+ let indexHost;
533
+ for (const p of pages) {
534
+ const text = p.contents.toString('utf8');
535
+ if (!terminologyHost && includesPartial(text, cfg.terminology_partial))
536
+ terminologyHost = p;
537
+ if (!indexHost && includesPartial(text, cfg.index_partial))
538
+ indexHost = p;
539
+ }
540
+ if (!terminologyHost && !indexHost) {
541
+ logger.debug(`autoterm: ${component}@${version} — skipped (no page includes a generated partial)`);
542
+ continue;
543
+ }
544
+ // Per-component overrides. The block is optional: participation comes
545
+ // from the include, not from the block.
546
+ const compExt = extByCompVer.get(`${component}@${version}`) ?? {};
547
+ if (!toBool(compExt['enabled'], true)) {
548
+ logger.debug(`autoterm: ${component}@${version} — skipped (enabled: false)`);
549
+ continue;
550
+ }
551
+ const compLocalFile = typeof compExt['local_file'] === 'string' ? compExt['local_file']
552
+ : typeof compExt['localFile'] === 'string' ? compExt['localFile']
553
+ : cfg.local_file;
554
+ const compTermCols = typeof compExt['terminology_cols'] === 'string' ? compExt['terminology_cols'] : cfg.terminology_cols;
555
+ const compIndexCols = typeof compExt['index_cols'] === 'string' ? compExt['index_cols'] : cfg.index_cols;
556
+ const compLetterStyle = compExt['letter_style'] === 'separate' ? 'separate'
557
+ : compExt['letter_style'] === 'combined' ? 'combined'
558
+ : cfg.letter_style;
559
+ const compWriteToDisk = toBool(compExt['write_to_disk'] ?? compExt['writeToDisk'], cfg.write_to_disk);
560
+ // A contributor component's own ext.autoterm local_file naming wins
561
+ // for that component's local table lookup.
562
+ const findLocalFile = (comp, ver) => {
563
+ const theirExt = extByCompVer.get(`${comp}@${ver}`) ?? {};
564
+ const name = typeof theirExt['local_file'] === 'string' ? theirExt['local_file']
565
+ : comp === component ? compLocalFile
566
+ : cfg.local_file;
567
+ return contentCatalog.getFiles().find((f) => f.src.component === comp && f.src.version === ver &&
568
+ f.src.family === 'partial' &&
569
+ (f.src.relative === name || f.src.basename === name));
570
+ };
571
+ // Scan pages and partials, excluding the generated and local partials.
572
+ const excluded = new Set([cfg.terminology_partial, cfg.index_partial, compLocalFile]);
573
+ const scanFiles = files.filter((f) => (f.src.family === 'page' || f.src.family === 'partial') &&
574
+ !excluded.has(f.src.relative ?? '') &&
575
+ !excluded.has(f.src.basename ?? ''));
576
+ // Pre-populate the include-resolution visited set with every file in
577
+ // this scan plus the excluded data partials, so resolved includes are
578
+ // neither double-counted nor pulled from the generated partials.
579
+ const includeVisited = new Set();
580
+ for (const f of files) {
581
+ if (f.src.family !== 'page' && f.src.family !== 'partial')
582
+ continue;
583
+ const key = `${f.src.component}@${f.src.version}:${f.src.module}:${f.src.family}$${f.src.relative}`;
584
+ if (scanFiles.includes(f))
585
+ includeVisited.add(key);
586
+ if (excluded.has(f.src.relative ?? '') || excluded.has(f.src.basename ?? ''))
587
+ includeVisited.add(key);
588
+ }
589
+ const allTerms = [];
590
+ const occurrenceMap = new Map();
591
+ for (const f of scanFiles) {
592
+ let text = f.contents.toString('utf8');
593
+ // Resolve include:: directives on pages so terms living in
594
+ // transcluded content, such as a reference page carried by another
595
+ // component, are collected and attributed to the including page.
596
+ if (f.src.family === 'page') {
597
+ const extra = (0, index_1.collectIncludeText)(text, contentCatalog, component, version, f.src.module ?? 'ROOT', includeVisited);
598
+ if (extra)
599
+ text = `${text}\n${extra}`;
600
+ }
601
+ const terms = scanIndexterms(text);
602
+ allTerms.push(...terms);
603
+ if (f.src.family !== 'page')
604
+ continue;
605
+ const title = pageTitle(text) || (f.src.relative ?? '');
606
+ for (const t of terms) {
607
+ const display = t.abbr ? `${t.full} (${t.abbr})` : t.full;
608
+ const sortKey = display.toLowerCase();
609
+ const occ = occurrenceMap.get(display) ?? {
610
+ display,
611
+ sortKey,
612
+ letter: (sortKey[0] ?? '').toUpperCase(),
613
+ pages: [],
614
+ };
615
+ const ref = { module: f.src.module ?? 'ROOT', relative: f.src.relative ?? '', title };
616
+ if (!occ.pages.some((p) => p.module === ref.module && p.relative === ref.relative)) {
617
+ occ.pages.push(ref);
618
+ }
619
+ occurrenceMap.set(display, occ);
620
+ }
621
+ }
622
+ if (!cfg.suppress_events) {
623
+ logEvent(`${component}@${version} — ${occurrenceMap.size} index term(s) detected`);
624
+ }
625
+ // Merge definitions. Order, lowest to highest precedence: the shared
626
+ // table; contributing components' local files, in component-name
627
+ // order, where a contributor is any component whose content was
628
+ // pulled into this scan through include resolution; the participating
629
+ // component's own local file last.
630
+ const contributors = new Map();
631
+ for (const key of includeVisited) {
632
+ const at = key.indexOf('@');
633
+ const colon = key.indexOf(':', at);
634
+ if (at < 0 || colon < 0)
635
+ continue;
636
+ const comp = key.slice(0, at);
637
+ if (comp !== component)
638
+ contributors.set(comp, key.slice(at + 1, colon));
639
+ }
640
+ let definitions = sharedDefinitions;
641
+ const mergeLocal = (comp, ver, label) => {
642
+ const entry = findLocalFile(comp, ver);
643
+ if (!entry)
644
+ return;
645
+ const local = parseTerminologyTable(entry.contents.toString('utf8'));
646
+ const { merged, overridden } = mergeTerminology(definitions, local);
647
+ definitions = merged;
648
+ const note = overridden.length > 0 ? ` (overriding: ${overridden.join(', ')})` : '';
649
+ logger.info(`autoterm: ${component}@${version} — merged ${local.size} definitions from ${label} ${cfg.local_file}${note}`);
650
+ };
651
+ for (const comp of [...contributors.keys()].sort()) {
652
+ mergeLocal(comp, contributors.get(comp), `contributor ${comp}'s`);
653
+ }
654
+ mergeLocal(component, version, 'own');
655
+ // Generate and inject.
656
+ if (terminologyHost) {
657
+ const hostText = terminologyHost.contents.toString('utf8');
658
+ const cols = pageAttribute(hostText, 'autoterm-terminology-cols') ?? compTermCols;
659
+ const style = pageAttribute(hostText, 'autoterm-letter-style') === 'separate' ? 'separate'
660
+ : pageAttribute(hostText, 'autoterm-letter-style') === 'combined' ? 'combined'
661
+ : compLetterStyle;
662
+ const entries = buildTerminologyEntries(allTerms, definitions);
663
+ const content = generateTerminologyPartial(entries, cols, style, cfg.attribution);
664
+ injectPartial(contentCatalog, files, component, version, cfg.terminology_partial, content, compWriteToDisk, logger);
665
+ }
666
+ if (indexHost) {
667
+ const hostText = indexHost.contents.toString('utf8');
668
+ const cols = pageAttribute(hostText, 'autoterm-index-cols') ?? compIndexCols;
669
+ const content = generateIndexPartial([...occurrenceMap.values()], cols);
670
+ injectPartial(contentCatalog, files, component, version, cfg.index_partial, content, compWriteToDisk, logger);
671
+ }
672
+ }
673
+ }
674
+ logEvent('autoterm finished');
675
+ });
676
+ }
677
+ // ---------------------------------------------------------------------------
678
+ // Helpers
679
+ // ---------------------------------------------------------------------------
680
+ /**
681
+ * Locate the shared definitions file: catalog partial by resource-ID suffix,
682
+ * then filesystem path, then basename match — mirroring trademark-it's
683
+ * resolution strategies.
684
+ */
685
+ function resolveDefinitionsFile(ref, contentCatalog, playbook, logger) {
686
+ const catalogFile = contentCatalog.getFiles().find((f) => {
687
+ if (f.src.family !== 'partial')
688
+ return false;
689
+ const rel = f.src.relative ?? '';
690
+ const resourceId = `${f.src.module ?? 'ROOT'}:partial$${rel}`;
691
+ return resourceId === ref || resourceId.endsWith(ref) || rel === ref || rel.endsWith(ref);
692
+ });
693
+ if (catalogFile) {
694
+ logger.debug(`autoterm: definitions resolved from catalog (${catalogFile.src.path})`);
695
+ return catalogFile.contents.toString('utf8');
696
+ }
697
+ const fsPath = ref.startsWith('/') ? ref : `${playbook.dir}/${ref}`;
698
+ if (fs.existsSync(fsPath)) {
699
+ logger.debug(`autoterm: definitions resolved from filesystem (${fsPath})`);
700
+ return fs.readFileSync(fsPath, 'utf8');
701
+ }
702
+ const basename = ref.split(/[\\/]/).pop() ?? ref;
703
+ const fallback = contentCatalog.getFiles().find((f) => f.src.family === 'partial' && f.src.basename === basename);
704
+ if (fallback) {
705
+ logger.debug(`autoterm: definitions resolved by basename fallback (${fallback.src.path})`);
706
+ return fallback.contents.toString('utf8');
707
+ }
708
+ return null;
709
+ }
710
+ /**
711
+ * Inject a generated partial into the catalog, replacing an existing entry's
712
+ * contents when a file with that name was already loaded. When write_to_disk
713
+ * is enabled and the component has a local worktree, write the file there
714
+ * too; components loaded from a remote source skip the disk write,
715
+ * mirroring trademark-it.
716
+ */
717
+ function injectPartial(contentCatalog, componentFiles, component, version, relative, content, writeToDisk, logger) {
718
+ const contents = Buffer.from(content, 'utf8');
719
+ const existing = componentFiles.find((f) => f.src.family === 'partial' &&
720
+ (f.src.relative === relative || f.src.path === `modules/ROOT/partials/${relative}`));
721
+ if (existing) {
722
+ existing.contents = contents;
723
+ logger.debug(`autoterm: updated partial$${relative} in ${component}@${version}`);
724
+ }
725
+ else {
726
+ const basename = relative.split('/').pop() ?? relative;
727
+ try {
728
+ contentCatalog.addFile({
729
+ contents,
730
+ src: {
731
+ component,
732
+ version,
733
+ module: 'ROOT',
734
+ family: 'partial',
735
+ relative,
736
+ path: `modules/ROOT/partials/${relative}`,
737
+ basename,
738
+ stem: basename.replace(/\.[^.]+$/, ''),
739
+ extname: basename.includes('.') ? `.${basename.split('.').pop()}` : '',
740
+ mediaType: 'text/asciidoc',
741
+ origin: undefined,
742
+ },
743
+ });
744
+ logger.debug(`autoterm: injected partial$${relative} into ${component}@${version}`);
745
+ }
746
+ catch {
747
+ logger.warn(`autoterm: could not inject partial$${relative} into ${component}@${version}`);
748
+ }
749
+ }
750
+ if (!writeToDisk)
751
+ return;
752
+ const originFile = componentFiles.find((f) => f.src.origin?.worktree);
753
+ const worktree = originFile?.src.origin?.worktree;
754
+ const startPath = originFile?.src.origin?.startPath ?? '';
755
+ if (worktree) {
756
+ const diskPath = path.join(worktree, startPath, 'modules', 'ROOT', 'partials', relative);
757
+ try {
758
+ fs.mkdirSync(path.dirname(diskPath), { recursive: true });
759
+ fs.writeFileSync(diskPath, content, 'utf8');
760
+ logger.debug(`autoterm: wrote partial to disk (${diskPath})`);
761
+ }
762
+ catch (err) {
763
+ logger.warn(`autoterm: could not write partial to disk (${diskPath}): ${String(err)}`);
764
+ }
765
+ }
766
+ else {
767
+ logger.debug(`autoterm: write_to_disk skipped for ${component}@${version} — no local worktree`);
768
+ }
769
+ }
770
+ //# sourceMappingURL=index.js.map