@redpanda-data/docs-extensions-and-macros 5.12.0 → 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.
@@ -2,7 +2,12 @@
2
2
 
3
3
  const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
4
 
5
- const semver = require('semver')
5
+ // The same comparator the prop macro uses to pick a dataset. semver.coerce
6
+ // keeps 'rc10' as one alphanumeric identifier and string-compares it, so it
7
+ // ranked v26.3.1-rc2 above v26.3.1-rc10 while the macro ranked them correctly:
8
+ // build-time validation and the runtime tooltip fetch would then read different
9
+ // files. Sharing the comparator is the only way they cannot disagree.
10
+ const { compareTags } = require('../macros/prop')
6
11
 
7
12
  /**
8
13
  * Points UI templates at generated JSON attachments that actually exist.
@@ -19,7 +24,9 @@ const semver = require('semver')
19
24
  * component version:
20
25
  *
21
26
  * - available-properties-tag: newest redpanda-properties-<tag>.json attachment
22
- * in the reference module of each streaming (ROOT) component version
27
+ * in the reference module of a component version that publishes property
28
+ * data (the ROOT and streaming components, plus any component shipping such
29
+ * an attachment of its own)
23
30
  * - available-connect-version: newest connect-<version>.json attachment in the
24
31
  * components module of the connect component
25
32
  * - page-disable-property-tooltips: 'true' on streaming versions that have no
@@ -54,8 +61,19 @@ module.exports.register = function () {
54
61
  if (!isProperties && !isConnect) return
55
62
 
56
63
  component.versions.forEach((compVer) => {
57
- const attributes = ((compVer.asciidoc = compVer.asciidoc || {}).attributes =
58
- compVer.asciidoc.attributes || {})
64
+ // Copy before writing. Antora hands every component version whose
65
+ // antora.yml declares no `asciidoc:` block THE SAME siteAsciiDocConfig
66
+ // object -- see the NOTE at content-classifier/lib/content-catalog.js
67
+ // ("if no AsciiDoc attributes are defined in the component descriptor,
68
+ // asciidoc is the siteAsciiDocConfig object"). Mutating it in place
69
+ // wrote these attributes into the shared site config, so a component
70
+ // publishing no property data still advertised a dataset, and because
71
+ // the write is guarded on the attribute being unset, the first version
72
+ // processed won and every later version silently kept that value --
73
+ // no log line, and the wrong tag. Cloning both levels makes the write
74
+ // per-version, which is what the attribute means.
75
+ const asciidoc = (compVer.asciidoc = { ...compVer.asciidoc })
76
+ const attributes = (asciidoc.attributes = { ...asciidoc.attributes })
59
77
  const attachments = contentCatalog.findBy({
60
78
  component: component.name,
61
79
  version: compVer.version,
@@ -98,17 +116,13 @@ module.exports.register = function () {
98
116
 
99
117
  function findNewestVersion (attachments, module, rx) {
100
118
  let newestRaw = null
101
- let newestSemver = null
102
119
  attachments.forEach((attachment) => {
103
120
  if (attachment.src.module !== module) return
104
121
  const basename = attachment.src.relative.split('/').pop()
105
122
  const match = basename.match(rx)
106
123
  if (!match) return
107
124
  const raw = match[1]
108
- const parsed = semver.coerce(raw, { includePrerelease: true })
109
- if (!parsed) return
110
- if (!newestSemver || semver.gt(parsed, newestSemver)) {
111
- newestSemver = parsed
125
+ if (!newestRaw || compareTags(raw, newestRaw) > 0) {
112
126
  newestRaw = raw
113
127
  }
114
128
  })
@@ -31,7 +31,7 @@ Every release series has its own copy of that data, and the build picks the copy
31
31
 
32
32
  *It can link itself.* Add `link=true` and the property links to its reference page. You don't say which page -- the build works that out by looking at which reference page actually documents that property (details below). If our property reference is ever reorganized into different pages, every link follows automatically.
33
33
 
34
- *It adapts to Kubernetes pages.* Add `helm-path=auto` and, on pages rendered for Kubernetes, the property displays as the Helm values path readers actually type -- `config.cluster.log_segment_size`, `config.node.fips_mode`, or `storage.tiered.config.cloud_storage_enabled` for tiered storage settings. On Linux pages the same call shows the plain name. One sentence, correct for both audiences.
34
+ *It adapts to Kubernetes pages.* Add `helm-path=auto` and, on pages rendered for Kubernetes, the property displays as the Helm values path readers actually type -- `config.cluster.log_segment_size`, `config.node.fips_mode`, or `storage.tiered.config.cloud_storage_enabled` for tiered storage settings. On Linux pages the same call shows the plain name, as does a topic property anywhere: topic properties are set per topic rather than in `values.yaml`, so there is no path to show. One sentence, correct for both audiences.
35
35
 
36
36
  *Once per paragraph is enough.* Mark the first mention of a property in a paragraph and leave later mentions as plain backticks -- a paragraph doesn't need three identical tooltips. The migration tool follows this rule, and the UI enforces it as a backstop: even if every mention is marked, only the first one per paragraph gets the tooltip.
37
37
 
@@ -41,9 +41,11 @@ Rather than hardcoding "cluster properties live on the cluster-properties page,"
41
41
 
42
42
  Because the lookup is done separately for each part of the site, a Cloud page links to Cloud's own property pages and a streaming page to streaming's, and a link is only ever emitted when a page in that same doc set actually documents the property. Nothing is linked across doc sets, and no page is guessed from a property's scope -- a link the build can't verify is a broken link.
43
43
 
44
- Availability is a separate question from linking, and it comes from the data rather than from which pages happen to include what. The published JSON marks each property with `cloud_supported`. Cloud is managed Redpanda, so a Cloud page may reference only the properties that flag covers, and it always reads the newest self-managed file: there is no such thing as a Cloud release of the property list. A property Cloud doesn't support renders as plain code in code font -- no link and no tooltip -- plus a build warning naming the property and the file. A tooltip there would tell a Cloud reader they can change something they can't. (`cloud_supported` is the whole gate: no published property is editable or read-only in Cloud without also being supported.)
44
+ Availability is a separate question from linking, and it comes from the data rather than from which pages happen to include what. Cloud is managed Redpanda, so it has no property release of its own: a Cloud page reads the newest *released* self-managed file, ignoring release candidates, because Cloud runs released Redpanda.
45
45
 
46
- Usefully, the current data has no Cloud-supported *topic* property at all -- all 62 are cluster properties. So a topic property mentioned on a Cloud page isn't a missing page; it genuinely doesn't apply.
46
+ What that file can rule out is narrower than it looks. `cloud_supported` is derived from the Cloud control plane's install pack, and that source describes *cluster* configuration and nothing else. So the gate applies to cluster properties only: a cluster property the control plane forbids renders as plain code -- no link and no tooltip -- plus a build warning naming the property and the file, because a tooltip there would tell a Cloud reader they can change something they can't. For any other scope the file has no opinion, and a mention is allowed. Topic properties in particular are set per topic through the Kafka API, rpk, or Console -- a surface the control plane doesn't gate -- and Cloud publishes a topic properties reference of its own.
47
+
48
+ An absent `cloud_supported` therefore means "not asserted", not "unsupported". Only an explicit `false` on a cluster property blocks a mention. (Where the flag is written, it is self-consistent: no published property is editable or read-only in Cloud without also being supported.)
47
49
 
48
50
  Doc sets with no property data -- Connect, the agentic data plane -- get nothing, and nothing is inferred for them. Their version numbers are their own product's, and say nothing about which Redpanda properties exist. Adding a third doc set is a deliberate change to the macro, not something it guesses.
49
51
 
@@ -20,7 +20,7 @@ antora:
20
20
 
21
21
  == config_ref
22
22
 
23
- IMPORTANT: `config_ref` is deprecated. The <<prop>> macro supersedes all of its functionality: it validates names against the published property reference, discovers the target page dynamically instead of taking a manual path, powers hover tooltips, and its `helm-path=auto` attribute replaces `config_ref`'s Kubernetes prefixing with a per-property correct Helm values path (`config_ref` prefixed every property with `storage.tiered.config.`, mislabeling non-tiered properties). Migrate call sites with `doc-tools generate migrate-property-refs --config-refs`. The macro stays registered only because the generated property partials still emit it.
23
+ IMPORTANT: `config_ref` is deprecated. The `prop` macro supersedes all of its functionality: it validates names against the published property reference, discovers the target page dynamically instead of taking a manual path, powers hover tooltips, and its `helm-path=auto` attribute replaces `config_ref`'s Kubernetes prefixing with a per-property correct Helm values path (`config_ref` prefixed every property with `storage.tiered.config.`, mislabeling non-tiered properties). Migrate call sites with `doc-tools generate migrate-property-refs --config-refs`. The macro stays registered only because older versioned docs, which are frozen, still contain it; generated content emits `prop` (the extractor rewrites any `config_ref` it finds).
24
24
 
25
25
  This inline macro is used to generate a reference to a configuration value in the Redpanda documentation. The macro's parameters allow for control over the generated reference's format and the type of output produced.
26
26
 
@@ -46,7 +46,7 @@ This is the path to the document where the configuration value is defined. This
46
46
 
47
47
  IMPORTANT: The path must be the name of a document at the root of the `reference` module.
48
48
 
49
- NOTE: The `config_ref` macro is environment-aware. It checks if the document it is being used in is part of a Kubernetes environment by checking if the `env-kubernetes` attribute is set in the document's attributes. Depending on this check, it either prepends `storage.tieredConfig.` to the `configRef` or just uses the `configRef` as is.
49
+ NOTE: The `config_ref` macro is environment-aware. It checks if the document it is being used in is part of a Kubernetes environment by checking if the `env-kubernetes` attribute is set in the document's attributes. Depending on this check, it either prepends `storage.tiered.config.` to the `configRef` or just uses the `configRef` as is.
50
50
 
51
51
  For example:
52
52
 
@@ -184,7 +184,7 @@ asciidoc:
184
184
 
185
185
  == enterprise_features
186
186
 
187
- This block macro renders the licensing feature table for one scope from the enterprise features registry (see <<enterprise>>). Because the table is generated, it can never drift from the registry.
187
+ This block macro renders the licensing feature table for one scope from the enterprise features registry (see the `enterprise` macro above). Because the table is generated, it can never drift from the registry.
188
188
 
189
189
  === Usage
190
190
 
@@ -205,7 +205,7 @@ Optional third-column heading override. The default is `Behavior Upon Expiration
205
205
 
206
206
  === Registration
207
207
 
208
- Registered together with the `enterprise` inline macro (see <<enterprise>>).
208
+ Registered together with the `enterprise` inline macro.
209
209
 
210
210
  == prop
211
211
 
@@ -219,20 +219,20 @@ The macro checks that a property is actually available to the audience reading t
219
219
  ----
220
220
  prop:cloud_storage_enabled[]
221
221
  prop:iceberg_enabled[link=true]
222
- prop:retention_ms[link=true,page=properties/topic-properties]
222
+ prop:retention.ms[link=true,page=properties/topic-properties]
223
223
  prop:write_caching_default[text=write caching]
224
224
  ----
225
225
 
226
226
  The macro target is the property name. Named attributes:
227
227
 
228
228
  link::
229
- Set to `true` to also link the property to its reference page. The page is discovered dynamically: the macro indexes which reference-module page in the current component documents each property, by scanning property headings in the partials each page includes (respecting include tag filters such as `tags=redpanda-cloud`). Links are component-relative, so a Cloud page links to the Cloud component's own property pages, and they keep working if properties are split across different pages in the future. See <<prop-undocumented>> for properties the current component does not document. This supersedes the `config_ref` macro's linking for most uses.
229
+ Set to `true` to also link the property to its reference page. The page is discovered dynamically: the macro indexes which reference-module page in the current component documents each property, by scanning property headings in the partials each page includes (respecting include tag filters such as `tags=redpanda-cloud`). Links are component-relative, so a Cloud page links to the Cloud component's own property pages, and they keep working if properties are split across different pages in the future. See <<prop-availability>> for properties the current component does not document. This supersedes the `config_ref` macro's linking for most uses.
230
230
 
231
231
  page::
232
232
  Optional reference-page override (module-relative, without `.adoc`), for example `properties/object-storage-properties`. Overrides discovery entirely.
233
233
 
234
234
  helm-path::
235
- Set to `auto` to display the property as its Helm values path on pages rendered with the `env-kubernetes` attribute, so single-sourced content reads correctly for both Linux and Kubernetes audiences. The path is derived per property: `storage.tiered.config.<name>` for tiered storage properties, `config.node.<name>` for broker properties, and `config.cluster.<name>` for other cluster properties. On pages without `env-kubernetes`, the plain property name renders. This supersedes the deprecated `config_ref` macro's prefixing.
235
+ Set to `auto` to display the property as its Helm values path on pages rendered with the `env-kubernetes` attribute, so single-sourced content reads correctly for both Linux and Kubernetes audiences. The path is derived per property: `storage.tiered.config.<name>` for properties whose name begins `cloud_storage_`, `config.node.<name>` for broker properties, and `config.cluster.<name>` for cluster properties. Topic properties render as the plain name, because they are set per topic through the Kafka API, rpk, or a Topic resource and have no `values.yaml` key at all. The plain name also renders on any page where `env-kubernetes` is unset or explicitly `false`. This supersedes the deprecated `config_ref` macro's prefixing.
236
236
 
237
237
  text::
238
238
  Optional display text override. Wins over the Helm path display.
@@ -240,7 +240,7 @@ Optional display text override. Wins over the Helm path display.
240
240
  === Configuration options
241
241
 
242
242
  property-validate::
243
- How to report a target that does not match any published property: `warn` (default) logs a build warning with the file path, the dataset the page was checked against, and close-match suggestions; `error` fails the build; and `off` disables validation and silences every warning described here. When no property JSON is available in the build, the macro renders without validation and logs one warning per component version.
243
+ How to report a target that does not match any published property: `warn` (default) logs a build warning with the file path, the dataset the page was checked against, and close-match suggestions; `error` fails the build; and `off` disables validation and silences the unknown-name, unavailable-property, unpublished-property and missing-data warnings. Diagnostics about the build itself are not silenced by `off`, because they report a broken input rather than a questionable mention: unusable property JSON, a property claimed by two declared sources, and an index built after conversion started. When no property JSON is available in the build, the macro renders without validation and logs one warning per component version.
244
244
 
245
245
  [[prop-version-selection]]
246
246
  === Which property data a page validates against
package/macros/badge.js CHANGED
@@ -15,30 +15,52 @@
15
15
  * @param {string} [opts.tooltip] - Tooltip text, rendered as `data-tooltip`.
16
16
  * @returns {string} Badge HTML.
17
17
  */
