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