@redpanda-data/docs-extensions-and-macros 5.11.1 → 5.12.1
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/docs-data/rpk-overrides.schema.json +744 -0
- package/extensions/set-available-attachment-versions.js +32 -10
- package/macros/DOCUMENTING_UNRELEASED_FEATURES.adoc +188 -0
- package/macros/PROPERTY_AND_ENTERPRISE_REFERENCES.adoc +23 -3
- package/macros/REFERENCE.adoc +98 -9
- package/macros/badge.js +33 -3
- package/macros/enterprise.js +232 -22
- package/macros/prop.js +687 -187
- package/package.json +5 -4
- package/tools/property-extractor/cloud_config.py +16 -3
- package/tools/property-extractor/generate-handlebars-docs.js +100 -0
- package/tools/property-extractor/helpers/anchorName.js +14 -1
- package/tools/property-extractor/helpers/canonicalizePropertyXrefs.js +54 -0
- package/tools/property-extractor/helpers/index.js +2 -0
- package/tools/property-extractor/helpers/normalizePropertyAnchors.js +86 -0
- package/tools/property-extractor/templates/topic-property-mappings.hbs +13 -4
- package/tools/property-extractor/templates/topic-property.hbs +11 -1
- package/tools/redpanda-connect/report-delta.js +49 -3
- package/tools/rpk-docs/extract-conditional-content.js +3 -1
package/macros/prop.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* prop:cloud_storage_enabled[]
|
|
8
8
|
* prop:iceberg_enabled[link=true]
|
|
9
|
-
* prop:
|
|
9
|
+
* prop:retention.ms[link=true,page=properties/topic-properties]
|
|
10
10
|
* prop:write_caching_default[text=write caching]
|
|
11
11
|
*
|
|
12
12
|
* The property renders as a marked <code> element that the docs UI decorates
|
|
@@ -15,25 +15,50 @@
|
|
|
15
15
|
* `admin` or `rack` in unrelated contexts (Helm values, feature settings)
|
|
16
16
|
* are never decorated by mistake.
|
|
17
17
|
*
|
|
18
|
-
* The macro
|
|
19
|
-
*
|
|
20
|
-
* module, the same data the tooltips fetch at runtime). Unknown names are
|
|
21
|
-
* reported according to the property-validate attribute. When no property
|
|
22
|
-
* JSON is available in the build, the macro renders unvalidated with a
|
|
23
|
-
* single warning.
|
|
18
|
+
* The macro's job is to check that a property is actually available to the
|
|
19
|
+
* audience reading the page, and to link it only where a page documents it.
|
|
24
20
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
21
|
+
* WHICH PROPERTIES A COMPONENT MAY REFERENCE
|
|
22
|
+
*
|
|
23
|
+
* Property data exists for two doc sets today:
|
|
24
|
+
*
|
|
25
|
+
* Self-managed (streaming) publishes its own redpanda-properties-<tag>.json
|
|
26
|
+
* per release, as a reference-module attachment. Every property in that
|
|
27
|
+
* release's file is available, and a page validates against the file for
|
|
28
|
+
* its OWN release series: a 25.3 page against 25.3 data, never against a
|
|
29
|
+
* neighbouring release, which would accept properties that release never
|
|
30
|
+
* had and flag removed ones as typos.
|
|
31
|
+
*
|
|
32
|
+
* Cloud is managed streaming, so it has no property data of its own and
|
|
33
|
+
* always tracks the newest streaming file. A cluster property is available
|
|
34
|
+
* only when its cloud_supported field is not false; topic and broker
|
|
35
|
+
* properties are always available, because the Cloud control plane config
|
|
36
|
+
* that flag derives from describes cluster configuration only and has no
|
|
37
|
+
* opinion on the other scopes. A Cloud component is recognized by its
|
|
38
|
+
* env-cloud attribute.
|
|
39
|
+
*
|
|
40
|
+
* Any other component (connect, the agentic data plane, a preview site) has
|
|
41
|
+
* no property data, and none is inferred for it: the macro warns once and
|
|
42
|
+
* renders the property as plain inline code. Supporting a further doc set is
|
|
43
|
+
* a deliberate addition here, not something guessed from a version number --
|
|
44
|
+
* those components carry their own product versions, which say nothing about
|
|
45
|
+
* which Redpanda properties exist.
|
|
46
|
+
*
|
|
47
|
+
* Unavailable and unknown names are reported according to property-validate.
|
|
48
|
+
* Every warning names the dataset the page was checked against, so a typo is
|
|
49
|
+
* distinguishable from an availability or release mismatch.
|
|
50
|
+
*
|
|
51
|
+
* LINKING
|
|
52
|
+
*
|
|
53
|
+
* With link=true the property also links to its reference page, but only when
|
|
54
|
+
* a page in this component actually documents it. The page is discovered by
|
|
55
|
+
* indexing which reference-module page documents each property, scanning
|
|
56
|
+
* property headings in the partials each page includes and respecting include
|
|
57
|
+
* tag filters such as tags=redpanda-cloud. Links are therefore
|
|
58
|
+
* component-relative and stay correct if properties are split across
|
|
59
|
+
* different pages later. Nothing is ever linked across components, and no
|
|
60
|
+
* page target is guessed from a property's scope: a link the build cannot
|
|
61
|
+
* verify would be a broken link. Use page= to set the target explicitly.
|
|
37
62
|
*
|
|
38
63
|
* With helm-path=auto, pages rendered with the env-kubernetes attribute
|
|
39
64
|
* display the property as its Helm values path (storage.tiered.config.*
|
|
@@ -45,9 +70,9 @@
|
|
|
45
70
|
*
|
|
46
71
|
* Document or site attributes:
|
|
47
72
|
*
|
|
48
|
-
* property-validate 'warn' (default) to log
|
|
49
|
-
*
|
|
50
|
-
* validation.
|
|
73
|
+
* property-validate 'warn' (default) to log problems, 'error' to fail
|
|
74
|
+
* the build on an unknown name, 'off' to disable
|
|
75
|
+
* validation and silence these warnings.
|
|
51
76
|
* property-ref-role CSS class applied to the code element
|
|
52
77
|
* (default: property-ref).
|
|
53
78
|
*
|
|
@@ -59,27 +84,35 @@
|
|
|
59
84
|
*/
|
|
60
85
|
|
|
61
86
|
const chalk = require('chalk')
|
|
87
|
+
const semver = require('semver')
|
|
62
88
|
|
|
63
89
|
const $propertyRegistry = Symbol('$propertyRegistry')
|
|
90
|
+
// Release series a component version has no property data for, recorded at
|
|
91
|
+
// load time and reported by the macro on first actual use.
|
|
92
|
+
const $propertySeriesGap = Symbol('$propertySeriesGap')
|
|
64
93
|
|
|
65
94
|
const DEFAULT_ROLE = 'property-ref'
|
|
95
|
+
// Which slice of a dataset a component may reference.
|
|
96
|
+
const SURFACE_ALL = 'all'
|
|
97
|
+
const SURFACE_CLOUD = 'cloud'
|
|
66
98
|
const PROPERTIES_JSON_RX = /^redpanda-properties-(v\d+\.\d+\.\d+(?:-[\w.]+)?)\.json$/
|
|
67
99
|
|
|
68
|
-
// Deterministic fallback only. The primary mechanism is dynamic discovery:
|
|
69
|
-
// the macro indexes which reference-module page actually documents each
|
|
70
|
-
// property (by scanning property headings in the partials each page
|
|
71
|
-
// includes, respecting include tag filters), so links keep working if
|
|
72
|
-
// properties are ever split across different pages.
|
|
73
|
-
const SCOPE_PAGES = {
|
|
74
|
-
cluster: 'properties/cluster-properties',
|
|
75
|
-
broker: 'properties/broker-properties',
|
|
76
|
-
topic: 'properties/topic-properties',
|
|
77
|
-
}
|
|
78
|
-
|
|
79
100
|
const $propertyPageIndex = Symbol('$propertyPageIndex')
|
|
80
|
-
|
|
101
|
+
// Two or more '=': a level-0 heading is the page title, which generates no
|
|
102
|
+
// anchor, so treating it as a property heading produced a link and a tooltip
|
|
103
|
+
// URL pointing at a fragment that does not exist on the page.
|
|
104
|
+
const HEADING_RX = /^={2,}\s+(\S+)\s*$/
|
|
81
105
|
const TAG_MARKER_RX = /^\/\/\s*(tag|end)::([\w-]+)\[\]\s*$/
|
|
82
106
|
const INCLUDE_RX = /^include::([^[]+)\[([^\]]*)\]/
|
|
107
|
+
// A reference page declaring the property collection it publishes.
|
|
108
|
+
const PROPERTY_SOURCE_RX = /^:page-property-source:[ \t]*(\S.*)$/m
|
|
109
|
+
// Claim strength: an explicit declaration beats a generated partial, which
|
|
110
|
+
// beats a bare heading that happens to match a property name.
|
|
111
|
+
const CLAIM_HEADING = 1
|
|
112
|
+
const CLAIM_PARTIAL = 2
|
|
113
|
+
const CLAIM_DECLARED = 3
|
|
114
|
+
// Delimited block fences: listing, literal, example, sidebar, quote, comment.
|
|
115
|
+
const DELIMITER_RX = /^(-{4,}|\.{4,}|={4,}|\*{4,}|_{4,}|\/{4,})$/
|
|
83
116
|
|
|
84
117
|
/**
|
|
85
118
|
* Extract property-style headings from AsciiDoc source together with the
|
|
@@ -118,7 +151,19 @@ function extractHeadingsWithTags (source) {
|
|
|
118
151
|
*/
|
|
119
152
|
function evaluateTagExpression (headingTags, expression) {
|
|
120
153
|
if (!expression) return true
|
|
121
|
-
|
|
154
|
+
// Asciidoctor accepts a quoted value, which is its documented way to write a
|
|
155
|
+
// comma-separated list. Keeping the quotes made the first item look like a
|
|
156
|
+
// positive tag named '"!deprecated', which matches nothing, so the page
|
|
157
|
+
// indexed zero properties and every link on it silently disappeared.
|
|
158
|
+
const unquoted = expression.trim().replace(/^(['"])([\s\S]*)\1$/, '$2')
|
|
159
|
+
const items = unquoted.split(/[;,]/).map((item) => item.trim()).filter(Boolean)
|
|
160
|
+
// '*' selects all tagged regions, '**' all content. Both mean "do not filter
|
|
161
|
+
// on positive tags"; negations still apply.
|
|
162
|
+
const wildcards = items.filter((item) => item === '*' || item === '**')
|
|
163
|
+
if (wildcards.length) {
|
|
164
|
+
const negated = items.filter((item) => item.startsWith('!')).map((item) => item.slice(1))
|
|
165
|
+
return !negated.some((tag) => headingTags.includes(tag))
|
|
166
|
+
}
|
|
122
167
|
const positives = items.filter((item) => !item.startsWith('!'))
|
|
123
168
|
const negatives = items.filter((item) => item.startsWith('!')).map((item) => item.slice(1))
|
|
124
169
|
if (negatives.some((tag) => headingTags.includes(tag))) return false
|
|
@@ -158,8 +203,12 @@ function buildPageIndex (contentCatalog, component, properties, version) {
|
|
|
158
203
|
if (cache[cacheKey]) return cache[cacheKey]
|
|
159
204
|
const index = new Map()
|
|
160
205
|
const partialHeadings = new Map()
|
|
206
|
+
// Antora replaces contents with converted HTML as it goes, but backs the
|
|
207
|
+
// AsciiDoc up at src.contents first, so prefer that: it makes an index built
|
|
208
|
+
// mid-conversion correct instead of merely reporting that it might be wrong.
|
|
209
|
+
const sourceOf = (file) => ((file.src && file.src.contents) || file.contents || '').toString()
|
|
161
210
|
const headingsFor = (file) => {
|
|
162
|
-
if (!partialHeadings.has(file)) partialHeadings.set(file, extractHeadingsWithTags(file
|
|
211
|
+
if (!partialHeadings.has(file)) partialHeadings.set(file, extractHeadingsWithTags(sourceOf(file)))
|
|
163
212
|
return partialHeadings.get(file)
|
|
164
213
|
}
|
|
165
214
|
// Restrict the index to the page's own component version, so a page on an
|
|
@@ -171,35 +220,136 @@ function buildPageIndex (contentCatalog, component, properties, version) {
|
|
|
171
220
|
// an index built lazily mid-conversion can miss includes on pages that
|
|
172
221
|
// already converted. The property-page-index extension warms this cache
|
|
173
222
|
// before conversion starts; warn when that safety net is absent.
|
|
174
|
-
|
|
223
|
+
// Converted HTML starts with a tag. AsciiDoc that starts with '<<<' (a page
|
|
224
|
+
// break) or '<<anchor>>' also starts with '<', and tripping on those told the
|
|
225
|
+
// maintainer to register an extension that was often already running.
|
|
226
|
+
const looksConverted = (candidate) => {
|
|
227
|
+
if (candidate.src && candidate.src.contents) return false
|
|
228
|
+
const head = candidate.contents.toString('utf8', 0, 200).trimStart()
|
|
229
|
+
return head.startsWith('<') && !head.startsWith('<<') && /^<[a-zA-Z!/]/.test(head)
|
|
230
|
+
}
|
|
231
|
+
if (pages.some(looksConverted)) {
|
|
175
232
|
console.warn(chalk.yellow(`prop macro: building the property page index for ${component}@${version || 'any'} after conversion started; some pages are already HTML and their properties may not be indexed. Register '@redpanda-data/docs-extensions-and-macros/extensions/property-page-index' under antora.extensions to build the index up front.`))
|
|
176
233
|
}
|
|
177
|
-
|
|
178
|
-
|
|
234
|
+
// A page can declare that it is the reference for a named collection of
|
|
235
|
+
// properties:
|
|
236
|
+
//
|
|
237
|
+
// = Cluster Configuration Properties
|
|
238
|
+
// :page-property-source: cluster-properties
|
|
239
|
+
//
|
|
240
|
+
// Declared pages are authoritative and are visited first, so an ordinary page
|
|
241
|
+
// that merely happens to carry a heading matching a property name -- 'admin',
|
|
242
|
+
// 'rack' -- can never claim it from the reference page that documents it.
|
|
243
|
+
// Sorting also removes the last of the catalog-order dependence: two pages
|
|
244
|
+
// that both surface a property used to resolve by aggregation order, which is
|
|
245
|
+
// stable for one checkout but not meaningful.
|
|
246
|
+
const declarationOf = (page) => {
|
|
247
|
+
const match = sourceOf(page).match(PROPERTY_SOURCE_RX)
|
|
248
|
+
return match ? match[1].trim() : undefined
|
|
249
|
+
}
|
|
250
|
+
const ordered = pages
|
|
251
|
+
.map((page) => ({ page, declared: declarationOf(page) }))
|
|
252
|
+
.sort((a, b) =>
|
|
253
|
+
Number(Boolean(b.declared)) - Number(Boolean(a.declared)) ||
|
|
254
|
+
String(a.page.src.relative).localeCompare(String(b.page.src.relative)))
|
|
255
|
+
// Which page claimed each property, so a conflict between two *declared*
|
|
256
|
+
// sources can be reported rather than silently resolved.
|
|
257
|
+
const claims = new Map()
|
|
258
|
+
for (const { page, declared } of ordered) {
|
|
259
|
+
const source = sourceOf(page)
|
|
179
260
|
const pagePath = page.src.relative.replace(/\.adoc$/, '')
|
|
180
261
|
const pageUrl = (page.pub && page.pub.url) || undefined
|
|
262
|
+
// First claim wins. Declared pages come first, so a declaration always
|
|
263
|
+
// beats inference; two declared pages claiming one property is an authoring
|
|
264
|
+
// conflict worth reporting rather than resolving quietly.
|
|
265
|
+
// Claims are ranked, not first-come. A declared page outranks everything; a
|
|
266
|
+
// property heading pulled in from a generated partial outranks a bare
|
|
267
|
+
// heading that merely happens to match a property name, which is how a page
|
|
268
|
+
// titled '= admin' used to claim the admin broker property from the
|
|
269
|
+
// reference page that documents it.
|
|
270
|
+
const claim = (name, entry, strength) => {
|
|
271
|
+
// Declaring a page raises its claims above every undeclared page, but
|
|
272
|
+
// keeps the heading-versus-partial ordering within the page: a reference
|
|
273
|
+
// page can both hand-write a section for a property and include the
|
|
274
|
+
// generated partial that documents it.
|
|
275
|
+
const rank = (declared ? CLAIM_DECLARED : 0) + strength
|
|
276
|
+
const existing = claims.get(name)
|
|
277
|
+
if (existing) {
|
|
278
|
+
if (existing.rank > rank) return
|
|
279
|
+
// A page claiming the same property twice is not a conflict between
|
|
280
|
+
// sources, it is one page documenting it in two places.
|
|
281
|
+
if (existing.page === pagePath) return
|
|
282
|
+
if (existing.rank === rank) {
|
|
283
|
+
// Same standing. Keep the first, and say so when both pages
|
|
284
|
+
// explicitly declared themselves the source: that is an authoring
|
|
285
|
+
// conflict, not something to resolve quietly.
|
|
286
|
+
if (declared && existing.declared) {
|
|
287
|
+
warnOnce(contentCatalog, `claim:${component}:${name}`,
|
|
288
|
+
`prop macro: '${name}' is claimed by two declared property sources in ${component}: ${existing.page} (:page-property-source: ${existing.declared}) and ${pagePath} (:page-property-source: ${declared}). Links use ${existing.page}. Remove the declaration from whichever page is not the reference for it.`)
|
|
289
|
+
}
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
claims.set(name, { page: pagePath, declared, rank })
|
|
294
|
+
index.set(name, entry)
|
|
295
|
+
}
|
|
296
|
+
let inDelimitedBlock = false
|
|
181
297
|
for (const line of source.split('\n')) {
|
|
298
|
+
// A property name inside a listing, literal, or comment block is sample
|
|
299
|
+
// text, not documentation of that property. Indexing it handed the
|
|
300
|
+
// property to a page that renders no anchor for it.
|
|
301
|
+
if (DELIMITER_RX.test(line.trim())) {
|
|
302
|
+
inDelimitedBlock = !inDelimitedBlock
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
if (inDelimitedBlock) continue
|
|
182
306
|
const heading = line.match(HEADING_RX)
|
|
183
307
|
if (heading && Object.prototype.hasOwnProperty.call(properties, heading[1])) {
|
|
184
|
-
|
|
308
|
+
// First writer wins, as for partial-derived entries. Setting
|
|
309
|
+
// unconditionally let any reference page whose title happens to be a
|
|
310
|
+
// property name -- 'admin', 'rack' -- steal it from the reference page
|
|
311
|
+
// that documents it, and a doctitle produces no anchor at all, so both
|
|
312
|
+
// the link and the tooltip URL pointed at nothing.
|
|
313
|
+
claim(heading[1], { page: pagePath, url: pageUrl }, CLAIM_HEADING)
|
|
185
314
|
continue
|
|
186
315
|
}
|
|
187
316
|
const include = line.match(INCLUDE_RX)
|
|
188
317
|
if (!include) continue
|
|
189
318
|
const partialRef = parsePartialTarget(include[1].trim(), page.src)
|
|
190
319
|
if (!partialRef) continue
|
|
191
|
-
|
|
320
|
+
// Match a quoted value first. An unquoted value ends at the next comma
|
|
321
|
+
// because the attrlist is comma-separated -- but a quoted one may contain
|
|
322
|
+
// commas, and that is AsciiDoc's documented way to write a comma-separated
|
|
323
|
+
// tag list. Capturing up to the first comma regardless truncated
|
|
324
|
+
// tags="!deprecated,!exclude-from-docs" to '"!deprecated', which matches
|
|
325
|
+
// nothing, so the page indexed no properties and every link on it silently
|
|
326
|
+
// disappeared. Asciidoctor honours the quoted form, so the index has to.
|
|
327
|
+
const tagsMatch = include[2].match(/tags?=("[^"]*"|'[^']*'|[^,\]]*)/)
|
|
192
328
|
const expression = tagsMatch && tagsMatch[1]
|
|
193
329
|
const partials = contentCatalog.findBy({ component: partialRef.component, module: partialRef.module, family: 'partial' }) || []
|
|
194
330
|
const matching = partials.filter((candidate) => candidate.src.relative === partialRef.relative)
|
|
195
331
|
// Same-component includes resolve within the page's version; for
|
|
196
332
|
// cross-component includes take whichever version the catalog has.
|
|
197
|
-
|
|
333
|
+
// Resolve the partial the way Antora does. A same-component include is
|
|
334
|
+
// pinned to the including page's version, so falling back to another
|
|
335
|
+
// version's copy indexed foreign-release content against this page. A
|
|
336
|
+
// version-less cross-component include resolves to that component's
|
|
337
|
+
// latest version, not to whichever copy the catalog yielded first.
|
|
338
|
+
let partial
|
|
339
|
+
if (partialRef.component === page.src.component) {
|
|
340
|
+
partial = matching.find((candidate) => candidate.src.version === page.src.version)
|
|
341
|
+
} else {
|
|
342
|
+
const lender = typeof contentCatalog.getComponent === 'function' &&
|
|
343
|
+
contentCatalog.getComponent(partialRef.component)
|
|
344
|
+
const latest = lender && lender.latest && lender.latest.version
|
|
345
|
+
partial = matching.find((candidate) => candidate.src.version === latest) ||
|
|
346
|
+
[...matching].sort((a, b) => String(b.src.version).localeCompare(String(a.src.version)))[0]
|
|
347
|
+
}
|
|
198
348
|
if (!partial) continue
|
|
199
349
|
for (const entry of headingsFor(partial)) {
|
|
200
350
|
if (!Object.prototype.hasOwnProperty.call(properties, entry.name)) continue
|
|
201
351
|
if (!evaluateTagExpression(entry.tags, expression)) continue
|
|
202
|
-
|
|
352
|
+
claim(entry.name, { page: pagePath, url: pageUrl }, CLAIM_PARTIAL)
|
|
203
353
|
}
|
|
204
354
|
}
|
|
205
355
|
}
|
|
@@ -224,31 +374,158 @@ function propertyAnchor (name) {
|
|
|
224
374
|
// config.node, and every other cluster property maps to config.cluster --
|
|
225
375
|
// the path the docs recommend for all cluster properties.
|
|
226
376
|
function helmValuesPath (name, scope) {
|
|
377
|
+
// Topic properties are not set in values.yaml at all -- they are set per topic
|
|
378
|
+
// through the Kafka API, rpk, or a Topic resource -- so there is no Helm path
|
|
379
|
+
// to show. Returning config.cluster.<name> for them produced a confident,
|
|
380
|
+
// copy-pasteable key that does not exist, which is the exact failure the
|
|
381
|
+
// config_ref macro's blanket prefixing had. Undefined means "no Helm path",
|
|
382
|
+
// and the caller falls back to the plain property name.
|
|
383
|
+
if (scope === 'topic') return undefined
|
|
227
384
|
if (name.startsWith('cloud_storage_')) return `storage.tiered.config.${name}`
|
|
228
385
|
if (scope === 'broker') return `config.node.${name}`
|
|
229
386
|
return `config.cluster.${name}`
|
|
230
387
|
}
|
|
231
388
|
|
|
232
|
-
|
|
389
|
+
// Warnings are deduplicated per build. Antora's watch mode (gulp/npm start)
|
|
390
|
+
// reuses one process across many builds, so a module-level guard would report
|
|
391
|
+
// each problem only on a session's first build and stay silent afterwards --
|
|
392
|
+
// exactly when a writer is iterating and most wants to see it. The content
|
|
393
|
+
// catalog is rebuilt per build, so keying off it scopes the guard correctly.
|
|
394
|
+
const $warned = Symbol('$warned')
|
|
395
|
+
|
|
396
|
+
function warnOnce (contentCatalog, key, message) {
|
|
397
|
+
const seen = contentCatalog[$warned] || (contentCatalog[$warned] = new Set())
|
|
398
|
+
if (seen.has(key)) return false
|
|
399
|
+
seen.add(key)
|
|
400
|
+
console.warn(chalk.yellow(message))
|
|
401
|
+
return true
|
|
402
|
+
}
|
|
233
403
|
|
|
234
404
|
/**
|
|
235
|
-
* Compare two
|
|
236
|
-
*
|
|
405
|
+
* Compare two property tags by release precedence, newest last.
|
|
406
|
+
*
|
|
407
|
+
* Prereleases matter here: a release cycle publishes v26.3.1-rc1, then rc2,
|
|
408
|
+
* then the GA v26.3.1, and the beta branch has to land on the newest of them
|
|
409
|
+
* deterministically. A numeric-only comparison rates all three equal, leaving
|
|
410
|
+
* the winner to catalog iteration order -- so rc1 could beat rc2, or an RC
|
|
411
|
+
* could beat its own GA.
|
|
412
|
+
*
|
|
413
|
+
* Redpanda tags spell prereleases as one identifier (-rc2, not -rc.2), which
|
|
414
|
+
* the semver spec compares as a string: 'rc10' would sort below 'rc2'. Split
|
|
415
|
+
* the trailing digits into their own identifier first so they compare
|
|
416
|
+
* numerically.
|
|
237
417
|
*/
|
|
238
418
|
function compareTags (a, b) {
|
|
239
|
-
const
|
|
419
|
+
const normalize = (tag) => String(tag).replace(/^v/, '').replace(/-([a-z]+)(\d+)/gi, '-$1.$2')
|
|
420
|
+
const left = semver.valid(normalize(a))
|
|
421
|
+
const right = semver.valid(normalize(b))
|
|
422
|
+
if (left && right) return semver.compare(left, right)
|
|
423
|
+
// Not semver (a hand-named file, say): fall back to numeric release order.
|
|
424
|
+
const parse = (tag) => String(tag).replace(/^v/, '').split('-')[0].split('.').map(Number)
|
|
240
425
|
const [a1, a2, a3] = parse(a)
|
|
241
426
|
const [b1, b2, b3] = parse(b)
|
|
242
|
-
return a1 - b1 || a2 - b2 || a3 - b3
|
|
427
|
+
return (a1 - b1) || (a2 - b2) || (a3 - b3) || 0
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Whether a tag names a prerelease (an RC or beta build).
|
|
432
|
+
*/
|
|
433
|
+
function isPrerelease (tag) {
|
|
434
|
+
const normalized = String(tag).replace(/^v/, '')
|
|
435
|
+
const valid = semver.valid(normalized)
|
|
436
|
+
return valid ? Boolean(semver.prerelease(valid)) : /-/.test(normalized)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The release series ('26.2') of a component version ('26.2', '25.3.1') or a
|
|
441
|
+
* properties tag ('v26.2.1', 'v26.3.1-rc1'). Undefined when the value names no
|
|
442
|
+
* numeric series.
|
|
443
|
+
*/
|
|
444
|
+
function releaseSeries (value) {
|
|
445
|
+
const match = String(value == null ? '' : value).replace(/^v/, '').match(/^(\d+)\.(\d+)/)
|
|
446
|
+
return match ? `${match[1]}.${match[2]}` : undefined
|
|
243
447
|
}
|
|
244
448
|
|
|
245
449
|
/**
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
450
|
+
* Whether an Antora attribute counts as declared.
|
|
451
|
+
*
|
|
452
|
+
* Antora keeps a component version's antora.yml attributes verbatim, and
|
|
453
|
+
* setting one to false is how a maintainer turns it off: the key stays present.
|
|
454
|
+
* A bare `!== undefined` test therefore reads `env-cloud: false` as "this is
|
|
455
|
+
* Cloud", the opposite of what was written. Sibling readers of this attribute
|
|
456
|
+
* in this repo use truthiness, so match them, while still honouring AsciiDoc's
|
|
457
|
+
* set-but-empty convention.
|
|
458
|
+
*/
|
|
459
|
+
function isAttributeSet (value) {
|
|
460
|
+
if (value === undefined || value === null || value === false) return false
|
|
461
|
+
return String(value).toLowerCase() !== 'false'
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* The asciidoc attributes declared for one component version, which is where
|
|
466
|
+
* env-cloud lives. Read from the catalog rather than the converting document
|
|
467
|
+
* so the property-page-index extension resolves surfaces identically.
|
|
468
|
+
*/
|
|
469
|
+
function componentAttributes (contentCatalog, component, version) {
|
|
470
|
+
if (typeof contentCatalog.getComponent !== 'function') return {}
|
|
471
|
+
const found = contentCatalog.getComponent(component)
|
|
472
|
+
if (!found) return {}
|
|
473
|
+
const versions = found.versions || []
|
|
474
|
+
const match = versions.find((entry) => entry.version === version) || found.latest || versions[0]
|
|
475
|
+
return (match && match.asciidoc && match.asciidoc.attributes) || {}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Every properties JSON in the build, as {tag, file, component, version}.
|
|
480
|
+
*/
|
|
481
|
+
function propertyDatasets (contentCatalog) {
|
|
482
|
+
const found = []
|
|
483
|
+
for (const attachment of contentCatalog.findBy({ family: 'attachment' }) || []) {
|
|
484
|
+
if (attachment.src.module !== 'reference') continue
|
|
485
|
+
const match = attachment.src.relative.split('/').pop().match(PROPERTIES_JSON_RX)
|
|
486
|
+
if (!match) continue
|
|
487
|
+
found.push({ tag: match[1], file: attachment, component: attachment.src.component, version: attachment.src.version })
|
|
488
|
+
}
|
|
489
|
+
return found
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The newest dataset in a list, with ties broken on a stable identity rather
|
|
494
|
+
* than on catalog iteration order: two attachments can carry the same tag, and
|
|
495
|
+
* picking whichever the catalog happened to yield first made the chosen
|
|
496
|
+
* property data differ between builds of identical content.
|
|
497
|
+
*/
|
|
498
|
+
const newestDataset = (list) =>
|
|
499
|
+
list.reduce((best, entry) => {
|
|
500
|
+
if (!best) return entry
|
|
501
|
+
const byTag = compareTags(entry.tag, best.tag)
|
|
502
|
+
if (byTag > 0) return entry
|
|
503
|
+
if (byTag < 0) return best
|
|
504
|
+
return stableKey(entry) < stableKey(best) ? entry : best
|
|
505
|
+
}, null)
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Load and cache the property data for one page's component and version, plus
|
|
509
|
+
* which slice of it that component may reference. Returns undefined when the
|
|
510
|
+
* component has no property data, which is the signal to render plain and warn.
|
|
511
|
+
*
|
|
512
|
+
* Two doc sets have property data:
|
|
513
|
+
*
|
|
514
|
+
* A component that publishes its own redpanda-properties-<tag>.json (the
|
|
515
|
+
* self-managed docs) uses the file for its own release series, newest patch
|
|
516
|
+
* within it, and may reference every property in it. Matching by series
|
|
517
|
+
* rather than by newest tag matters because the main branch carries the
|
|
518
|
+
* previous series' file alongside its own, and a prerelease could land there
|
|
519
|
+
* too. A versioned page whose series has no file goes unvalidated with an
|
|
520
|
+
* explicit warning rather than borrowing a neighbouring release's data.
|
|
521
|
+
*
|
|
522
|
+
* A Cloud component (env-cloud) is managed streaming: no data of its own, so
|
|
523
|
+
* it tracks the newest streaming (or ROOT) file, and may reference only the
|
|
524
|
+
* properties marked cloud_supported.
|
|
525
|
+
*
|
|
526
|
+
* Anything else gets nothing. Component versions elsewhere in the site carry
|
|
527
|
+
* their own product versions, which say nothing about Redpanda releases, so no
|
|
528
|
+
* dataset is inferred for them.
|
|
252
529
|
*/
|
|
253
530
|
function loadProperties (config) {
|
|
254
531
|
const contentCatalog = config && config.contentCatalog
|
|
@@ -263,48 +540,209 @@ function loadProperties (config) {
|
|
|
263
540
|
* property-page-index extension can warm the caches before conversion.
|
|
264
541
|
*/
|
|
265
542
|
function loadPropertiesFor (contentCatalog, pageComponent, pageVersion) {
|
|
266
|
-
const cacheKey = `${pageComponent}@${pageVersion}`
|
|
543
|
+
const cacheKey = `${pageComponent}@${pageVersion}`
|
|
267
544
|
const cache = contentCatalog[$propertyRegistry] || (contentCatalog[$propertyRegistry] = {})
|
|
268
545
|
if (cache[cacheKey] !== undefined) return cache[cacheKey] || undefined
|
|
269
546
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
547
|
+
const datasets = propertyDatasets(contentCatalog)
|
|
548
|
+
const own = datasets.filter((d) => d.component === pageComponent)
|
|
549
|
+
let pick
|
|
550
|
+
let surface
|
|
551
|
+
// The candidates the corrupt-file fallback may fall back TO. It has to be the
|
|
552
|
+
// same filtered set `pick` was chosen from, not everything the component
|
|
553
|
+
// publishes: falling back across release series would validate a 25.3 page
|
|
554
|
+
// against 25.2 data, which is exactly the borrowing this function refuses to
|
|
555
|
+
// do on the primary path.
|
|
556
|
+
let pool
|
|
557
|
+
|
|
558
|
+
// Cloud is decided first, and deliberately outranks the component's own
|
|
559
|
+
// attachments. The property extractor writes
|
|
560
|
+
// modules/reference/attachments/redpanda-properties-<tag>.json into whatever
|
|
561
|
+
// repo it runs in, so running it inside cloud-docs would otherwise flip that
|
|
562
|
+
// component to the unfiltered surface and switch off cloud_supported gating
|
|
563
|
+
// site-wide, with no warning anywhere.
|
|
564
|
+
if (isAttributeSet(componentAttributes(contentCatalog, pageComponent, pageVersion)['env-cloud'])) {
|
|
565
|
+
surface = SURFACE_CLOUD
|
|
566
|
+
// GA only: Cloud runs released Redpanda, so an RC published on the beta
|
|
567
|
+
// branch must not become the dataset Cloud pages validate against.
|
|
568
|
+
// Only streaming/ROOT count as lenders -- another component's product
|
|
569
|
+
// version is not a Redpanda release candidate.
|
|
570
|
+
const lenders = datasets.filter((d) => d.component === 'streaming' || d.component === 'ROOT')
|
|
571
|
+
const released = lenders.filter((d) => !isPrerelease(d.tag))
|
|
572
|
+
pool = released
|
|
573
|
+
pick =
|
|
574
|
+
newestDataset(released.filter((d) => d.component === 'streaming')) ||
|
|
575
|
+
newestDataset(released.filter((d) => d.component === 'ROOT'))
|
|
576
|
+
if (!pick && lenders.length) {
|
|
577
|
+
// Self-managed data exists but all of it is a prerelease. Say that
|
|
578
|
+
// specifically rather than claiming no property data was found.
|
|
579
|
+
const gaps = contentCatalog[$propertySeriesGap] || (contentCatalog[$propertySeriesGap] = {})
|
|
580
|
+
gaps[cacheKey] = {
|
|
581
|
+
kind: 'cloud-prerelease-only',
|
|
582
|
+
component: pageComponent,
|
|
583
|
+
version: pageVersion,
|
|
584
|
+
have: [...new Set(lenders.map((d) => d.tag))].sort(compareTags),
|
|
585
|
+
}
|
|
586
|
+
cache[cacheKey] = null
|
|
587
|
+
return undefined
|
|
588
|
+
}
|
|
589
|
+
} else if (own.length) {
|
|
590
|
+
surface = SURFACE_ALL
|
|
591
|
+
const series = releaseSeries(pageVersion)
|
|
592
|
+
if (series) {
|
|
593
|
+
const inSeries = own.filter((d) => releaseSeries(d.tag) === series)
|
|
594
|
+
pool = inSeries
|
|
595
|
+
pick = newestDataset(inSeries.filter((d) => d.version === pageVersion)) || newestDataset(inSeries)
|
|
596
|
+
if (!pick) {
|
|
597
|
+
// Record the gap rather than reporting it here: this also runs from the
|
|
598
|
+
// property-page-index extension, once per component version in the
|
|
599
|
+
// build, so warning now would report versions no page of which uses the
|
|
600
|
+
// macro. The macro reports it on first actual use.
|
|
601
|
+
const gaps = contentCatalog[$propertySeriesGap] || (contentCatalog[$propertySeriesGap] = {})
|
|
602
|
+
gaps[cacheKey] = {
|
|
603
|
+
kind: 'series',
|
|
604
|
+
component: pageComponent,
|
|
605
|
+
version: pageVersion,
|
|
606
|
+
series,
|
|
607
|
+
have: [...new Set(own.map((d) => d.tag))].sort(compareTags),
|
|
608
|
+
}
|
|
609
|
+
cache[cacheKey] = null
|
|
610
|
+
return undefined
|
|
611
|
+
}
|
|
612
|
+
} else {
|
|
613
|
+
pool = own
|
|
614
|
+
pick = newestDataset(own.filter((d) => d.version === pageVersion)) || newestDataset(own)
|
|
615
|
+
}
|
|
283
616
|
}
|
|
284
|
-
const newest = (list) => list.reduce((best, entry) => (!best || compareTags(entry.tag, best.tag) > 0 ? entry : best), null)
|
|
285
|
-
const pick =
|
|
286
|
-
newest(candidates.filter((c) => c.component === pageComponent && c.version === pageVersion)) ||
|
|
287
|
-
newest(candidates.filter((c) => c.component === pageComponent)) ||
|
|
288
|
-
newest(candidates.filter((c) => c.component === 'streaming')) ||
|
|
289
|
-
newest(candidates.filter((c) => c.component === 'ROOT')) ||
|
|
290
|
-
newest(candidates)
|
|
291
617
|
|
|
618
|
+
// A corrupt or half-written attachment must not take the whole build down,
|
|
619
|
+
// and must not mask a good file: try candidates newest-first and use the
|
|
620
|
+
// first one that actually carries properties, reporting each one skipped.
|
|
292
621
|
let registry = null
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
622
|
+
const candidates = orderedCandidates(pick, pool)
|
|
623
|
+
for (const candidate of candidates) {
|
|
624
|
+
const properties = readProperties(candidate, contentCatalog)
|
|
625
|
+
if (!properties) continue
|
|
626
|
+
registry = { tag: candidate.tag, properties, component: candidate.component, version: candidate.version, surface }
|
|
627
|
+
break
|
|
628
|
+
}
|
|
629
|
+
if (!registry && candidates.length) {
|
|
630
|
+
// Every candidate was unusable. Record that, because the caller's fallback
|
|
631
|
+
// diagnostic says no attachment exists anywhere -- which contradicts the
|
|
632
|
+
// per-file warning readProperties just emitted and sends the writer looking
|
|
633
|
+
// for a file that is sitting right there.
|
|
634
|
+
const gaps = contentCatalog[$propertySeriesGap] || (contentCatalog[$propertySeriesGap] = {})
|
|
635
|
+
gaps[cacheKey] = {
|
|
636
|
+
kind: 'unusable',
|
|
637
|
+
component: pageComponent,
|
|
638
|
+
version: pageVersion,
|
|
639
|
+
have: [...new Set(candidates.map((d) => d.tag))].sort(compareTags),
|
|
297
640
|
}
|
|
298
641
|
}
|
|
299
|
-
// Cache null too, so a missing
|
|
642
|
+
// Cache null too, so a missing dataset is only searched for once per build.
|
|
300
643
|
cache[cacheKey] = registry
|
|
301
644
|
return registry || undefined
|
|
302
645
|
}
|
|
303
646
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
647
|
+
/**
|
|
648
|
+
* The chosen dataset first, then the remaining candidates from the same pool in
|
|
649
|
+
* descending release order, so an unusable file falls through to the next best
|
|
650
|
+
* rather than voiding the component's data entirely.
|
|
651
|
+
*/
|
|
652
|
+
function orderedCandidates (pick, pool) {
|
|
653
|
+
if (!pick) return []
|
|
654
|
+
const rest = (pool || [])
|
|
655
|
+
.filter((d) => d !== pick)
|
|
656
|
+
.sort((a, b) => compareTags(b.tag, a.tag) || stableKey(a).localeCompare(stableKey(b)))
|
|
657
|
+
return [pick, ...rest]
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Parse one properties attachment, or report why it is unusable and return
|
|
662
|
+
* undefined. A JSON syntax error used to escape as an unhandled exception that
|
|
663
|
+
* named no file, so a single bad attachment failed the build with no way to tell
|
|
664
|
+
* which of them was at fault.
|
|
665
|
+
*/
|
|
666
|
+
function readProperties (candidate, contentCatalog) {
|
|
667
|
+
const where = `${candidate.component}@${candidate.version || 'unversioned'}`
|
|
668
|
+
let data
|
|
669
|
+
try {
|
|
670
|
+
data = JSON.parse(candidate.file.contents.toString())
|
|
671
|
+
} catch (error) {
|
|
672
|
+
warnOnce(contentCatalog, `badjson:${where}:${candidate.tag}`,
|
|
673
|
+
`prop macro: redpanda-properties-${candidate.tag}.json in ${where} is not valid JSON (${error.message}), so it is ignored. Regenerate it with 'npx doc-tools generate property-docs'.`)
|
|
674
|
+
return undefined
|
|
675
|
+
}
|
|
676
|
+
const properties = data && data.properties
|
|
677
|
+
// Must be a plain object of name -> entry. A string, number, or array parses
|
|
678
|
+
// fine and would then make every prop: target look like a typo.
|
|
679
|
+
if (!properties || typeof properties !== 'object' || Array.isArray(properties)) {
|
|
680
|
+
warnOnce(contentCatalog, `noprops:${where}:${candidate.tag}`,
|
|
681
|
+
`prop macro: redpanda-properties-${candidate.tag}.json in ${where} has no usable 'properties' object, so it is ignored. Regenerate it with 'npx doc-tools generate property-docs'.`)
|
|
682
|
+
return undefined
|
|
683
|
+
}
|
|
684
|
+
return properties
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* A stable identity for an attachment, so two files carrying the same tag are
|
|
689
|
+
* resolved the same way on every build instead of by catalog iteration order.
|
|
690
|
+
*/
|
|
691
|
+
function stableKey (candidate) {
|
|
692
|
+
return `${candidate.component}\u0000${candidate.version}\u0000${candidate.file.src.relative}`
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Whether a property is available to the audience this dataset was loaded for.
|
|
697
|
+
*
|
|
698
|
+
* On Cloud the answer comes from cloud_supported, but only where that field
|
|
699
|
+
* means anything. It is derived from the Cloud control plane's install pack,
|
|
700
|
+
* whose two sections describe CLUSTER configuration and contain no topic or
|
|
701
|
+
* broker property at all -- so for those scopes a false value asserted
|
|
702
|
+
* something its source cannot know. Cloud users set topic properties routinely
|
|
703
|
+
* through the Kafka API and Console, a surface the control plane does not gate,
|
|
704
|
+
* and Cloud publishes a topic properties reference of its own.
|
|
705
|
+
*
|
|
706
|
+
* So: gate only when the flag is explicitly false AND the property is cluster
|
|
707
|
+
* scoped. Anything else counts as available, because nothing authoritative says
|
|
708
|
+
* otherwise. The extractor no longer writes the field for other scopes, and
|
|
709
|
+
* this also keeps already-published datasets correct, which assert false on
|
|
710
|
+
* every topic and broker property.
|
|
711
|
+
*/
|
|
712
|
+
function isAvailable (registry, entry) {
|
|
713
|
+
if (registry.surface !== SURFACE_CLOUD) return true
|
|
714
|
+
if (entry.config_scope !== 'cluster') return true
|
|
715
|
+
return entry.cloud_supported !== false
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function warnSeriesMissing (contentCatalog, pageComponent, pageVersion) {
|
|
719
|
+
const gaps = contentCatalog[$propertySeriesGap]
|
|
720
|
+
const gap = gaps && gaps[`${pageComponent}@${pageVersion}`]
|
|
721
|
+
if (!gap) return false
|
|
722
|
+
const have = gap.have.join(', ')
|
|
723
|
+
if (gap.kind === 'unusable') {
|
|
724
|
+
warnOnce(contentCatalog, `unusable:${gap.component}@${gap.version}`,
|
|
725
|
+
`prop macro: every property attachment for ${gap.component}@${gap.version} is unusable (${have}); see the per-file warning above for why. ` +
|
|
726
|
+
'prop: targets are therefore not validated and their tooltips carry no data. Regenerate the file with \'npx doc-tools generate property-docs\' rather than treating this as missing data.')
|
|
727
|
+
return true
|
|
728
|
+
}
|
|
729
|
+
if (gap.kind === 'cloud-prerelease-only') {
|
|
730
|
+
warnOnce(contentCatalog, `cloudpre:${gap.component}@${gap.version}`,
|
|
731
|
+
`prop macro: ${gap.component} found only prerelease property data (${have}). Cloud runs released Redpanda, so it uses GA property data and will not validate against a release candidate. ` +
|
|
732
|
+
'prop: targets on Cloud pages are therefore not validated and their tooltips carry no data. Include a GA self-managed branch in the build, or leave prop: out of Cloud content until the release ships.')
|
|
733
|
+
return true
|
|
734
|
+
}
|
|
735
|
+
warnOnce(contentCatalog, `series:${gap.component}@${gap.version}`,
|
|
736
|
+
`prop macro: ${gap.component}@${gap.version} publishes no redpanda-properties-v${gap.series}.*.json attachment in its reference module (found for other series: ${have}). ` +
|
|
737
|
+
`prop: targets on ${gap.version} pages are therefore not validated and their tooltips carry no data, rather than being checked against another release's properties. ` +
|
|
738
|
+
`Generate it with 'npx doc-tools generate property-docs --tag v${gap.series}.<patch>' on that branch, or leave prop: out of ${gap.version} content.`)
|
|
739
|
+
return true
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function warnNoPropertyData (contentCatalog, component, version) {
|
|
743
|
+
warnOnce(contentCatalog, `nodata:${component}@${version}`,
|
|
744
|
+
`prop macro: no redpanda-properties-<tag>.json attachment found in any component's reference module for ${component || 'unknown component'}@${version || 'unversioned'}; ` +
|
|
745
|
+
'prop: targets are not validated and their tooltips carry no data. Publish the property JSON as a reference-module attachment in this component or in streaming.')
|
|
308
746
|
}
|
|
309
747
|
|
|
310
748
|
/**
|
|
@@ -326,11 +764,56 @@ function reportUnknownProperty ({ name, mode, registry, filePath }) {
|
|
|
326
764
|
.map((scored) => scored.candidate)
|
|
327
765
|
const hint = candidates.length ? ` Did you mean: ${candidates.join(', ')}?` : ''
|
|
328
766
|
const where = filePath ? ` in ${filePath}` : ''
|
|
329
|
-
const message = `prop:${name}[]
|
|
767
|
+
const message = `prop:${name}[]${where}: '${name}' is not in the property data this page validates against (${describeDataset(registry)}). ` +
|
|
768
|
+
`Either the name is misspelled or the property does not exist in that release.${hint}`
|
|
330
769
|
if (mode === 'error') throw new Error(message)
|
|
331
770
|
console.warn(chalk.yellow(message))
|
|
332
771
|
}
|
|
333
772
|
|
|
773
|
+
/**
|
|
774
|
+
* Which properties JSON a page was checked against. Named in every warning
|
|
775
|
+
* so a reader can tell a typo apart from a release or component mismatch --
|
|
776
|
+
* the same name can be valid in one series and absent from another.
|
|
777
|
+
*/
|
|
778
|
+
function describeDataset (registry) {
|
|
779
|
+
const from = registry.component
|
|
780
|
+
? `${registry.component}@${registry.version || 'unversioned'}`
|
|
781
|
+
: 'unknown component'
|
|
782
|
+
return `redpanda-properties-${registry.tag}.json from ${from}`
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Report a property that exists in Redpanda but is not available to this
|
|
787
|
+
* component's audience. On Cloud that means cloud_supported is false: a real
|
|
788
|
+
* property a Cloud reader cannot set, so it renders as plain code rather than
|
|
789
|
+
* as a tooltip implying otherwise.
|
|
790
|
+
*/
|
|
791
|
+
function reportUnavailableProperty ({ name, component, registry, mode, filePath }) {
|
|
792
|
+
if (mode === 'off') return
|
|
793
|
+
const where = filePath ? ` in ${filePath}` : ''
|
|
794
|
+
const which = component || 'this'
|
|
795
|
+
console.warn(chalk.yellow(
|
|
796
|
+
`prop:${name}[]${where}: '${name}' is not available in the ${which} component, so it renders as plain code -- no link, and no tooltip. ` +
|
|
797
|
+
`${describeDataset(registry)} marks it cloud_supported: false, meaning Redpanda has the property but this audience cannot set it. ` +
|
|
798
|
+
'If that is wrong, fix cloud_supported in the property extractor; if it is right, reword the sentence and drop the macro.'
|
|
799
|
+
))
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Report an available property that no page in this component documents. The
|
|
804
|
+
* tooltip still works, so this only matters when a link was explicitly asked
|
|
805
|
+
* for -- there is nothing to link to.
|
|
806
|
+
*/
|
|
807
|
+
function reportUnpublishedProperty ({ name, registry, mode, filePath }) {
|
|
808
|
+
if (mode === 'off') return
|
|
809
|
+
const where = filePath ? ` in ${filePath}` : ''
|
|
810
|
+
console.warn(chalk.yellow(
|
|
811
|
+
`prop:${name}[link=true]${where}: '${name}' is in ${describeDataset(registry)} but no reference page in this component renders a heading for it, so it keeps its tooltip and renders without a link. ` +
|
|
812
|
+
'Every property reference page filters out the deprecated and exclude-from-docs include tags, and some include only selected category tags -- check which tags enclose this property in the generated partial. ' +
|
|
813
|
+
'If it is meant to stay unpublished, drop link=true.'
|
|
814
|
+
))
|
|
815
|
+
}
|
|
816
|
+
|
|
334
817
|
/**
|
|
335
818
|
* Build the AsciiDoc content emitted for one macro instance. Exported for
|
|
336
819
|
* unit testing.
|
|
@@ -338,27 +821,38 @@ function reportUnknownProperty ({ name, mode, registry, filePath }) {
|
|
|
338
821
|
* @param {object} opts
|
|
339
822
|
* @param {string} opts.name - Property name.
|
|
340
823
|
* @param {string} [opts.text] - Display text override.
|
|
341
|
-
* @param {boolean} opts.link - Whether to link to the reference page.
|
|
342
|
-
*
|
|
343
|
-
* @param {string} [opts.
|
|
824
|
+
* @param {boolean} [opts.link] - Whether to link to the reference page. Ignored
|
|
825
|
+
* without a page: the macro never guesses a target.
|
|
826
|
+
* @param {string} [opts.page] - Reference page (module-relative, no .adoc).
|
|
827
|
+
* @param {string} [opts.scope] - config_scope, used only for the Helm path.
|
|
344
828
|
* @param {string} opts.role - CSS class for the code element.
|
|
345
|
-
* @param {
|
|
346
|
-
*
|
|
829
|
+
* @param {boolean} [opts.plain] - Render unmarked code (no tooltip, no link)
|
|
830
|
+
* because the build could not verify the property for this component.
|
|
347
831
|
* @returns {string}
|
|
348
832
|
*/
|
|
349
|
-
function buildPropContent ({ name, text, link, page, scope, role,
|
|
350
|
-
const display = text || (helmPath
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
833
|
+
function buildPropContent ({ name, text, link, page, scope, role, helmPath = false, docUrl, plain = false }) {
|
|
834
|
+
const display = text || (helmPath && helmValuesPath(name, scope)) || name
|
|
835
|
+
if (plain) return `<code>${display}</code>`
|
|
836
|
+
const inner = link && page
|
|
837
|
+
? `xref:reference:${page}.adoc#${propertyAnchor(name)}[${display}]`
|
|
838
|
+
: display
|
|
839
|
+
// Escape every interpolated attribute value. role comes from a site
|
|
840
|
+
// attribute, docUrl from a page's published URL, and name from a dataset
|
|
841
|
+
// key: an unescaped quote in any of them ends the attribute early and the
|
|
842
|
+
// remainder becomes stray attributes on every marked property in the build.
|
|
843
|
+
const docAttr = docUrl ? ` data-doc-url="${escapeAttribute(docUrl)}"` : ''
|
|
844
|
+
return `<code class="${escapeAttribute(role)}" data-property-name="${escapeAttribute(name)}"${docAttr}>${inner}</code>`
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Escape a value for use inside a double-quoted HTML attribute.
|
|
849
|
+
*/
|
|
850
|
+
function escapeAttribute (value) {
|
|
851
|
+
return String(value === undefined || value === null ? '' : value)
|
|
852
|
+
.replace(/&/g, '&')
|
|
853
|
+
.replace(/"/g, '"')
|
|
854
|
+
.replace(/</g, '<')
|
|
855
|
+
.replace(/>/g, '>')
|
|
362
856
|
}
|
|
363
857
|
|
|
364
858
|
function propInlineMacro (config) {
|
|
@@ -368,105 +862,109 @@ function propInlineMacro (config) {
|
|
|
368
862
|
self.process(function (parent, target, attributes) {
|
|
369
863
|
const document = parent.getDocument()
|
|
370
864
|
const name = target.trim()
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
865
|
+
const mode = document.getAttribute('property-validate', 'warn')
|
|
866
|
+
const filePath = config && config.file && config.file.src && config.file.src.path
|
|
867
|
+
const component = (config && config.file && config.file.src && config.file.src.component) || ''
|
|
868
|
+
const version = (config && config.file && config.file.src && config.file.src.version) || ''
|
|
869
|
+
const linkRequested = attributes.link === 'true' || attributes.link === true
|
|
870
|
+
|
|
871
|
+
// Anything that leaves the property unverified renders as plain inline
|
|
872
|
+
// code: no marker, so the docs UI adds no tooltip. A tooltip the build
|
|
873
|
+
// could not check is worse than none, and a marker with nothing behind it
|
|
874
|
+
// would leave the UI guessing at a documentation link.
|
|
875
|
+
// No Helm path here, deliberately: helmValuesPath needs the property's
|
|
876
|
+
// config_scope to choose between config.node.* and config.cluster.*, and
|
|
877
|
+
// on this path there is no verified entry to take it from. Emitting one
|
|
878
|
+
// anyway printed a confident, copy-pasteable values.yaml path -- and the
|
|
879
|
+
// wrong one for every broker property -- for a property the build could
|
|
880
|
+
// not verify. Show the name instead.
|
|
881
|
+
//
|
|
882
|
+
// No macro substitution either: the content is a hand-built tag pair, so
|
|
883
|
+
// a text= value containing xref:/image:/pass:[] would otherwise be
|
|
884
|
+
// expanded inside it and could close or nest past the </code>.
|
|
885
|
+
const plain = () => self.createInline(parent, 'quoted', buildPropContent({
|
|
886
|
+
name,
|
|
887
|
+
text: attributes.text,
|
|
888
|
+
role: document.getAttribute('property-ref-role', DEFAULT_ROLE),
|
|
889
|
+
plain: true,
|
|
890
|
+
}))
|
|
891
|
+
|
|
892
|
+
const registry = config && config.contentCatalog ? loadProperties(config) : undefined
|
|
893
|
+
if (!registry) {
|
|
894
|
+
// No dataset covers this component. Say so once and render plain.
|
|
895
|
+
if (mode !== 'off' && config && config.contentCatalog) {
|
|
896
|
+
if (!warnSeriesMissing(config.contentCatalog, component, version)) {
|
|
897
|
+
warnNoPropertyData(config.contentCatalog, component, version)
|
|
898
|
+
}
|
|
386
899
|
}
|
|
900
|
+
return plain()
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Own properties only: a bare index lookup makes toString, constructor
|
|
904
|
+
// and valueOf validate as real properties and render marked.
|
|
905
|
+
const entry = Object.prototype.hasOwnProperty.call(registry.properties, name)
|
|
906
|
+
? registry.properties[name]
|
|
907
|
+
: undefined
|
|
908
|
+
if (!entry) {
|
|
909
|
+
reportUnknownProperty({ name, mode, registry, filePath })
|
|
910
|
+
return plain()
|
|
911
|
+
}
|
|
912
|
+
if (!isAvailable(registry, entry)) {
|
|
913
|
+
reportUnavailableProperty({ name, component, registry, mode, filePath })
|
|
914
|
+
return plain()
|
|
387
915
|
}
|
|
388
|
-
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
|
|
394
|
-
let discoveredPage
|
|
916
|
+
|
|
917
|
+
// Discover the page in THIS component that documents the property. A page
|
|
918
|
+
// is never borrowed from another component, and never guessed from the
|
|
919
|
+
// property's scope, because a link the build cannot verify is a broken
|
|
920
|
+
// link. page= overrides discovery for the rare case a writer knows better.
|
|
921
|
+
let discoveredPage = attributes.page
|
|
395
922
|
let discoveredUrl
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if (ownEntry) {
|
|
405
|
-
discoveredPage = ownEntry.page
|
|
406
|
-
discoveredUrl = ownEntry.url
|
|
407
|
-
} else {
|
|
408
|
-
let fallbackWithPages
|
|
409
|
-
for (const fallbackComponent of ['streaming', 'ROOT']) {
|
|
410
|
-
if (fallbackComponent === component) continue
|
|
411
|
-
// A component-qualified xref without a version resolves to the
|
|
412
|
-
// fallback component's latest version, so consult exactly that
|
|
413
|
-
// version's index (which the property-page-index extension warms).
|
|
414
|
-
const latest = typeof config.contentCatalog.getComponent === 'function' &&
|
|
415
|
-
config.contentCatalog.getComponent(fallbackComponent)
|
|
416
|
-
const fallbackVersion = (latest && latest.latest && latest.latest.version) || undefined
|
|
417
|
-
const fallbackIndex = buildPageIndex(config.contentCatalog, fallbackComponent, registry.properties, fallbackVersion)
|
|
418
|
-
if (fallbackIndex.size === 0) continue
|
|
419
|
-
if (!fallbackWithPages) fallbackWithPages = fallbackComponent
|
|
420
|
-
const fallbackEntry = fallbackIndex.get(name)
|
|
421
|
-
if (fallbackEntry) {
|
|
422
|
-
discoveredPage = fallbackEntry.page
|
|
423
|
-
discoveredUrl = fallbackEntry.url
|
|
424
|
-
componentPrefix = `${fallbackComponent}:`
|
|
425
|
-
break
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
// Nothing documents the property anywhere: point the scope-derived
|
|
429
|
-
// fallback at a component that at least publishes property pages,
|
|
430
|
-
// unless the current component does.
|
|
431
|
-
if (!discoveredPage && ownIndex.size === 0 && fallbackWithPages) {
|
|
432
|
-
componentPrefix = `${fallbackWithPages}:`
|
|
433
|
-
}
|
|
434
|
-
// Property pages exist but none of them documents this property
|
|
435
|
-
// (for example, a cloud-only topic property when no site publishes
|
|
436
|
-
// a cloud topic-properties page). Linking would produce a broken
|
|
437
|
-
// xref, so render the marker without a link and say why. A plain
|
|
438
|
-
// tooltip marker for an undocumented property is fine -- only an
|
|
439
|
-
// explicit link request warrants the warning.
|
|
440
|
-
if (linkRequested && !discoveredPage && (ownIndex.size > 0 || fallbackWithPages)) {
|
|
441
|
-
const where = (config.file && config.file.src && config.file.src.path) || 'unknown file'
|
|
442
|
-
console.warn(chalk.yellow(`prop:${name}[link=true] in ${where}: no property reference page documents '${name}' in this build, so it renders without a link. Check the property's include tags on the reference pages.`))
|
|
443
|
-
suppressLink = true
|
|
444
|
-
}
|
|
923
|
+
if (!discoveredPage) {
|
|
924
|
+
const index = buildPageIndex(config.contentCatalog, component, registry.properties, version)
|
|
925
|
+
const found = index.get(name)
|
|
926
|
+
if (found) {
|
|
927
|
+
discoveredPage = found.page
|
|
928
|
+
discoveredUrl = found.url
|
|
929
|
+
} else if (linkRequested) {
|
|
930
|
+
reportUnpublishedProperty({ name, registry, mode, filePath })
|
|
445
931
|
}
|
|
446
932
|
}
|
|
447
|
-
|
|
448
|
-
// pages rendered with env-kubernetes, so single-sourced content reads
|
|
449
|
-
// correctly for both Linux and Kubernetes audiences (the successor to
|
|
450
|
-
// the config_ref macro's hardcoded storage.tiered.config prefixing).
|
|
451
|
-
const helmPath = attributes['helm-path'] === 'auto' && document.getAttribute('env-kubernetes') !== undefined
|
|
933
|
+
|
|
452
934
|
const content = buildPropContent({
|
|
453
935
|
name,
|
|
454
936
|
text: attributes.text,
|
|
455
|
-
link: linkRequested &&
|
|
456
|
-
page:
|
|
457
|
-
scope: entry
|
|
937
|
+
link: linkRequested && Boolean(discoveredPage),
|
|
938
|
+
page: discoveredPage,
|
|
939
|
+
scope: entry.config_scope,
|
|
458
940
|
role: document.getAttribute('property-ref-role', DEFAULT_ROLE),
|
|
459
|
-
|
|
460
|
-
helmPath,
|
|
941
|
+
helmPath: helmPathRequested(attributes, document),
|
|
461
942
|
docUrl: discoveredUrl ? `${discoveredUrl}#${propertyAnchor(name)}` : undefined,
|
|
462
943
|
})
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
|
|
944
|
+
// Only request macro substitution when an xref actually needs resolving.
|
|
945
|
+
// Applying it unconditionally let a text= value containing another macro
|
|
946
|
+
// expand inside the hand-built <code> element, producing mis-nested or
|
|
947
|
+
// unclosed markup.
|
|
948
|
+
const options = linkRequested && discoveredPage ? { attributes: { subs: 'macros' } } : {}
|
|
949
|
+
return self.createInline(parent, 'quoted', content, options)
|
|
466
950
|
})
|
|
467
951
|
}
|
|
468
952
|
}
|
|
469
953
|
|
|
954
|
+
/**
|
|
955
|
+
* helm-path=auto displays the property as its Helm values path on pages
|
|
956
|
+
* rendered with env-kubernetes, so single-sourced content reads correctly for
|
|
957
|
+
* both Linux and Kubernetes audiences (the successor to the config_ref macro's
|
|
958
|
+
* hardcoded storage.tiered.config prefixing).
|
|
959
|
+
*/
|
|
960
|
+
function helmPathRequested (attributes, document) {
|
|
961
|
+
// isAttributeSet, not a bare !== undefined: Antora yields the string 'false'
|
|
962
|
+
// for `env-kubernetes: false` in antora.yml, and a bare presence check read
|
|
963
|
+
// that as "Kubernetes page", so a Linux page in a component that explicitly
|
|
964
|
+
// turned the attribute off still told readers to set a values.yaml key.
|
|
965
|
+
return attributes['helm-path'] === 'auto' && isAttributeSet(document.getAttribute('env-kubernetes'))
|
|
966
|
+
}
|
|
967
|
+
|
|
470
968
|
function register (registry, config = {}) {
|
|
471
969
|
if (typeof registry.register === 'function') {
|
|
472
970
|
registry.register(function () {
|
|
@@ -486,6 +984,8 @@ module.exports.propertyAnchor = propertyAnchor
|
|
|
486
984
|
module.exports.helmValuesPath = helmValuesPath
|
|
487
985
|
module.exports.loadPropertiesFor = loadPropertiesFor
|
|
488
986
|
module.exports.compareTags = compareTags
|
|
987
|
+
module.exports.isPrerelease = isPrerelease
|
|
988
|
+
module.exports.releaseSeries = releaseSeries
|
|
489
989
|
module.exports.extractHeadingsWithTags = extractHeadingsWithTags
|
|
490
990
|
module.exports.evaluateTagExpression = evaluateTagExpression
|
|
491
991
|
module.exports.buildPageIndex = buildPageIndex
|