@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/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:retention_ms[link=true,page=properties/topic-properties]
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 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,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
- const HEADING_RX = /^=+\s+(\S+)\s*$/
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
- const items = expression.split(/[;,]/).map((item) => item.trim()).filter(Boolean)
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.contents.toString()))
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
- if (pages.some((candidate) => candidate.contents.toString('utf8', 0, 200).trimStart().startsWith('<'))) {
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
- for (const page of pages) {
178
- const source = page.contents.toString()
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
- index.set(heading[1], { page: pagePath, url: pageUrl })
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
- const tagsMatch = include[2].match(/tags?=([^,\]]+)/)
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
- const partial = matching.find((candidate) => candidate.src.version === page.src.version) || matching[0]
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
- if (!index.has(entry.name)) index.set(entry.name, { page: pagePath, url: pageUrl })
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
- let warnedNoRegistry = false
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 vX.Y.Z(-pre) tags without a semver dependency. Good enough to
236
- * pick the newest properties attachment.
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 parse = (tag) => tag.replace(/^v/, '').split('-')[0].split('.').map(Number)
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
- * 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.
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}` || '$any'
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
- // 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 })
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
- if (pick) {
294
- const data = JSON.parse(pick.file.contents.toString())
295
- if (data && data.properties) {
296
- registry = { tag: pick.tag, properties: data.properties }
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 JSON is only searched for once per build.
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
- 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.'))
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}[] does not match any property in the published property reference (${registry.tag})${where}.${hint}`
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
- * @param {string} [opts.page] - Reference page override (module-relative, no .adoc).
343
- * @param {string} [opts.scope] - config_scope from the published JSON.
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 {string} [opts.componentPrefix] - 'component:' prefix when the link
346
- * must leave the current component (which publishes no property pages).
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, componentPrefix = '', helmPath = false, docUrl }) {
350
- 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>`
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, '&amp;')
853
+ .replace(/"/g, '&quot;')
854
+ .replace(/</g, '&lt;')
855
+ .replace(/>/g, '&gt;')
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
- 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
- })
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
- // 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
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
- 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
- }
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
- // 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
933
+
452
934
  const content = buildPropContent({
453
935
  name,
454
936
  text: attributes.text,
455
- link: linkRequested && !suppressLink,
456
- page: attributes.page || discoveredPage,
457
- scope: entry && entry.config_scope,
937
+ link: linkRequested && Boolean(discoveredPage),
938
+ page: discoveredPage,
939
+ scope: entry.config_scope,
458
940
  role: document.getAttribute('property-ref-role', DEFAULT_ROLE),
459
- componentPrefix,
460
- helmPath,
941
+ helmPath: helmPathRequested(attributes, document),
461
942
  docUrl: discoveredUrl ? `${discoveredUrl}#${propertyAnchor(name)}` : undefined,
462
943
  })
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' } })
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