18
+ // Hover text for the labels this macro is asked for by name. The badge is
19
+ // styled with a help cursor, so a badge with nothing to say promises an
20
+ // explanation and then withholds it. Callers with something more specific pass
21
+ // their own tooltip, which wins.
22
+ const DEFAULT_TOOLTIPS = {
23
+ beta: 'This feature is in public beta. You can enable it, but it may change before it is generally available.',
24
+ unreleased: 'This feature is not in a released version of Redpanda yet. It is documented here for the upcoming release.',
25
+ };
26
+
18
27
  function buildBadgeHtml ({ label, size, tooltip } = {}) {
19
28
  const text = label || 'label';
20
- const className = `badge--${text.toLowerCase().replace(/\s+/g, '-')}`;
21
29
  const isLarge = size === 'large';
22
30
  const sizeClass = isLarge ? 'badge--large' : '';
23
- // Escape before interpolating: tooltips come from a registry field, and an
24
- // unescaped quote terminates the attribute early, turning the rest of the
25
- // text into stray attributes (including event handlers).
31
+ // Escape before interpolating: these values reach the macro from .adoc source
32
+ // and from registry fields, and an unescaped quote terminates the attribute
33
+ // early, turning the rest of the text into stray attributes (including event
34
+ // handlers). The label needs it as much as the tooltip -- it lands in the
35
+ // class attribute and in the text content, and escaping only the tooltip left
36
+ // `badge:x[label=x" onmouseover="...]` free to break out of the class.
26
37
  const escapeAttr = (value) => String(value)
27
38
  .replace(/&/g, '&amp;')
28
39
  .replace(/"/g, '&quot;')
29
40
  .replace(/</g, '&lt;')
30
41
  .replace(/>/g, '&gt;');
31
- const tooltipAttr = tooltip ? ` data-tooltip="${escapeAttr(tooltip)}"` : '';
42
+ const className = `badge--${escapeAttr(text.toLowerCase().replace(/\s+/g, '-'))}`;
43
+ // hasOwnProperty, not a bare index: `label=constructor` and `label=__proto__`
44
+ // are already lowercase, so a bare lookup shipped
45
+ // `function Object() { [native code] }` to readers as hover text. prop.js
46
+ // guards its property lookups the same way.
47
+ const labelKey = text.trim().toLowerCase();
48
+ const defaultTooltip = Object.prototype.hasOwnProperty.call(DEFAULT_TOOLTIPS, labelKey)
49
+ ? DEFAULT_TOOLTIPS[labelKey]
50
+ : undefined;
51
+ const hoverText = tooltip || defaultTooltip;
52
+ const tooltipAttr = hoverText ? ` data-tooltip="${escapeAttr(hoverText)}"` : '';
32
53
 
33
54
  // Add brackets if not large. The brackets matter beyond styling: they are
34
55
  // real text content, so the label survives an HTML-to-Markdown conversion as
35
56
  // "(beta)" rather than vanishing with the CSS class.
36
- const renderedLabel = isLarge ? text : `(${text})`;
57
+ const renderedLabel = isLarge ? escapeAttr(text) : `(${escapeAttr(text)})`;
37
58
 
38
59
  return `<span class="badge ${className} ${sizeClass}"${tooltipAttr}>${renderedLabel}</span>`;
39
60
  }
40
61
 
41
62
  module.exports.buildBadgeHtml = buildBadgeHtml;
63
+ module.exports.DEFAULT_TOOLTIPS = DEFAULT_TOOLTIPS;
42
64
 
43
65
  module.exports.register = function (registry) {
44
66
  registry.inlineMacro(function () {
@@ -55,9 +55,10 @@
55
55
 
56
56
  const yaml = require('js-yaml')
57
57
  const chalk = require('chalk')
58
- const { buildBadgeHtml } = require('./badge')
58
+ const { buildBadgeHtml, DEFAULT_TOOLTIPS } = require('./badge')
59
59
 
60
60
  const $enterpriseRegistry = Symbol('$enterpriseRegistry')
61
+ const $enterpriseRegistryUnreadable = Symbol('$enterpriseRegistryUnreadable')
61
62
  const $warned = Symbol('$warned')
62
63
 
63
64
  /**
@@ -76,8 +77,10 @@ const DEFAULT_LICENSING_PAGE = 'get-started:licensing/overview.adoc'
76
77
  const DEFAULT_ROLE = 'enterprise-feature'
77
78
  const BETA_LABEL = 'beta'
78
79
  const UNRELEASED_LABEL = 'unreleased'
79
- const UNRELEASED_TOOLTIP =
80
- 'This feature is not in a released version of Redpanda yet. It is documented here for the upcoming release.'
80
+ // The badge macro supplies the default hover text for these labels, so the same
81
+ // wording appears whether a badge comes from the registry or from a
82
+ // badge::[label=beta] call in a page.
83
+ const UNRELEASED_TOOLTIP = DEFAULT_TOOLTIPS.unreleased
81
84
 
82
85
  // Release status of a feature, which decides where it may be referenced.
83
86
  //
@@ -97,9 +100,12 @@ const VALID_STATUSES = [STATUS_GA, STATUS_BETA, STATUS_UNRELEASED]
97
100
  * The release status of a registry entry.
98
101
  *
99
102
  * `beta: true` predates the status field and still means beta, so old entries
100
- * keep working. An explicit status wins. An unrecognized status is reported
101
- * rather than quietly treated as shipped, which is the failure mode that lets a
102
- * typo publish an unreleased feature.
103
+ * keep working. An explicit status wins. An unrecognized status is reported AND
104
+ * treated as unreleased: a typo must not be the thing that publishes an
105
+ * unreleased feature. The two failure directions are not symmetric -- gating a
106
+ * shipped feature shows a writer a warning and an unstyled mention they will
107
+ * notice, while publishing an unreleased one promises readers a feature they
108
+ * cannot get, and nothing on the page looks wrong.
103
109
  *
104
110
  * @param {object} entry - Registry entry.
105
111
  * @param {object} [report] - {mode, filePath} to report an invalid status.
@@ -107,14 +113,22 @@ const VALID_STATUSES = [STATUS_GA, STATUS_BETA, STATUS_UNRELEASED]
107
113
  */
108
114
  function entryStatus (entry, report) {
109
115
  if (!entry) return STATUS_GA
116
+ // Any present key goes through validation, including a blank value. An absent
117
+ // key means no status was intended, so GA is the right default; `status:` with
118
+ // nothing after it means the writer meant to say something and did not, which
119
+ // is the same mistake as a typo and must fail the same way. Treating blank as
120
+ // absent reopened the hole this function exists to close: a forgotten value
121
+ // published an unreleased feature with no warning at all.
110
122
  if (entry.status !== undefined) {
111
- const status = String(entry.status).trim().toLowerCase()
123
+ const raw = entry.status === null ? '' : String(entry.status).trim()
124
+ const status = raw.toLowerCase()
112
125
  if (VALID_STATUSES.includes(status)) return status
113
126
  if (report && report.mode !== 'off') {
114
127
  const where = report.filePath ? ` in ${report.filePath}` : ''
128
+ const what = raw === '' ? 'an empty status' : `status '${entry.status}'`
115
129
  const message =
116
- `enterprise:${entry.name}[]${where}: registry status '${entry.status}' is not one of ${VALID_STATUSES.join(', ')}, so the feature is treated as released. ` +
117
- 'Fix the status in enterprise-features.yml.'
130
+ `enterprise:${entry.name}[]${where}: registry has ${what}, which is not one of ${VALID_STATUSES.join(', ')}, so the feature is treated as unreleased and is left unpublished on released pages. ` +
131
+ 'Fix the status in enterprise-features.yml, or remove the key entirely if the feature has shipped.'
118
132
  // Honour error mode like every other registry diagnostic. A typo here is
119
133
  // exactly what publishes an unreleased feature, so the strictest setting
120
134
  // has to stop it.
@@ -124,7 +138,7 @@ function entryStatus (entry, report) {
124
138
  if (report.contentCatalog) warnOnce(report.contentCatalog, `status:${entry.name}`, message)
125
139
  else console.warn(chalk.yellow(message))
126
140
  }
127
- return STATUS_GA
141
+ return STATUS_UNRELEASED
128
142
  }
129
143
  return entry.beta === true ? STATUS_BETA : STATUS_GA
130
144
  }
@@ -239,7 +253,14 @@ let warnedNoRegistry = false
239
253
  * @returns {{features: object[], lookup: Map<string, object>}}
240
254
  */
241
255
  function parseRegistry (source, origin = REGISTRY_FILENAME) {
242
- const data = yaml.load(source)
256
+ let data
257
+ try {
258
+ data = yaml.load(source)
259
+ } catch (error) {
260
+ // Every other throw below names the file, so this one does too: the caller
261
+ // then reports whatever it catches verbatim, and the path appears once.
262
+ throw new Error(`Enterprise features registry ${origin} is not valid YAML (${error.message}).`)
263
+ }
243
264
  if (!data || !Array.isArray(data.features)) {
244
265
  throw new Error(`Enterprise features registry ${origin} has no 'features' list.`)
245
266
  }
@@ -287,8 +308,17 @@ function loadRegistry (config) {
287
308
  // Caching the failure also stops the error being re-raised per macro call
288
309
  // and attributed to whichever page happened to convert first.
289
310
  registry = null
311
+ // Remember that the file was found and unreadable, so the caller does not
312
+ // then also report it missing. Saying "not found" about a file we just
313
+ // read and failed to parse sends the writer looking for the wrong problem.
314
+ contentCatalog[$enterpriseRegistryUnreadable] = true
315
+ // Report the error as thrown. It already names the file and says exactly
316
+ // what is wrong -- invalid YAML, a missing features list, an entry with no
317
+ // name, an unknown scope, or a duplicate name or alias. Wrapping it in
318
+ // "could not be read" was inaccurate for all but the first (the file read
319
+ // fine; its contents are invalid) and printed the path twice.
290
320
  warnOnce(contentCatalog, 'badregistry',
291
- `Enterprise features registry ${registryFile.path} could not be read (${error.message}); enterprise: targets are not validated.`)
321
+ `${error.message} No enterprise: target is validated, no feature is gated by release status, and the licensing tables are empty until this is fixed.`)
292
322
  }
293
323
  }
294
324
  // Cache null too, so a missing registry is only searched for once per build.
@@ -297,6 +327,8 @@ function loadRegistry (config) {
297
327
  }
298
328
 
299
329
  function warnNoRegistry (contentCatalog) {
330
+ // Already reported as unreadable: one problem, one diagnostic.
331
+ if (contentCatalog && contentCatalog[$enterpriseRegistryUnreadable]) return
300
332
  const message = "Enterprise features registry (enterprise-features.yml in the 'shared' component) not found; enterprise: targets are not validated."
301
333
  // Deduplicate per build, not per process: Antora's watch mode reuses one
302
334
  // process across builds, and a module-level guard reported this only on a
@@ -377,7 +409,16 @@ function resolveTooltipAttribute (raw) {
377
409
  function buildEnterpriseContent ({ feature, text, xref, url, tooltip, licensingPage, role, tooltipAttr, links }) {
378
410
  const display = text || feature
379
411
  const tooltipText = tooltip || `${feature} requires an Enterprise Edition license.`
380
- const escapedTooltip = tooltipText.replace(/"/g, '&quot;')
412
+ // All four, matching badge.js. Escaping only the quote kept the attribute
413
+ // intact but let < and > through from a registry tooltip field, which any
414
+ // consumer that re-parses the page then reads as markup -- the docs UI
415
+ // promotes this attribute into a tooltip, the Markdown converter and the
416
+ // search indexer both re-read it.
417
+ const escapedTooltip = tooltipText
418
+ .replace(/&/g, '&amp;')
419
+ .replace(/"/g, '&quot;')
420
+ .replace(/</g, '&lt;')
421
+ .replace(/>/g, '&gt;')
381
422
  const tooltipHtml = tooltipAttr ? ` ${tooltipAttr}="${escapedTooltip}"` : ''
382
423
  let inner = display
383
424
  if (links) {
@@ -408,7 +449,15 @@ function buildFeatureTable (features, scope, opts = {}) {
408
449
  const report = opts.report
409
450
  const rows = features
410
451
  .filter((feature) => feature.scope === scope)
411
- .filter((feature) => includeUnreleased || entryStatus(feature, report) !== STATUS_UNRELEASED)
452
+ // Evaluate the status first: `includeUnreleased || entryStatus(...)` skipped
453
+ // the call entirely on a prerelease page, so a typo'd status went unreported
454
+ // on the beta branch -- the one branch where unreleased features are
455
+ // actually authored and the table renders them -- and enterprise-validate=error
456
+ // did not fail there either.
457
+ .filter((feature) => {
458
+ const status = entryStatus(feature, report)
459
+ return includeUnreleased || status !== STATUS_UNRELEASED
460
+ })
412
461
  .sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }))
413
462
  const title = opts.title || TABLE_TITLES[scope]
414
463
  const heading = opts.heading || THIRD_COLUMN_HEADINGS[scope] || THIRD_COLUMN_HEADINGS.default
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
@@ -98,7 +98,10 @@ const SURFACE_CLOUD = 'cloud'
98
98
  const PROPERTIES_JSON_RX = /^redpanda-properties-(v\d+\.\d+\.\d+(?:-[\w.]+)?)\.json$/
99
99
 
100
100
  const $propertyPageIndex = Symbol('$propertyPageIndex')
101
- 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*$/
102
105
  const TAG_MARKER_RX = /^\/\/\s*(tag|end)::([\w-]+)\[\]\s*$/
103
106
  const INCLUDE_RX = /^include::([^[]+)\[([^\]]*)\]/
104
107
  // A reference page declaring the property collection it publishes.
@@ -314,7 +317,14 @@ function buildPageIndex (contentCatalog, component, properties, version) {
314
317
  if (!include) continue
315
318
  const partialRef = parsePartialTarget(include[1].trim(), page.src)
316
319
  if (!partialRef) continue
317
- 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?=("[^"]*"|'[^']*'|[^,\]]*)/)
318
328
  const expression = tagsMatch && tagsMatch[1]
319
329
  const partials = contentCatalog.findBy({ component: partialRef.component, module: partialRef.module, family: 'partial' }) || []
320
330
  const matching = partials.filter((candidate) => candidate.src.relative === partialRef.relative)
@@ -364,6 +374,13 @@ function propertyAnchor (name) {
364
374
  // config.node, and every other cluster property maps to config.cluster --
365
375
  // the path the docs recommend for all cluster properties.
366
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
367
384
  if (name.startsWith('cloud_storage_')) return `storage.tiered.config.${name}`
368
385
  if (scope === 'broker') return `config.node.${name}`
369
386
  return `config.cluster.${name}`
@@ -523,7 +540,7 @@ function loadProperties (config) {
523
540
  * property-page-index extension can warm the caches before conversion.
524
541
  */
525
542
  function loadPropertiesFor (contentCatalog, pageComponent, pageVersion) {
526
- const cacheKey = `${pageComponent}@${pageVersion}` || '$any'
543
+ const cacheKey = `${pageComponent}@${pageVersion}`
527
544
  const cache = contentCatalog[$propertyRegistry] || (contentCatalog[$propertyRegistry] = {})
528
545
  if (cache[cacheKey] !== undefined) return cache[cacheKey] || undefined
529
546
 
@@ -531,6 +548,12 @@ function loadPropertiesFor (contentCatalog, pageComponent, pageVersion) {
531
548
  const own = datasets.filter((d) => d.component === pageComponent)
532
549
  let pick
533
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
534
557
 
535
558
  // Cloud is decided first, and deliberately outranks the component's own
536
559
  // attachments. The property extractor writes
@@ -546,6 +569,7 @@ function loadPropertiesFor (contentCatalog, pageComponent, pageVersion) {
546
569
  // version is not a Redpanda release candidate.
547
570
  const lenders = datasets.filter((d) => d.component === 'streaming' || d.component === 'ROOT')
548
571
  const released = lenders.filter((d) => !isPrerelease(d.tag))
572
+ pool = released
549
573
  pick =
550
574
  newestDataset(released.filter((d) => d.component === 'streaming')) ||
551
575
  newestDataset(released.filter((d) => d.component === 'ROOT'))
@@ -567,6 +591,7 @@ function loadPropertiesFor (contentCatalog, pageComponent, pageVersion) {
567
591
  const series = releaseSeries(pageVersion)
568
592
  if (series) {
569
593
  const inSeries = own.filter((d) => releaseSeries(d.tag) === series)
594
+ pool = inSeries
570
595
  pick = newestDataset(inSeries.filter((d) => d.version === pageVersion)) || newestDataset(inSeries)
571
596
  if (!pick) {
572
597
  // Record the gap rather than reporting it here: this also runs from the
@@ -585,6 +610,7 @@ function loadPropertiesFor (contentCatalog, pageComponent, pageVersion) {
585
610
  return undefined
586
611
  }
587
612
  } else {
613
+ pool = own
588
614
  pick = newestDataset(own.filter((d) => d.version === pageVersion)) || newestDataset(own)
589
615
  }
590
616
  }
@@ -593,12 +619,26 @@ function loadPropertiesFor (contentCatalog, pageComponent, pageVersion) {
593
619
  // and must not mask a good file: try candidates newest-first and use the
594
620
  // first one that actually carries properties, reporting each one skipped.
595
621
  let registry = null
596
- for (const candidate of orderedCandidates(pick, own, datasets, surface)) {
622
+ const candidates = orderedCandidates(pick, pool)
623
+ for (const candidate of candidates) {
597
624
  const properties = readProperties(candidate, contentCatalog)
598
625
  if (!properties) continue
599
626
  registry = { tag: candidate.tag, properties, component: candidate.component, version: candidate.version, surface }
600
627
  break
601
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),
640
+ }
641
+ }
602
642
  // Cache null too, so a missing dataset is only searched for once per build.
603
643
  cache[cacheKey] = registry
604
644
  return registry || undefined
@@ -609,12 +649,9 @@ function loadPropertiesFor (contentCatalog, pageComponent, pageVersion) {
609
649
  * descending release order, so an unusable file falls through to the next best
610
650
  * rather than voiding the component's data entirely.
611
651
  */
612
- function orderedCandidates (pick, own, datasets, surface) {
652
+ function orderedCandidates (pick, pool) {
613
653
  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
654
+ const rest = (pool || [])
618
655
  .filter((d) => d !== pick)
619
656
  .sort((a, b) => compareTags(b.tag, a.tag) || stableKey(a).localeCompare(stableKey(b)))
620
657
  return [pick, ...rest]
@@ -683,6 +720,12 @@ function warnSeriesMissing (contentCatalog, pageComponent, pageVersion) {
683
720
  const gap = gaps && gaps[`${pageComponent}@${pageVersion}`]
684
721
  if (!gap) return false
685
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
+ }
686
729
  if (gap.kind === 'cloud-prerelease-only') {
687
730
  warnOnce(contentCatalog, `cloudpre:${gap.component}@${gap.version}`,
688
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. ` +
@@ -788,7 +831,7 @@ function reportUnpublishedProperty ({ name, registry, mode, filePath }) {
788
831
  * @returns {string}
789
832
  */
790
833
  function buildPropContent ({ name, text, link, page, scope, role, helmPath = false, docUrl, plain = false }) {
791
- const display = text || (helmPath ? helmValuesPath(name, scope) : name)
834
+ const display = text || (helmPath && helmValuesPath(name, scope)) || name
792
835
  if (plain) return `<code>${display}</code>`
793
836
  const inner = link && page
794
837
  ? `xref:reference:${page}.adoc#${propertyAnchor(name)}[${display}]`
@@ -915,7 +958,11 @@ function propInlineMacro (config) {
915
958
  * hardcoded storage.tiered.config prefixing).
916
959
  */
917
960
  function helmPathRequested (attributes, document) {
918
- return attributes['helm-path'] === 'auto' && document.getAttribute('env-kubernetes') !== undefined
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'))
919
966
  }
920
967
 
921
968
  function register (registry, config = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.12.0",
3
+ "version": "5.12.1",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -104,6 +104,103 @@ function registerPartials() {
104
104
  }
105
105
  }
106
106
 
107
+ /**
108
+ * Rewrite property anchors across every description and related-topics entry so
109
+ * they match the heading IDs Asciidoctor will actually emit.
110
+ *
111
+ * Runs once over the whole property map before rendering, because a property's
112
+ * description routinely links to a sibling property and the anchor shapes differ
113
+ * by where the text came from. Reports what it corrected: a steady stream of
114
+ * rewrites is a signal to fix the upstream doc string, not to lean on this pass.
115
+ *
116
+ * @param {Object<string, Object>} properties - Property map, mutated in place.
117
+ * @returns {number} Number of anchors rewritten.
118
+ */
119
+ function normalizeAnchorsInProperties(properties) {
120
+ const seen = new Map();
121
+ let total = 0;
122
+
123
+ let xrefTotal = 0;
124
+ const fix = (value, scope) => {
125
+ const { text, rewrites } = helpers.normalizePropertyAnchors(value, properties, scope);
126
+ for (const r of rewrites) {
127
+ total += 1;
128
+ seen.set(r.from, r.to);
129
+ }
130
+ const canonical = helpers.canonicalizePropertyXrefs(text);
131
+ xrefTotal += canonical.rewrites;
132
+ return canonical.text;
133
+ };
134
+
135
+ Object.values(properties).forEach((prop) => {
136
+ if (!prop || typeof prop !== 'object') return;
137
+ const scope = prop.config_scope;
138
+ if (typeof prop.description === 'string') prop.description = fix(prop.description, scope);
139
+ if (typeof prop.acceptable_values === 'string') prop.acceptable_values = fix(prop.acceptable_values, scope);
140
+ if (Array.isArray(prop.related_topics)) {
141
+ // Canonicalizing collapses forms that differed only by prefix, which turns
142
+ // some entries into exact duplicates of each other. Drop the repeats.
143
+ const seenTopics = new Set();
144
+ prop.related_topics = prop.related_topics
145
+ .map((t) => (typeof t === 'string' ? fix(t, scope) : t))
146
+ .filter((t) => {
147
+ if (typeof t !== 'string') return true;
148
+ const key = t.trim();
149
+ if (seenTopics.has(key)) return false;
150
+ seenTopics.add(key);
151
+ return true;
152
+ });
153
+ }
154
+ });
155
+
156
+ if (total > 0) {
157
+ console.log(`Fixed ${total} property anchor(s) that would have rendered as dead links:`);
158
+ for (const [from, to] of seen) console.log(` <<${from}>> -> <<${to}>>`);
159
+ }
160
+ if (xrefTotal > 0) {
161
+ console.log(`Canonicalized ${xrefTotal} property-page xref(s) to reference:properties/…`);
162
+ }
163
+ return total;
164
+ }
165
+
166
+ /**
167
+ * Record whether a topic property's corresponding cluster property is one a Cloud
168
+ * customer can actually reach.
169
+ *
170
+ * Cloud publishes a subset of cluster properties, so linking a topic property to
171
+ * its cluster counterpart unconditionally sends Cloud readers to an anchor that
172
+ * does not exist on their reference page. `cloud_supported` on the cluster
173
+ * property is the authority here: it is derived from the install pack's
174
+ * customer_managed_configs and readonly_cluster_config, so true means the Cloud
175
+ * page publishes it.
176
+ *
177
+ * Note what this flag does NOT say. False means the control plane does not expose
178
+ * the property to customers; it does not distinguish "Redpanda manages the value"
179
+ * from "the property does not apply to Cloud at all". So the template drops the
180
+ * link and keeps the name, and asserts nothing about who sets it.
181
+ *
182
+ * Both the primary and the alternate mapping are resolved, because a row that
183
+ * links to two cluster properties needs both to be reachable before it is worth
184
+ * linking either.
185
+ *
186
+ * @param {Object} prop - Topic property to annotate in place.
187
+ * @param {Object<string, Object>} properties - Full property map, used to look up counterparts.
188
+ */
189
+ function annotateCorrespondingClusterSupport(prop, properties) {
190
+ const isSupported = (name) => {
191
+ if (!name) return false;
192
+ const target = properties[name];
193
+ return Boolean(target && target.cloud_supported);
194
+ };
195
+
196
+ const primary = isSupported(prop.corresponding_cluster_property);
197
+ prop.corresponding_cluster_property_cloud_supported = primary;
198
+ prop.alternate_cluster_property_cloud_supported = isSupported(prop.alternate_cluster_property);
199
+ prop.cluster_property_mapping_cloud_supported = prop.alternate_cluster_property
200
+ ? primary && prop.alternate_cluster_property_cloud_supported
201
+ : primary;
202
+ }
203
+
107
204
  /**
108
205
  * Generate AsciiDoc partial files grouping input properties by scope (cluster, topic, broker, object-storage).
109
206
  *
@@ -120,6 +217,8 @@ function registerPartials() {
120
217
  function generatePropertyPartials(properties, partialsDir, onRender) {
121
218
  console.log(`📝 Generating consolidated property partials in ${partialsDir}…`);
122
219
 
220
+ normalizeAnchorsInProperties(properties);
221
+
123
222
  const propertyTemplate = handlebars.compile(
124
223
  fs.readFileSync(getTemplatePath(path.join(__dirname, 'templates', 'property.hbs'), 'TEMPLATE_PROPERTY'), 'utf8')
125
224
  );
@@ -151,6 +250,7 @@ function generatePropertyPartials(properties, partialsDir, onRender) {
151
250
 
152
251
  switch (prop.config_scope) {
153
252
  case 'topic':
253
+ annotateCorrespondingClusterSupport(prop, properties);
154
254
  propertyGroups.topic.push(prop);
155
255
  break;
156
256
  case 'broker':
@@ -1,5 +1,18 @@
1
+ // Builds the anchor for a property's generated section heading.
2
+ //
3
+ // The headings are plain AsciiDoc (`=== cleanup.policy`), so Asciidoctor derives
4
+ // their IDs itself: lowercase, every run of characters outside [a-z0-9_] replaced
5
+ // with the id separator, which Antora sets to `-`. Underscores are word
6
+ // characters and survive, so `redpanda.cloud_topic.enabled` becomes
7
+ // `redpanda-cloud_topic-enabled`.
8
+ //
9
+ // Reproduce that here rather than inventing a separate scheme: anything else
10
+ // renders as a link to an anchor that does not exist on the page.
1
11
  module.exports = function anchorName(name) {
2
- const anchor = String(name).replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
12
+ const anchor = String(name)
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9_]+/g, '-')
15
+ .replace(/^-+|-+$/g, '');
3
16
  if (!anchor) {
4
17
  throw new Error(`Invalid property name for anchor generation: "${name}"`);
5
18
  }
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ // The reference pages these xrefs target.
4
+ const PROPERTY_PAGES = [
5
+ 'cluster-properties',
6
+ 'object-storage-properties',
7
+ 'topic-properties',
8
+ 'broker-properties',
9
+ ];
10
+
11
+ // Every prefix seen in the wild for the same target:
12
+ // xref:cluster-properties.adoc#x bare filename
13
+ // xref:./cluster-properties.adoc#x relative to the current directory
14
+ // xref:reference:cluster-properties.adoc#x legacy, when the pages sat at the module root
15
+ // xref:reference:properties/...#x canonical
16
+ const PREFIXES = '(?:\\./|reference:properties/|reference:)?';
17
+ const PATTERN = new RegExp(
18
+ `xref:${PREFIXES}(${PROPERTY_PAGES.join('|')})\\.adoc(?=[#\\[])`,
19
+ 'g'
20
+ );
21
+
22
+ /**
23
+ * Rewrite every reference to a property reference page to one canonical resource ID.
24
+ *
25
+ * The bare and `reference:`-prefixed forms both resolve to the module root, where
26
+ * these pages no longer live. Self-managed papers over that with a
27
+ * `reference:cluster-properties.adoc` page alias, so the broken form renders as a
28
+ * working link there and as an unresolved xref in cloud-docs, which has no such
29
+ * alias. The `./` form works but depends on the including page sitting in
30
+ * `reference/properties/`, which is a constraint on every page that includes a
31
+ * partial rather than a property of the partial.
32
+ *
33
+ * The fully qualified module-and-path form has neither problem: it resolves
34
+ * inside whichever component includes the partial, from any directory.
35
+ *
36
+ * @param {string} text - AsciiDoc source that may contain property-page xrefs.
37
+ * @returns {{text: string, rewrites: number}}
38
+ */
39
+ function canonicalizePropertyXrefs(text) {
40
+ if (typeof text !== 'string' || !text.includes('xref:')) {
41
+ return { text, rewrites: 0 };
42
+ }
43
+ let rewrites = 0;
44
+ const out = text.replace(PATTERN, (match, page) => {
45
+ const canonical = `xref:reference:properties/${page}.adoc`;
46
+ if (match === canonical) return match;
47
+ rewrites += 1;
48
+ return canonical;
49
+ });
50
+ return { text: out, rewrites };
51
+ }
52
+
53
+ module.exports = canonicalizePropertyXrefs;
54
+ module.exports.PROPERTY_PAGES = PROPERTY_PAGES;
@@ -14,6 +14,8 @@ module.exports = {
14
14
  renderPropertyExample: require('./renderPropertyExample.js'),
15
15
  formatUnits: require('./formatUnits.js'),
16
16
  anchorName: require('./anchorName.js'),
17
+ normalizePropertyAnchors: require('./normalizePropertyAnchors.js'),
18
+ canonicalizePropertyXrefs: require('./canonicalizePropertyXrefs.js'),
17
19
  parseRelatedTopic: require('./parseRelatedTopic.js'),
18
20
  allTopicsConditional: require('./allTopicsConditional.js'),
19
21
  capitalize: require('./capitalize.js'),
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const anchorName = require('./anchorName.js');
4
+
5
+ // Strip every separator so `redpanda.storage.mode.impl`, `redpanda-storage-mode-impl`
6
+ // and `redpandastoragemodeimpl` all collapse to one key.
7
+ function squash(value) {
8
+ return String(value).toLowerCase().replace(/[^a-z0-9]/g, '');
9
+ }
10
+
11
+ /**
12
+ * Repair intra-page anchors (`<<anchor,text>>`) that name a property but use the
13
+ * wrong ID shape.
14
+ *
15
+ * Descriptions come from three places -- Redpanda's own doc strings, the
16
+ * overrides file, and hand-edits -- and each has at some point written a
17
+ * property anchor by deleting the dots (`<<redpandastoragemodeimpl>>`) or by
18
+ * hyphenating only part of the name (`<<redpandastorage-mode>>`). Asciidoctor
19
+ * derives the real heading ID from the heading text, so those render as links to
20
+ * nothing. Rather than hand-correct each one and wait for the next author to
21
+ * reintroduce it, resolve any anchor that unambiguously names a known property
22
+ * back to that property's real anchor.
23
+ *
24
+ * Only rewrites when the anchor is not already correct and the match is
25
+ * unambiguous, so an anchor pointing at a genuine non-property section is left
26
+ * alone. Names from different scopes can collide once separators are gone --
27
+ * `retention.bytes` and `retention_bytes` both squash to `retentionbytes` -- so
28
+ * a collision is resolved in favour of the scope the referring property is in,
29
+ * which is where a cross-reference nearly always points, and refused outright if
30
+ * that still leaves two candidates.
31
+ *
32
+ * @param {string} text - AsciiDoc source that may contain `<<anchor,label>>` references.
33
+ * @param {Iterable<string>|Object<string, Object>} properties - Known property names, or the property map.
34
+ * @param {string} [scope] - config_scope of the property that owns this text.
35
+ * @returns {{text: string, rewrites: Array<{from: string, to: string}>}}
36
+ */
37
+ function normalizePropertyAnchors(text, properties, scope) {
38
+ if (typeof text !== 'string' || !text.includes('<<')) {
39
+ return { text, rewrites: [] };
40
+ }
41
+
42
+ const isMap = properties && !Array.isArray(properties) && typeof properties === 'object'
43
+ && !(typeof properties[Symbol.iterator] === 'function');
44
+ const names = isMap ? Object.keys(properties) : Array.from(properties || []);
45
+ const scopeOf = (name) => (isMap && properties[name] ? properties[name].config_scope : undefined);
46
+
47
+ const bySquashed = new Map();
48
+ const validAnchors = new Set();
49
+ for (const name of names) {
50
+ if (typeof name !== 'string' || !name) continue;
51
+ let anchor;
52
+ try {
53
+ anchor = anchorName(name);
54
+ } catch {
55
+ continue;
56
+ }
57
+ validAnchors.add(anchor);
58
+ const key = squash(name);
59
+ const entry = bySquashed.get(key);
60
+ if (entry) entry.push({ anchor, scope: scopeOf(name) });
61
+ else bySquashed.set(key, [{ anchor, scope: scopeOf(name) }]);
62
+ }
63
+
64
+ const resolve = (key) => {
65
+ const candidates = bySquashed.get(key);
66
+ if (!candidates || candidates.length === 0) return null;
67
+ if (candidates.length === 1) return candidates[0].anchor;
68
+ const sameScope = scope ? candidates.filter((c) => c.scope === scope) : [];
69
+ return sameScope.length === 1 ? sameScope[0].anchor : null;
70
+ };
71
+
72
+ const rewrites = [];
73
+ const rewritten = text.replace(/<<([^<>,\]]+?)(,|>>)/g, (match, anchor, tail) => {
74
+ const trimmed = anchor.trim();
75
+ if (validAnchors.has(trimmed)) return match;
76
+ const target = resolve(squash(trimmed));
77
+ if (!target || target === trimmed) return match;
78
+ rewrites.push({ from: trimmed, to: target });
79
+ return `<<${target}${tail}`;
80
+ });
81
+
82
+ return { text: rewritten, rewrites };
83
+ }
84
+
85
+ module.exports = normalizePropertyAnchors;
86
+ module.exports.squash = squash;
@@ -4,12 +4,21 @@
4
4
 
5
5
  {{#each topicProperties}}
6
6
  | <<{{anchorName name}},`{{name}}`>>
7
- | {{#if alternate_cluster_property}}xref:./{{cluster_property_doc_file}}#{{corresponding_cluster_property}}[`{{corresponding_cluster_property}}`] or xref:./{{alternate_cluster_property_doc_file}}#{{alternate_cluster_property}}[`{{alternate_cluster_property}}`]{{else}}xref:./{{cluster_property_doc_file}}#{{corresponding_cluster_property}}[`{{corresponding_cluster_property}}`]{{/if}}
7
+ |
8
+ ifdef::env-cloud[]
9
+ {{#if cluster_property_mapping_cloud_supported}}
10
+ {{#if alternate_cluster_property}}xref:reference:properties/{{cluster_property_doc_file}}#{{corresponding_cluster_property}}[`{{corresponding_cluster_property}}`] or xref:reference:properties/{{alternate_cluster_property_doc_file}}#{{alternate_cluster_property}}[`{{alternate_cluster_property}}`]{{else}}xref:reference:properties/{{cluster_property_doc_file}}#{{corresponding_cluster_property}}[`{{corresponding_cluster_property}}`]{{/if}}
11
+ {{else}}
12
+ {{#if alternate_cluster_property}}`{{corresponding_cluster_property}}` or `{{alternate_cluster_property}}`{{else}}`{{corresponding_cluster_property}}`{{/if}}
13
+ {{/if}}
14
+ endif::[]
15
+ ifndef::env-cloud[]
16
+ {{#if alternate_cluster_property}}xref:reference:properties/{{cluster_property_doc_file}}#{{corresponding_cluster_property}}[`{{corresponding_cluster_property}}`] or xref:reference:properties/{{alternate_cluster_property_doc_file}}#{{alternate_cluster_property}}[`{{alternate_cluster_property}}`]{{else}}xref:reference:properties/{{cluster_property_doc_file}}#{{corresponding_cluster_property}}[`{{corresponding_cluster_property}}`]{{/if}}
17
+ endif::[]
8
18
 
9
19
  {{/each}}
10
20
 
11
21
  |===
12
22
 
13
- {{!-- Helper to generate anchor names --}}
14
- {{!-- Usage: anchorName propertyName --}}
15
- {{!-- Example: cleanup.policy -> cleanuppolicy --}}
23
+ {{!-- Anchors come from the anchorName helper, which reproduces Asciidoctor's --}}
24
+ {{!-- own auto-generated heading ID: cleanup.policy -> cleanup-policy. --}}
@@ -77,7 +77,17 @@ endif::[]
77
77
  {{#if corresponding_cluster_property}}
78
78
 
79
79
  | Corresponding cluster property
80
- | xref:reference:cluster-properties.adoc#{{corresponding_cluster_property}}[{{corresponding_cluster_property}}]
80
+ |
81
+ ifdef::env-cloud[]
82
+ {{#if corresponding_cluster_property_cloud_supported}}
83
+ xref:reference:properties/{{#if cluster_property_doc_file}}{{cluster_property_doc_file}}{{else}}cluster-properties.adoc{{/if}}#{{corresponding_cluster_property}}[`{{corresponding_cluster_property}}`]
84
+ {{else}}
85
+ `{{corresponding_cluster_property}}`
86
+ {{/if}}
87
+ endif::[]
88
+ ifndef::env-cloud[]
89
+ xref:reference:properties/{{#if cluster_property_doc_file}}{{cluster_property_doc_file}}{{else}}cluster-properties.adoc{{/if}}#{{corresponding_cluster_property}}[`{{corresponding_cluster_property}}`]
90
+ endif::[]
81
91
  {{/if}}
82
92
  {{#if (and minimum maximum)}}
83
93