@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.
- package/bin/doc-tools.js +15 -2
- package/cli-utils/sync-schemas.js +66 -12
- package/docs-data/property-overrides.schema.json +202 -29
- package/package.json +1 -1
- package/tools/overrides-audit/adapters/properties.js +14 -3
- package/tools/overrides-audit/classify.js +175 -3
- package/tools/property-extractor/README.adoc +141 -2
- package/tools/property-extractor/compare-properties.js +12 -2
- package/tools/property-extractor/generate-handlebars-docs.js +262 -11
- package/tools/property-extractor/helpers/applyPropertyLinks.js +586 -0
- package/tools/property-extractor/helpers/audienceScope.js +172 -0
- package/tools/property-extractor/helpers/findAudienceScopeProblems.js +121 -0
- package/tools/property-extractor/helpers/index.js +3 -0
- package/tools/property-extractor/helpers/seeAlsoView.js +10 -24
- package/tools/property-extractor/property_extractor.py +30 -2
- package/tools/property-extractor/templates/property.hbs +32 -2
- package/tools/property-extractor/templates/topic-property.hbs +32 -2
|
@@ -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;
|