@redpanda-data/docs-extensions-and-macros 5.46.0 → 5.47.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,586 @@
1
+ 'use strict';
2
+
3
+ const { parseAudience, wrapBothAudiences, findGluedConditionals } = require('./audienceScope.js');
4
+ const canonicalizePropertyXrefs = require('./canonicalizePropertyXrefs.js');
5
+
6
+ // A line that opens or closes a delimited block. Duplicating a paragraph that
7
+ // sits inside one would put a conditional between the delimiters and split the
8
+ // block across two branches, so the link is refused there instead.
9
+ const DELIMITER_RX = /^(?:-{4,}|={4,}|\.{4,}|\*{4,}|_{4,}|\+{4,}|\/{4,})\s*$/;
10
+
11
+ // Separates paragraphs: a newline, optional whitespace, another newline.
12
+ const PARAGRAPH_BREAK_RX = /\n[ \t]*\n/g;
13
+
14
+ /**
15
+ * Resolve a property's `links` map into one spec per entry.
16
+ *
17
+ * The map key is the literal text to find in the prose; the value is the target,
18
+ * optionally audience-scoped with the same `cloud-only:` / `self-managed-only:`
19
+ * prefix `see_also` uses. Two target forms:
20
+ *
21
+ * "#<property_name>" a property. Emitted as a prop macro, which resolves the
22
+ * reference page itself and degrades to plain code in a
23
+ * build that does not publish the target -- so a property
24
+ * link needs no conditional and cannot dangle.
25
+ * "xref:<target>" anything else. Emitted as a literal xref with the key as
26
+ * its link text.
27
+ *
28
+ * @param {string} propName - Property that owns the links, for messages.
29
+ * @param {Object<string, string>} links - The `links` override map.
30
+ * @param {Object<string, Object>} properties - Full property map, to validate `#` targets.
31
+ * @returns {{specs: Array<Object>, warnings: Array<string>}}
32
+ */
33
+ function resolveLinkSpecs(propName, links, properties) {
34
+ const specs = [];
35
+ const warnings = [];
36
+ if (!links || typeof links !== 'object' || Array.isArray(links)) return { specs, warnings };
37
+
38
+ for (const [key, rawValue] of Object.entries(links)) {
39
+ if (typeof key !== 'string' || !key.trim()) {
40
+ warnings.push(`${propName}: links contains an empty key; skipped.`);
41
+ continue;
42
+ }
43
+ if (typeof rawValue !== 'string' || !rawValue.trim()) {
44
+ warnings.push(`${propName}: links["${key}"] has no target; skipped.`);
45
+ continue;
46
+ }
47
+
48
+ const scope = parseAudience(rawValue);
49
+ const target = scope.content;
50
+ // The key is the text as it appears in the prose, backticks included. The
51
+ // prop macro wraps the name in its own code element, so its text= takes the
52
+ // bare words; an xref keeps the backticks inside the brackets, matching how
53
+ // see_also entries are written.
54
+ const display = key.trim().replace(/^`+|`+$/g, '');
55
+
56
+ if (target.startsWith('#')) {
57
+ const name = target.slice(1).trim();
58
+ if (!properties[name]) {
59
+ // Never emit a prop macro for a name the macro cannot verify: it would
60
+ // publish as literal macro text on the page.
61
+ warnings.push(`${propName}: links["${key}"] targets #${name}, which is not a known property; left as plain text.`);
62
+ continue;
63
+ }
64
+ specs.push({ key: key.trim(), display, scope, kind: 'property', targetName: name });
65
+ continue;
66
+ }
67
+
68
+ if (target.startsWith('xref:')) {
69
+ specs.push({ key: key.trim(), display: key.trim(), scope, kind: 'xref', target });
70
+ continue;
71
+ }
72
+
73
+ if (target === 'glossterm' || target.startsWith('glossterm:')) {
74
+ // The macro takes the term as its target and looks the definition up in
75
+ // the glossary, so the displayed text is the term. Bare `glossterm` means
76
+ // the key is the term; `glossterm:<Term>` is for prose that says
77
+ // something other than the term's own name.
78
+ const term = target === 'glossterm' ? display : target.slice('glossterm:'.length).trim();
79
+ if (!term) {
80
+ warnings.push(`${propName}: links["${key}"] has a glossterm target with no term; skipped.`);
81
+ continue;
82
+ }
83
+ specs.push({ key: key.trim(), display, scope, kind: 'glossterm', term });
84
+ continue;
85
+ }
86
+
87
+ warnings.push(
88
+ `${propName}: links["${key}"] target "${target}" is not "#<property_name>", "xref:..." or "glossterm"; skipped.`
89
+ );
90
+ }
91
+
92
+ // Longest key first, so a key that is a substring of another cannot steal its
93
+ // match -- `cloud_storage_enabled` must win over `cloud_storage`.
94
+ specs.sort((a, b) => b.key.length - a.key.length);
95
+ return { specs, warnings };
96
+ }
97
+
98
+ /**
99
+ * Render one link spec as AsciiDoc.
100
+ *
101
+ * @param {Object} spec - A spec from resolveLinkSpecs.
102
+ * @returns {string}
103
+ */
104
+ function renderLink(spec) {
105
+ if (spec.kind === 'property') {
106
+ const attrs = ['link=true'];
107
+ if (spec.display && spec.display !== spec.targetName) {
108
+ // A comma would end the attribute early and the rest would become stray
109
+ // positional attributes on the macro.
110
+ attrs.push(spec.display.includes(',') ? `text="${spec.display}"` : `text=${spec.display}`);
111
+ }
112
+ return `prop:${spec.targetName}[${attrs.join(',')}]`;
113
+ }
114
+ if (spec.kind === 'glossterm') {
115
+ // Empty payload: the definition comes from the glossary, not from here.
116
+ return `glossterm:${spec.term}[]`;
117
+ }
118
+ return canonicalizePropertyXrefs(`xref:${spec.target.slice('xref:'.length)}[${spec.display}]`).text;
119
+ }
120
+
121
+ /**
122
+ * Whether an offset sits inside a delimited block.
123
+ *
124
+ * @param {string} text
125
+ * @param {number} index
126
+ * @returns {boolean}
127
+ */
128
+ function insideDelimitedBlock(text, index) {
129
+ let open = 0;
130
+ for (const line of text.slice(0, index).split('\n')) {
131
+ if (DELIMITER_RX.test(line)) open += 1;
132
+ }
133
+ return open % 2 === 1;
134
+ }
135
+
136
+ /**
137
+ * Paragraph boundaries around an offset.
138
+ *
139
+ * @param {string} text
140
+ * @param {number} index
141
+ * @returns {{start: number, end: number}}
142
+ */
143
+ function paragraphBounds(text, index) {
144
+ let start = 0;
145
+ let end = text.length;
146
+ PARAGRAPH_BREAK_RX.lastIndex = 0;
147
+ let match;
148
+ while ((match = PARAGRAPH_BREAK_RX.exec(text)) !== null) {
149
+ if (match.index + match[0].length <= index) {
150
+ start = match.index + match[0].length;
151
+ } else {
152
+ end = match.index;
153
+ break;
154
+ }
155
+ }
156
+ return { start, end };
157
+ }
158
+
159
+ /**
160
+ * Substitute a property's links into one text surface.
161
+ *
162
+ * Only the first occurrence of a key is linked, so a property named four times
163
+ * in one description links once rather than turning the paragraph into a wall of
164
+ * links.
165
+ *
166
+ * An audience-scoped link cannot be wrapped around the link alone: AsciiDoc
167
+ * preprocessor directives must start a line, so they cannot bracket an inline
168
+ * span. The containing paragraph is therefore emitted twice, linked in the
169
+ * branch the scope names and plain in the other -- the same shape
170
+ * topic-property.hbs already uses for the corresponding-cluster-property row.
171
+ *
172
+ * @param {string} text - AsciiDoc to rewrite.
173
+ * @param {Array<Object>} specs - Specs from resolveLinkSpecs.
174
+ * @param {string} propName - For messages.
175
+ * @param {string} surface - Which field this text came from, for messages.
176
+ * @returns {{text: string, applied: Array<string>, warnings: Array<string>}}
177
+ */
178
+ function applyLinksToText(text, specs, propName, surface) {
179
+ const applied = [];
180
+ const warnings = [];
181
+ if (typeof text !== 'string' || !text || !specs.length) return { text, applied, warnings };
182
+
183
+ // Ranges of `out` that are markup THIS call has already emitted, in `out`'s
184
+ // own coordinates, shifted after every splice. "Longest key first" (see the
185
+ // sort in resolveLinkSpecs) stops a substring key from stealing a longer
186
+ // key's match, but it does not stop the REVERSE: a longer key's rendered
187
+ // macro can contain a SHORTER key as a literal substring of its own link
188
+ // text (an xref's label, a prop macro's text= attribute), and without this
189
+ // guard the shorter key's substitution fires a second time INSIDE that
190
+ // markup and corrupts it -- e.g. a key "Tiered Storage" matching inside the
191
+ // xref label a sibling key "Tiered Storage v2" just emitted, producing
192
+ // `xref:...[xref:...[Tiered Storage] v2]`. Not reachable from today's
193
+ // corpus (checked: no key is a substring of another key's emitted markup
194
+ // on any one property), but the ordering guarantee should hold regardless
195
+ // of what a future override adds.
196
+ const protectedRanges = [];
197
+ const shiftRangesFrom = (fromIndex, delta) => {
198
+ for (const range of protectedRanges) {
199
+ if (range.start >= fromIndex) {
200
+ range.start += delta;
201
+ range.end += delta;
202
+ }
203
+ }
204
+ };
205
+ const findUnprotectedIndex = (haystack, key) => {
206
+ let from = 0;
207
+ for (;;) {
208
+ const idx = haystack.indexOf(key, from);
209
+ if (idx === -1) return -1;
210
+ const overlapsMarkup = protectedRanges.some((r) => idx < r.end && idx + key.length > r.start);
211
+ if (!overlapsMarkup) return idx;
212
+ from = idx + 1;
213
+ }
214
+ };
215
+
216
+ // Two passes, unscoped first. A scoped spec duplicates the paragraph it
217
+ // matches into an ifdef/ifndef pair, and every later substitution uses
218
+ // indexOf, which finds only the FIRST copy. So an unscoped link sharing a
219
+ // paragraph with a scoped one would be applied to one branch and left as
220
+ // plain text in the other, and a reader of that build silently loses a link
221
+ // they should have. Applying every unscoped spec before any duplication
222
+ // makes the result independent of the order the keys happen to sit in the
223
+ // overrides JSON -- which is what it depended on before.
224
+ const ordered = [
225
+ ...specs.filter((spec) => !(spec.scope.cloudOnly || spec.scope.selfManagedOnly)),
226
+ ...specs.filter((spec) => spec.scope.cloudOnly || spec.scope.selfManagedOnly)
227
+ ];
228
+
229
+ let out = text;
230
+ for (const spec of ordered) {
231
+ if (spec.scope.cloudOnly || spec.scope.selfManagedOnly) break; // scoped specs handled below
232
+ const index = findUnprotectedIndex(out, spec.key);
233
+ if (index === -1) continue;
234
+
235
+ const link = renderLink(spec);
236
+ out = out.slice(0, index) + link + out.slice(index + spec.key.length);
237
+ shiftRangesFrom(index + spec.key.length, link.length - spec.key.length);
238
+ protectedRanges.push({ start: index, end: index + link.length });
239
+ applied.push(spec.key);
240
+ }
241
+
242
+ // Scoped specs: located against the fully-unscoped-substituted `out`
243
+ // BEFORE any scoped duplication, then grouped by the paragraph each one
244
+ // matches in. Locating first means one spec's duplication never shifts
245
+ // where a sibling spec is found; grouping means every spec matching the
246
+ // SAME paragraph duplicates it exactly ONCE, applying all of them to
247
+ // their own branch of that one duplication.
248
+ //
249
+ // Without the grouping, `default_redpanda_storage_mode`'s three
250
+ // self-managed-only links -- two of which share one bullet-list paragraph
251
+ // -- duplicated that paragraph once per spec, so the second spec's
252
+ // duplication wrapped the FIRST spec's already-emitted ifdef/ifndef pair
253
+ // as its "paragraph", nesting one conditional inside the other. The
254
+ // nested branch is one neither build's attribute state ever satisfies, so
255
+ // the link inside it renders in NEITHER audience, silently, with the run
256
+ // summary still reporting it as applied.
257
+ const scopedSpecs = ordered.filter((spec) => spec.scope.cloudOnly || spec.scope.selfManagedOnly);
258
+ const located = [];
259
+ for (const spec of scopedSpecs) {
260
+ const index = findUnprotectedIndex(out, spec.key);
261
+ if (index === -1) continue;
262
+ if (insideDelimitedBlock(out, index)) {
263
+ // Splitting a listing or example block across two conditional branches
264
+ // would break the block in both. Leave the text plain and say so.
265
+ warnings.push(
266
+ `${propName}: links["${spec.key}"] is audience-scoped but matches inside a delimited block in ${surface}; ` +
267
+ 'left as plain text. Move the sentence out of the block, or drop the scope prefix.'
268
+ );
269
+ continue;
270
+ }
271
+ located.push({ spec, index, bounds: paragraphBounds(out, index) });
272
+ }
273
+
274
+ const groups = [];
275
+ for (const item of located) {
276
+ let group = groups.find((g) => g.bounds.start === item.bounds.start && g.bounds.end === item.bounds.end);
277
+ if (!group) {
278
+ group = { bounds: item.bounds, items: [] };
279
+ groups.push(group);
280
+ }
281
+ group.items.push(item);
282
+ }
283
+ // Right-to-left, so replacing one group's span never shifts the start
284
+ // offset of a group still to be processed (groups never overlap, since
285
+ // they come from paragraphBounds against the same unmodified `out`).
286
+ groups.sort((a, b) => b.bounds.start - a.bounds.start);
287
+
288
+ for (const group of groups) {
289
+ const { start, end } = group.bounds;
290
+ const paragraph = out.slice(start, end);
291
+ let cloudVersion = paragraph;
292
+ let selfManagedVersion = paragraph;
293
+ // Each branch's own protected ranges. Two specs sharing a SCOPE within
294
+ // one group both substitute into the SAME version string (two
295
+ // self-managed-only specs both edit selfManagedVersion), so the same
296
+ // substring-in-emitted-markup hazard the top-level pass guards against
297
+ // applies here too -- and it was not guarded here. "Tiered Storage" and
298
+ // "Tiered Storage v2" as two self-managed-only keys on one paragraph
299
+ // produced `xref:...[xref:...[Tiered Storage] v2]` with zero warnings.
300
+ const cloudRanges = [];
301
+ const selfManagedRanges = [];
302
+ const findUnprotectedIndexIn = (haystack, key, ranges) => {
303
+ let from = 0;
304
+ for (;;) {
305
+ const idx = haystack.indexOf(key, from);
306
+ if (idx === -1) return -1;
307
+ const overlapsMarkup = ranges.some((r) => idx < r.end && idx + key.length > r.start);
308
+ if (!overlapsMarkup) return idx;
309
+ from = idx + 1;
310
+ }
311
+ };
312
+ for (const { spec } of group.items) {
313
+ const link = renderLink(spec);
314
+ if (spec.scope.cloudOnly) {
315
+ const idx = findUnprotectedIndexIn(cloudVersion, spec.key, cloudRanges);
316
+ if (idx === -1) continue;
317
+ cloudVersion = cloudVersion.slice(0, idx) + link + cloudVersion.slice(idx + spec.key.length);
318
+ cloudRanges.push({ start: idx, end: idx + link.length });
319
+ // selfManagedVersion keeps the plain key text: correct, the link is
320
+ // scoped away from that build.
321
+ } else {
322
+ const idx = findUnprotectedIndexIn(selfManagedVersion, spec.key, selfManagedRanges);
323
+ if (idx === -1) continue;
324
+ selfManagedVersion = selfManagedVersion.slice(0, idx) + link + selfManagedVersion.slice(idx + spec.key.length);
325
+ selfManagedRanges.push({ start: idx, end: idx + link.length });
326
+ }
327
+ applied.push(spec.key);
328
+ }
329
+ const both = wrapBothAudiences(cloudVersion, selfManagedVersion);
330
+ out = out.slice(0, start) + both + out.slice(end);
331
+ shiftRangesFrom(end, both.length - paragraph.length);
332
+ }
333
+
334
+ return { text: out, applied, warnings };
335
+ }
336
+
337
+ /**
338
+ * Flatten a description into AsciiDoc, wrapping audience-scoped paragraphs.
339
+ *
340
+ * A description is either a plain string (unchanged) or an array of paragraphs,
341
+ * each optionally carrying a `cloud-only:` / `self-managed-only:` prefix. The
342
+ * array form exists so a Cloud-specific sentence does not have to be written as
343
+ * raw `ifdef::env-cloud[]` inside the JSON string, which is what pinned
344
+ * replication.factor and redpanda.remote.readreplica as permanent overrides: the
345
+ * audit cannot compare prose it cannot separate from markup.
346
+ *
347
+ * @param {string|Array<string>} description
348
+ * @returns {string}
349
+ */
350
+ function flattenDescription(description) {
351
+ if (typeof description === 'string') return description;
352
+ if (!Array.isArray(description)) return description;
353
+
354
+ const parts = [];
355
+ for (const raw of description) {
356
+ if (typeof raw !== 'string' || !raw.trim()) continue;
357
+ const scope = parseAudience(raw);
358
+ if (!scope || !scope.content) continue;
359
+ if (scope.cloudOnly) {
360
+ parts.push(`ifdef::env-cloud[]\n${scope.content}\nendif::[]`);
361
+ } else if (scope.selfManagedOnly) {
362
+ parts.push(`ifndef::env-cloud[]\n${scope.content}\nendif::[]`);
363
+ } else {
364
+ parts.push(scope.content);
365
+ }
366
+ }
367
+ // Joined with a blank line, which is what keeps a conditional from gluing to
368
+ // its neighbours. Without it the preprocessor leaves the surrounding lines
369
+ // adjacent and Asciidoctor merges them into one paragraph -- and it does so in
370
+ // only one branch when the conditional wraps a delimited block, so a
371
+ // self-managed preview can look correct while Cloud loses its paragraph breaks.
372
+ return parts.join('\n\n');
373
+ }
374
+
375
+ /**
376
+ * Normalize an `includes` override into what the template renders.
377
+ *
378
+ * A shared partial pulled into a property entry is block-level docs structure:
379
+ * the "internal use only" warning, or the HTTP Proxy ephemeral-credentials
380
+ * breaking-change notice. Written into the description string it is markup the
381
+ * overrides audit cannot separate from the prose, which pinned seven
382
+ * descriptions as permanent overrides. As its own field the prose stays
383
+ * comparable to source and the include still renders in the same place.
384
+ *
385
+ * Each entry is a resource ID, optionally audience-scoped with the same prefix
386
+ * every other field uses. An entry may carry its own `[attrs]`; without them
387
+ * the generator adds the empty brackets the directive requires.
388
+ *
389
+ * @param {string} propName - For messages.
390
+ * @param {Array<string>|string} raw - The `includes` override value.
391
+ * @param {string[]} warnings - Collected warnings, appended to in place.
392
+ * @returns {Array<{target: string, cloud_only?: boolean, self_managed_only?: boolean}>}
393
+ */
394
+ function normalizeIncludes(propName, raw, warnings) {
395
+ const items = Array.isArray(raw) ? raw : [raw];
396
+ const out = [];
397
+ for (const entry of items) {
398
+ const scope = parseAudience(entry);
399
+ if (!scope || !scope.content) {
400
+ warnings.push(`${propName}: an includes entry has no resource ID; dropped.`);
401
+ continue;
402
+ }
403
+ let target = scope.content;
404
+ if (target.startsWith('include::')) target = target.slice('include::'.length);
405
+ if (!/\$/.test(target) && !target.includes('$')) {
406
+ // Antora resource IDs for a partial carry a family segment; without one
407
+ // the include silently resolves to nothing at build time.
408
+ warnings.push(
409
+ `${propName}: includes entry "${target}" does not look like an Antora resource ID ` +
410
+ '(expected something like reference:partial$name.adoc); emitted as given.'
411
+ );
412
+ }
413
+ if (!target.endsWith(']')) target = `${target}[]`;
414
+ const item = { target };
415
+ if (scope.cloudOnly) item.cloud_only = true;
416
+ if (scope.selfManagedOnly) item.self_managed_only = true;
417
+ out.push(item);
418
+ }
419
+ return out;
420
+ }
421
+
422
+ /**
423
+ * Why the Cloud build cannot reach a link target, or null when it can.
424
+ *
425
+ * Two separate reasons, and only the first was obvious. `cloud_supported: false`
426
+ * says the control plane does not expose the property to customers. But
427
+ * cloud-docs also publishes no broker properties page and no topic properties
428
+ * page at all, so a link to any broker- or topic-scope property dangles there
429
+ * however the install pack feels about it. That case cannot be read off
430
+ * `cloud_supported`: cloud_config.py annotates cluster scope only and leaves the
431
+ * field absent elsewhere, because "absent" means no opinion rather than false.
432
+ *
433
+ * @param {Object} target - The property being linked to.
434
+ * @returns {string|null} A short reason, or null when the target is reachable.
435
+ */
436
+ function cloudUnreachable(target) {
437
+ if (target.cloud_supported === false) return 'cloud_supported: false';
438
+ if (target.config_scope === 'broker') return 'Cloud publishes no broker properties page';
439
+ if (target.config_scope === 'topic') return 'Cloud publishes no topic properties page';
440
+ return null;
441
+ }
442
+
443
+ /**
444
+ * Apply `links` and audience-scoped text to every property in the map, in place.
445
+ *
446
+ * Runs once over the whole map before rendering, because a `#` target has to be
447
+ * checked against the other properties and because the same text reaches the
448
+ * property partials, the deprecated partial and the topic mappings partial.
449
+ *
450
+ * @param {Object<string, Object>} properties - Property map, mutated in place.
451
+ * @returns {{applied: number, unmatched: Array<{property: string, key: string}>, warnings: Array<string>}}
452
+ */
453
+ function applyPropertyLinks(properties) {
454
+ const unmatched = [];
455
+ const warnings = [];
456
+ let applied = 0;
457
+
458
+ for (const [propName, prop] of Object.entries(properties || {})) {
459
+ if (!prop || typeof prop !== 'object') continue;
460
+
461
+ // Flatten first: an array description becomes one string, so a link that
462
+ // matches text in a scoped paragraph is substituted inside the conditional
463
+ // that paragraph already carries rather than triggering a second wrapper.
464
+ if (prop.description !== undefined) {
465
+ prop.description = flattenDescription(prop.description);
466
+ }
467
+ if (prop.includes !== undefined) {
468
+ prop.includes = normalizeIncludes(propName, prop.includes, warnings);
469
+ }
470
+ if (Array.isArray(prop.admonitions)) {
471
+ prop.admonitions = prop.admonitions.filter((item) => {
472
+ if (!item || typeof item !== 'object') return false;
473
+ if (item.cloud_only === true && (item.self_managed_only === true || item.self_hosted_only === true)) {
474
+ warnings.push(
475
+ `${propName}: an admonition sets both cloud_only and self_managed_only, so it would render in neither build; dropped.`
476
+ );
477
+ return false;
478
+ }
479
+ return true;
480
+ });
481
+ }
482
+
483
+ const { specs, warnings: specWarnings } = resolveLinkSpecs(propName, prop.links, properties);
484
+ warnings.push(...specWarnings);
485
+ if (!specs.length) continue;
486
+
487
+ // A cloud-published property linking to one Cloud cannot reach renders as
488
+ // plain code there and makes the prop macro warn on every build. Catching it
489
+ // here says which override to fix, rather than leaving it to an Antora log.
490
+ for (const spec of specs) {
491
+ if (spec.kind !== 'property') continue;
492
+ if (spec.scope.cloudOnly || spec.scope.selfManagedOnly) continue;
493
+ if (!prop.cloud_supported) continue;
494
+ const target = properties[spec.targetName];
495
+ if (!target) continue;
496
+ const reason = cloudUnreachable(target);
497
+ if (reason) {
498
+ warnings.push(
499
+ `${propName}: links["${spec.key}"] targets ${spec.targetName}, which Cloud cannot reach (${reason}), ` +
500
+ 'from a property Cloud does publish. Prefix the target with "self-managed-only:" so the Cloud build gets the plain sentence.'
501
+ );
502
+ }
503
+ }
504
+
505
+ const matched = new Set();
506
+ const run = (text, surface) => {
507
+ const result = applyLinksToText(text, specs, propName, surface);
508
+ result.applied.forEach((key) => matched.add(key));
509
+ warnings.push(...result.warnings);
510
+ applied += result.applied.length;
511
+ return result.text;
512
+ };
513
+
514
+ if (typeof prop.description === 'string') prop.description = run(prop.description, 'description');
515
+ if (typeof prop.acceptable_values === 'string') {
516
+ prop.acceptable_values = run(prop.acceptable_values, 'acceptable_values');
517
+ }
518
+ if (typeof prop.example === 'string') {
519
+ prop.example = run(prop.example, 'example');
520
+ } else if (Array.isArray(prop.example)) {
521
+ // Joined, and left joined. Two reasons: a match spanning two lines is
522
+ // found, and paragraph bounds mean the same thing here as in a
523
+ // description. Handing the array back would break the page -- property.hbs
524
+ // renders the example with a bare {{{example}}}, so Handlebars would
525
+ // stringify an array with commas and the whole YAML block would collapse
526
+ // onto one line. The Python stage already joins an override's example
527
+ // array before the generator sees it, so a string is also the shape the
528
+ // real pipeline uses.
529
+ prop.example = run(prop.example.join('\n'), 'example');
530
+ }
531
+ if (Array.isArray(prop.admonitions)) {
532
+ // A new array of new objects, never a mutation of the admonition
533
+ // objects themselves: a caller that builds several property maps by
534
+ // copying fields BY REFERENCE from one parsed overrides file (as the
535
+ // corpus test's buildCorpus does) shares these exact objects across
536
+ // every copy. Mutating item.text in place meant a second call against
537
+ // a second copy saw text that already had a link applied, so its own
538
+ // key no longer matched -- 12 links that were correctly applied on
539
+ // the first call reported as unmatched on the second and third.
540
+ prop.admonitions = prop.admonitions.map((item, i) => {
541
+ if (item && typeof item.text === 'string') {
542
+ return { ...item, text: run(item.text, `admonitions[${i}].text`) };
543
+ }
544
+ return item;
545
+ });
546
+ }
547
+
548
+ for (const spec of specs) {
549
+ if (!matched.has(spec.key)) unmatched.push({ property: propName, key: spec.key });
550
+ }
551
+ }
552
+
553
+ // Self-check the prose this pass produced, plus any conditional an override
554
+ // still writes by hand. A conditional with no blank line beside it merges the
555
+ // neighbouring paragraphs in both builds, so comparing the two renders sees
556
+ // nothing wrong -- reading the source is what catches it, and naming the
557
+ // property is what makes it fixable.
558
+ for (const [propName, prop] of Object.entries(properties || {})) {
559
+ if (!prop || typeof prop !== 'object') continue;
560
+ const surfaces = [['description', prop.description]];
561
+ if (Array.isArray(prop.admonitions)) {
562
+ prop.admonitions.forEach((item, i) => {
563
+ if (item && typeof item.text === 'string') surfaces.push([`admonitions[${i}].text`, item.text]);
564
+ });
565
+ }
566
+ for (const [surface, text] of surfaces) {
567
+ for (const offender of findGluedConditionals(text)) {
568
+ warnings.push(
569
+ `${propName}: ${surface} has ${offender.directive} with no blank line ${offender.side} it ` +
570
+ `("${offender.neighbour}"). Both builds will merge the neighbouring paragraphs.`
571
+ );
572
+ }
573
+ }
574
+ }
575
+
576
+ return { applied, unmatched, warnings };
577
+ }
578
+
579
+ module.exports = applyPropertyLinks;
580
+ module.exports.applyPropertyLinks = applyPropertyLinks;
581
+ module.exports.flattenDescription = flattenDescription;
582
+ module.exports.resolveLinkSpecs = resolveLinkSpecs;
583
+ module.exports.applyLinksToText = applyLinksToText;
584
+ module.exports.renderLink = renderLink;
585
+ module.exports.cloudUnreachable = cloudUnreachable;
586
+ module.exports.normalizeIncludes = normalizeIncludes;