@redpanda-data/docs-extensions-and-macros 5.8.0 → 5.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,244 @@
1
+ 'use strict'
2
+
3
+ /* Drift checks between the enterprise features registry (the shared
4
+ * component's enterprise-features.yml in the docs repo) and the internal
5
+ * sources of truth. Used by `doc-tools validate enterprise-features`.
6
+ *
7
+ * Finding levels:
8
+ * error - registry is internally inconsistent or contradicts a source
9
+ * needs-human - a source changed in a way that needs a naming or policy
10
+ * decision (for example, a new core enum value)
11
+ * info - notable but not actionable by itself
12
+ */
13
+
14
+ const yaml = require('js-yaml')
15
+
16
+ const VALID_SCOPES = ['redpanda', 'console', 'connect', 'operator', 'cloud']
17
+ const SOURCE_KINDS = ['core-enum', 'core-property', 'connect-plugin', 'manual']
18
+
19
+ function finding (level, check, message) {
20
+ return { level, check, message }
21
+ }
22
+
23
+ /**
24
+ * Parse and lint the registry itself.
25
+ *
26
+ * @param {string} source - Registry YAML.
27
+ * @returns {{features: object[], findings: object[]}}
28
+ */
29
+ function lintRegistry (source) {
30
+ const findings = []
31
+ const data = yaml.load(source)
32
+ if (!data || !Array.isArray(data.features)) {
33
+ return { features: [], findings: [finding('error', 'registry-lint', "Registry has no 'features' list.")] }
34
+ }
35
+ const seen = new Map()
36
+ for (const feature of data.features) {
37
+ const name = feature && feature.name
38
+ if (!name) {
39
+ findings.push(finding('error', 'registry-lint', 'Entry without a name.'))
40
+ continue
41
+ }
42
+ for (const key of [name, ...(feature.aliases || [])].map((value) => String(value).trim().toLowerCase())) {
43
+ if (seen.has(key) && seen.get(key) !== name) {
44
+ findings.push(finding('error', 'registry-lint', `Duplicate name or alias '${key}' shared by '${seen.get(key)}' and '${name}'.`))
45
+ }
46
+ seen.set(key, name)
47
+ }
48
+ if (!VALID_SCOPES.includes(feature.scope)) {
49
+ findings.push(finding('error', 'registry-lint', `'${name}' has unknown scope '${feature.scope}'.`))
50
+ }
51
+ if (!feature.description || !String(feature.description).trim()) {
52
+ findings.push(finding('error', 'registry-lint', `'${name}' has no description.`))
53
+ }
54
+ if (!feature.source || !feature.source.kind) {
55
+ findings.push(finding('error', 'registry-lint', `'${name}' has no source pointer.`))
56
+ } else if (!SOURCE_KINDS.includes(feature.source.kind)) {
57
+ findings.push(finding('error', 'registry-lint', `'${name}' has unknown source kind '${feature.source.kind}'.`))
58
+ } else if (!feature.source.value || !String(feature.source.value).trim()) {
59
+ findings.push(finding('error', 'registry-lint', `'${name}' has an empty source value; manual entries need a justification.`))
60
+ }
61
+ }
62
+ return { features: data.features, findings }
63
+ }
64
+
65
+ /**
66
+ * Compare registry core-enum pointers against the actual enum values.
67
+ * Every enum value needs at least one registry entry (else needs-human),
68
+ * and every core-enum pointer must be a real enum value (else error).
69
+ */
70
+ function checkCoreEnum (features, enumValues) {
71
+ const findings = []
72
+ const pointers = new Map()
73
+ for (const feature of features) {
74
+ if (feature.source && feature.source.kind === 'core-enum') {
75
+ const value = String(feature.source.value).trim()
76
+ if (!pointers.has(value)) pointers.set(value, [])
77
+ pointers.get(value).push(feature.name)
78
+ }
79
+ }
80
+ for (const value of enumValues) {
81
+ if (!pointers.has(value)) {
82
+ findings.push(finding('needs-human', 'core-enum', `New or unmapped core enterprise feature '${value}' (license_required_feature) has no registry entry. Decide its approved external name and add an entry.`))
83
+ }
84
+ }
85
+ for (const [value, names] of pointers) {
86
+ if (!enumValues.includes(value)) {
87
+ findings.push(finding('error', 'core-enum', `Registry entr${names.length > 1 ? 'ies' : 'y'} ${names.map((n) => `'${n}'`).join(', ')} point${names.length > 1 ? '' : 's'} at core enum value '${value}', which does not exist in enterprise_features.h.`))
88
+ }
89
+ }
90
+ return findings
91
+ }
92
+
93
+ /**
94
+ * Check core-property pointers and gating properties against the
95
+ * config::enterprise<> property names extracted from configuration.h.
96
+ * Gating properties are checked for existence only when a full property
97
+ * list is provided.
98
+ */
99
+ function checkCoreProperties (features, enterpriseProperties, allPropertyNames) {
100
+ const findings = []
101
+ for (const feature of features) {
102
+ if (feature.source && feature.source.kind === 'core-property') {
103
+ const value = String(feature.source.value).trim()
104
+ if (!enterpriseProperties.includes(value)) {
105
+ findings.push(finding('error', 'core-property', `'${feature.name}' points at property '${value}', which is not an enterprise-wrapped property in configuration.h.`))
106
+ }
107
+ }
108
+ if (feature['gating-property'] && allPropertyNames && !allPropertyNames.includes(feature['gating-property'])) {
109
+ findings.push(finding('error', 'gating-property', `'${feature.name}' names gating property '${feature['gating-property']}', which does not exist in the property reference.`))
110
+ }
111
+ }
112
+ const covered = features
113
+ .filter((f) => f.source && (f.source.kind === 'core-property' || f.source.kind === 'core-enum'))
114
+ .flatMap((f) => [String(f.source.value).trim(), f['gating-property']])
115
+ .filter(Boolean)
116
+ for (const property of enterpriseProperties) {
117
+ if (!covered.includes(property)) {
118
+ findings.push(finding('info', 'core-property', `Enterprise-wrapped property '${property}' is not referenced by any registry entry. Confirm whether it belongs to an existing feature or needs a new entry.`))
119
+ }
120
+ }
121
+ return findings
122
+ }
123
+
124
+ /**
125
+ * Compare connect info.csv enterprise plugins against the stale
126
+ * enterprise-components list in rp-connect-docs antora.yml (when given).
127
+ */
128
+ function checkConnect (infoCsvPlugins, antoraList) {
129
+ const findings = []
130
+ if (!antoraList) return findings
131
+ for (const plugin of infoCsvPlugins) {
132
+ if (!antoraList.includes(plugin)) {
133
+ findings.push(finding('error', 'connect-list', `Plugin '${plugin}' is enterprise in info.csv but missing from the enterprise-components list in rp-connect-docs antora.yml.`))
134
+ }
135
+ }
136
+ for (const plugin of antoraList) {
137
+ if (!infoCsvPlugins.includes(plugin)) {
138
+ findings.push(finding('error', 'connect-list', `Plugin '${plugin}' is in the enterprise-components list in rp-connect-docs antora.yml but not enterprise in info.csv.`))
139
+ }
140
+ }
141
+ return findings
142
+ }
143
+
144
+ /**
145
+ * Compare the hand-maintained disable-enterprise-features.adoc table with
146
+ * registry gating properties.
147
+ */
148
+ function checkDisableTable (features, disableRows) {
149
+ const findings = []
150
+ const registryProperties = new Set(features.map((f) => f['gating-property']).filter(Boolean))
151
+ for (const row of disableRows) {
152
+ for (const property of row.properties) {
153
+ if (!registryProperties.has(property)) {
154
+ findings.push(finding('needs-human', 'disable-table', `disable-enterprise-features.adoc row '${row.feature}' references property '${property}', which is not a gating-property of any registry entry.`))
155
+ }
156
+ }
157
+ }
158
+ return findings
159
+ }
160
+
161
+ /**
162
+ * Render the internal-to-external name mapping partial, so pages can
163
+ * translate the names printed by `rpk cluster license info`.
164
+ */
165
+ function buildMappingPartial (features, enumValues) {
166
+ const byValue = new Map()
167
+ for (const feature of features) {
168
+ if (feature.source && feature.source.kind === 'core-enum') {
169
+ const value = String(feature.source.value).trim()
170
+ if (!byValue.has(value)) byValue.set(value, [])
171
+ byValue.get(value).push(feature)
172
+ }
173
+ }
174
+ const lines = [
175
+ '// Generated by `doc-tools validate enterprise-features --write-mapping`. Do not edit.',
176
+ '// Maps the internal feature names reported by `rpk cluster license info`',
177
+ '// (enterprise_features_in_use) to their documented names.',
178
+ '[cols="1m,2a"]',
179
+ '|===',
180
+ '| Reported name | Documented feature',
181
+ '',
182
+ ]
183
+ const order = enumValues.length ? enumValues.filter((value) => byValue.has(value)) : [...byValue.keys()].sort()
184
+ for (const value of order) {
185
+ const cell = byValue.get(value)
186
+ .map((feature) => (feature.xref ? `xref:${feature.xref}[${feature.name}]` : feature.name))
187
+ .join('\n\n')
188
+ lines.push(`| ${value}`, `| ${cell}`, '')
189
+ }
190
+ lines.push('|===')
191
+ return lines.join('\n')
192
+ }
193
+
194
+ /**
195
+ * Run all checks. Sources that were not provided are skipped with an info
196
+ * finding, so partial runs stay useful.
197
+ *
198
+ * @param {object} sources
199
+ * @param {string} sources.registryYaml
200
+ * @param {string} [sources.coreHeader] - enterprise_features.h contents.
201
+ * @param {string} [sources.configurationHeader] - configuration.h contents.
202
+ * @param {string} [sources.infoCsv] - connect info.csv contents.
203
+ * @param {string[]} [sources.antoraEnterpriseComponents]
204
+ * @param {string} [sources.disablePage] - disable-enterprise-features.adoc contents.
205
+ * @param {string[]} [sources.allPropertyNames] - full property list for gating checks.
206
+ * @returns {{findings: object[], features: object[], enumValues: string[]}}
207
+ */
208
+ function runChecks (sources) {
209
+ const parsers = require('./parsers')
210
+ const { features, findings } = lintRegistry(sources.registryYaml)
211
+ let enumValues = []
212
+ if (sources.coreHeader) {
213
+ enumValues = parsers.parseCoreEnum(sources.coreHeader)
214
+ findings.push(...checkCoreEnum(features, enumValues))
215
+ } else {
216
+ findings.push(finding('info', 'core-enum', 'Skipped: no core header provided.'))
217
+ }
218
+ if (sources.configurationHeader) {
219
+ const enterpriseProperties = parsers.parseEnterpriseProperties(sources.configurationHeader)
220
+ findings.push(...checkCoreProperties(features, enterpriseProperties, sources.allPropertyNames))
221
+ } else {
222
+ findings.push(finding('info', 'core-property', 'Skipped: no configuration header provided.'))
223
+ }
224
+ if (sources.infoCsv) {
225
+ const plugins = parsers.parseConnectEnterprisePlugins(sources.infoCsv)
226
+ findings.push(...checkConnect(plugins, sources.antoraEnterpriseComponents))
227
+ } else {
228
+ findings.push(finding('info', 'connect-list', 'Skipped: no connect info.csv provided.'))
229
+ }
230
+ if (sources.disablePage) {
231
+ findings.push(...checkDisableTable(features, parsers.parseDisableTable(sources.disablePage)))
232
+ }
233
+ return { findings, features, enumValues }
234
+ }
235
+
236
+ module.exports = {
237
+ lintRegistry,
238
+ checkCoreEnum,
239
+ checkCoreProperties,
240
+ checkConnect,
241
+ checkDisableTable,
242
+ buildMappingPartial,
243
+ runChecks,
244
+ }
@@ -128,7 +128,7 @@ function parseJSDocComments(sourceFile) {
128
128
 
129
129
  // Regex to match JSDoc comments followed by command definitions
130
130
  // Matches both top-level commands and automation.command()
131
- const pattern = /\/\*\*\s*([\s\S]*?)\s*\*\/\s*(?:programCli|automation)\s*\.command\(['"]([^'"]+)['"]\)/g;
131
+ const pattern = /\/\*\*\s*([\s\S]*?)\s*\*\/\s*(?:programCli|automation|validation)\s*\.command\(['"]([^'"]+)['"]\)/g;
132
132
 
133
133
  let match;
134
134
  while ((match = pattern.exec(content)) !== null) {
@@ -316,6 +316,24 @@ IMPORTANT: This documentation is auto-generated. Do not edit manually. Run \`npm
316
316
  doc += generateCommandDoc(`generate ${subcmd}`, data, jsdoc, 3);
317
317
  });
318
318
 
319
+ // Validate command and its subcommands
320
+ console.log(' Generating docs for: validate');
321
+ const validateHelp = getHelpOutput('validate');
322
+ const validateData = parseHelp(validateHelp);
323
+ doc += generateCommandDoc('validate', validateData, null, 2);
324
+
325
+ const validateSubcommands = [
326
+ 'enterprise-features'
327
+ ];
328
+
329
+ validateSubcommands.forEach(subcmd => {
330
+ console.log(` Generating docs for: validate ${subcmd}`);
331
+ const help = getHelpOutput(`validate ${subcmd}`);
332
+ const data = parseHelp(help);
333
+ const jsdoc = jsdocs[subcmd];
334
+ doc += generateCommandDoc(`validate ${subcmd}`, data, jsdoc, 3);
335
+ });
336
+
319
337
  // Write to file
320
338
  const outputPath = path.join(__dirname, '..', 'CLI_REFERENCE.adoc');
321
339
  fs.writeFileSync(outputPath, doc);
@@ -0,0 +1,206 @@
1
+ 'use strict'
2
+
3
+ /* One-time migration of plain-backtick property mentions to the prop: macro.
4
+ *
5
+ * Rewrites `property_name` to prop:property_name[] in AsciiDoc prose when the
6
+ * name exists in the published property reference JSON AND contains a
7
+ * separator (_ or .). Separator-free names (admin, brokers, rack, retries,
8
+ * superusers) are exactly the ambiguous words that motivated opt-in marking,
9
+ * so they are never converted automatically.
10
+ *
11
+ * Conservative by design. A conversion is skipped when the mention is:
12
+ * - inside a delimited listing, literal, or comment block (----, ...., ////)
13
+ * - inside a fenced code block (```)
14
+ * - on a heading, attribute-entry, or block-title line
15
+ * - inside the [] payload of another inline macro (unbalanced [ before it)
16
+ */
17
+
18
+ const fs = require('fs')
19
+ const path = require('path')
20
+
21
+ const SKIP_LINE_RX = /^(=+\s|:[-\w]+[!]?:|\.[^.\s])/
22
+ const DELIMITERS = [
23
+ { rx: /^-{4,}\s*$/, key: 'listing' },
24
+ { rx: /^\.{4,}\s*$/, key: 'literal' },
25
+ { rx: /^\/{4,}\s*$/, key: 'comment' },
26
+ { rx: /^`{3,}/, key: 'fence' },
27
+ ]
28
+
29
+ /**
30
+ * Collect convertible property names from a published properties JSON.
31
+ *
32
+ * @param {object} propertiesJson - Parsed redpanda-properties JSON.
33
+ * @returns {{convertible: Set<string>, ambiguous: string[]}}
34
+ */
35
+ function classifyNames (propertiesJson) {
36
+ const names = Object.keys(propertiesJson.properties || {})
37
+ const convertible = new Set()
38
+ const ambiguous = []
39
+ for (const name of names) {
40
+ if (name.includes('_') || name.includes('.')) convertible.add(name)
41
+ else ambiguous.push(name)
42
+ }
43
+ return { convertible, ambiguous }
44
+ }
45
+
46
+ /**
47
+ * Convert one line of prose. Exported for unit testing.
48
+ *
49
+ * @param {string} line
50
+ * @param {Set<string>} convertible - Names eligible for conversion.
51
+ * @param {Set<string>} [seen] - Names already marked in the current
52
+ * paragraph. Repeat mentions stay as plain backticks: one tooltip per
53
+ * property per paragraph is enough.
54
+ * @returns {{line: string, count: number}}
55
+ */
56
+ function convertLine (line, convertible, seen) {
57
+ let count = 0
58
+ // `name` spans: backtick, name, backtick, not adjacent to more backticks.
59
+ const result = line.replace(/(^|[^`\\])`([A-Za-z][A-Za-z0-9_.]*)`(?!`)/g, (match, prefix, name, offset) => {
60
+ if (!convertible.has(name)) return match
61
+ if (seen && seen.has(name)) return match
62
+ // Skip mentions inside another macro's [...] payload.
63
+ const before = line.slice(0, offset + prefix.length)
64
+ const opens = (before.match(/\[/g) || []).length
65
+ const closes = (before.match(/\]/g) || []).length
66
+ if (opens > closes) return match
67
+ if (seen) seen.add(name)
68
+ count++
69
+ return `${prefix}prop:${name}[]`
70
+ })
71
+ return { line: result, count }
72
+ }
73
+
74
+ /**
75
+ * Convert config_ref macro calls to prop macro calls. The old macro's manual
76
+ * path argument is dropped (the prop macro discovers the page dynamically),
77
+ * and its implicit Kubernetes prefixing becomes the explicit, per-property
78
+ * correct helm-path=auto. Names must exist in the published JSON; unknown
79
+ * names are left as config_ref and reported. Exported for unit testing.
80
+ *
81
+ * @param {string} line
82
+ * @param {Set<string>} knownNames - Every name in the published JSON.
83
+ * @returns {{line: string, count: number, skipped: string[]}}
84
+ */
85
+ function convertConfigRefLine (line, knownNames) {
86
+ let count = 0
87
+ const skipped = []
88
+ const result = line.replace(/config_ref:([^[,]+)(?:,([^[,]*))?(?:,([^[,]*))?\[([^\]]*)\]/g, (match, name, isLink, _path, payload) => {
89
+ const trimmed = name.trim()
90
+ if (!knownNames.has(trimmed)) {
91
+ skipped.push(trimmed)
92
+ return match
93
+ }
94
+ // A payload of the backticked property name is redundant (the prop macro
95
+ // renders a code element already); anything else becomes a text override.
96
+ const display = (payload || '').trim().replace(/^`|`$/g, '')
97
+ const attrs = [
98
+ ...(isLink === 'true' ? ['link=true'] : []),
99
+ 'helm-path=auto',
100
+ ...(display && display !== trimmed ? [`text=${display}`] : []),
101
+ ]
102
+ count++
103
+ return `prop:${trimmed}[${attrs.join(',')}]`
104
+ })
105
+ return { line: result, count, skipped }
106
+ }
107
+
108
+ /**
109
+ * Convert one AsciiDoc document. Exported for unit testing.
110
+ *
111
+ * @param {string} content
112
+ * @param {Set<string>} convertible
113
+ * @returns {{content: string, count: number}}
114
+ */
115
+ function convertDocument (content, convertible, options = {}) {
116
+ const lines = content.split('\n')
117
+ const open = {}
118
+ let total = 0
119
+ const skippedConfigRefs = []
120
+ // One prop macro per property per paragraph. Blank lines and list items
121
+ // start a new paragraph; existing prop: calls count as the mention.
122
+ let paragraphSeen = new Set()
123
+ const converted = lines.map((line) => {
124
+ const delimiter = DELIMITERS.find((d) => d.rx.test(line))
125
+ if (delimiter) {
126
+ open[delimiter.key] = !open[delimiter.key]
127
+ paragraphSeen = new Set()
128
+ return line
129
+ }
130
+ if (Object.values(open).some(Boolean)) return line
131
+ if (line.trim() === '' || /^\s*(?:[*\-.]+|\d+\.)\s/.test(line)) {
132
+ paragraphSeen = new Set()
133
+ }
134
+ if (SKIP_LINE_RX.test(line)) {
135
+ paragraphSeen = new Set()
136
+ return line
137
+ }
138
+ let current = line
139
+ if (options.configRefs && options.knownNames) {
140
+ const refResult = convertConfigRefLine(current, options.knownNames)
141
+ total += refResult.count
142
+ skippedConfigRefs.push(...refResult.skipped)
143
+ current = refResult.line
144
+ }
145
+ for (const m of current.matchAll(/prop:([A-Za-z][A-Za-z0-9_.]*)\[/g)) paragraphSeen.add(m[1])
146
+ const result = convertLine(current, convertible, paragraphSeen)
147
+ total += result.count
148
+ return result.line
149
+ })
150
+ return { content: converted.join('\n'), count: total, skippedConfigRefs }
151
+ }
152
+
153
+ /**
154
+ * Walk a modules tree and migrate every .adoc file.
155
+ *
156
+ * @param {object} opts
157
+ * @param {string} opts.docsDir - Repo root containing modules/.
158
+ * @param {object} opts.propertiesJson - Parsed properties JSON.
159
+ * @param {boolean} opts.write - Apply changes (dry run when false).
160
+ * @param {string[]} [opts.exclude] - Path substrings to skip.
161
+ * @returns {{files: number, conversions: number, changed: {file: string, count: number}[], ambiguous: string[]}}
162
+ */
163
+ function migrate ({ docsDir, propertiesJson, write, configRefs = false, exclude = [
164
+ 'modules/reference/partials/properties/',
165
+ // Autogenerated; hand edits revert on regeneration.
166
+ 'modules/reference/pages/rpk/',
167
+ 'modules/reference/partials/rpk',
168
+ 'modules/reference/pages/k-crd.adoc',
169
+ '-helm-spec.adoc',
170
+ '/attachments/',
171
+ '/examples/',
172
+ '/test-resources/',
173
+ ] }) {
174
+ const { convertible, ambiguous } = classifyNames(propertiesJson)
175
+ const knownNames = new Set(Object.keys(propertiesJson.properties || {}))
176
+ const changed = []
177
+ const skippedConfigRefs = new Set()
178
+ let conversions = 0
179
+ let files = 0
180
+
181
+ const walk = (dir) => {
182
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
183
+ const full = path.join(dir, entry.name)
184
+ const relative = path.relative(docsDir, full)
185
+ if (exclude.some((fragment) => `${relative}/`.includes(fragment) || relative.includes(fragment))) continue
186
+ if (entry.isDirectory()) {
187
+ walk(full)
188
+ } else if (entry.name.endsWith('.adoc')) {
189
+ files++
190
+ const original = fs.readFileSync(full, 'utf8')
191
+ const result = convertDocument(original, convertible, { configRefs, knownNames })
192
+ for (const name of result.skippedConfigRefs || []) skippedConfigRefs.add(name)
193
+ if (result.count > 0) {
194
+ changed.push({ file: relative, count: result.count })
195
+ conversions += result.count
196
+ if (write) fs.writeFileSync(full, result.content)
197
+ }
198
+ }
199
+ }
200
+ }
201
+
202
+ walk(path.join(docsDir, 'modules'))
203
+ return { files, conversions, changed, ambiguous, skippedConfigRefs: [...skippedConfigRefs] }
204
+ }
205
+
206
+ module.exports = { classifyNames, convertLine, convertConfigRefLine, convertDocument, migrate }
@@ -301,6 +301,26 @@ function generateErrorReports(properties, documentedProperties = []) {
301
301
  };
302
302
  }
303
303
 
304
+ /**
305
+ * Rewrite legacy config_ref macro calls in a description to prop macro
306
+ * calls. The prop macro validates names against the published property
307
+ * JSON, discovers the target page dynamically (config_ref's manual path is
308
+ * dropped), and powers hover tooltips. A payload that just repeats the
309
+ * backticked property name is redundant; anything else becomes text=.
310
+ */
311
+ function convertConfigRefsToProp(text) {
312
+ if (!text || typeof text !== 'string') return text;
313
+ return text.replace(/config_ref:([^[,]+)(?:,([^[,]*))?(?:,([^[,]*))?\[([^\]]*)\]/g, (match, name, isLink, _path, payload) => {
314
+ const trimmed = name.trim();
315
+ const display = (payload || '').trim().replace(/^`|`$/g, '');
316
+ const attrs = [
317
+ ...(isLink === 'true' ? ['link=true'] : []),
318
+ ...(display && display !== trimmed ? [`text=${display}`] : []),
319
+ ];
320
+ return `prop:${trimmed}[${attrs.join(',')}]`;
321
+ });
322
+ }
323
+
304
324
  /**
305
325
  * Main generator
306
326
  */
@@ -309,6 +329,12 @@ function generateAllDocs(inputFile, outputDir) {
309
329
  // Support both 'properties' (from property_extractor.py) and 'topic_properties' (from topic_property_extractor.py)
310
330
  const properties = data.properties || data.topic_properties || {};
311
331
 
332
+ // Generated output must emit the prop macro, never the deprecated
333
+ // config_ref, regardless of what the input JSON or overrides carry.
334
+ Object.values(properties).forEach((prop) => {
335
+ if (prop && prop.description) prop.description = convertConfigRefsToProp(prop.description);
336
+ });
337
+
312
338
  registerPartials();
313
339
 
314
340
  let partialsCount = 0;
@@ -361,7 +387,8 @@ function generateAllDocs(inputFile, outputDir) {
361
387
  module.exports = {
362
388
  generateAllDocs,
363
389
  generateDeprecatedDocs,
364
- generatePropertyPartials
390
+ generatePropertyPartials,
391
+ convertConfigRefsToProp
365
392
  };
366
393
 
367
394
  // CLI
@@ -1013,6 +1013,36 @@ def apply_property_overrides(properties, overrides, overrides_file_path=None):
1013
1013
  Returns:
1014
1014
  dict: The same properties mapping with overrides applied and any new properties created.
1015
1015
  """
1016
+ def _normalize_config_ref_macros(text):
1017
+ """Rewrite legacy config_ref macro calls to prop macro calls.
1018
+
1019
+ The prop macro validates names against the published property JSON,
1020
+ discovers the target page dynamically (config_ref's manual path is
1021
+ dropped), and powers hover tooltips. A payload that just repeats the
1022
+ backticked property name is redundant; anything else becomes text=.
1023
+ Applied to every description so neither upstream source text nor
1024
+ overrides can reintroduce the deprecated macro into published JSON.
1025
+ """
1026
+ if not isinstance(text, str) or "config_ref:" not in text:
1027
+ return text
1028
+
1029
+ def _replace(match):
1030
+ name = match.group(1).strip()
1031
+ is_link = (match.group(2) or "").strip() == "true"
1032
+ display = (match.group(4) or "").strip().strip("`")
1033
+ attrs = []
1034
+ if is_link:
1035
+ attrs.append("link=true")
1036
+ if display and display != name:
1037
+ attrs.append(f"text={display}")
1038
+ return f"prop:{name}[{','.join(attrs)}]"
1039
+
1040
+ return re.sub(
1041
+ r"config_ref:([^\[,]+)(?:,([^\[,]*))?(?:,([^\[,]*))?\[([^\]]*)\]",
1042
+ _replace,
1043
+ text,
1044
+ )
1045
+
1016
1046
  phantom_stub_entries.clear()
1017
1047
  if overrides and "properties" in overrides:
1018
1048
  for prop, override in overrides["properties"].items():
@@ -1042,6 +1072,10 @@ def apply_property_overrides(properties, overrides, overrides_file_path=None):
1042
1072
  "name": prop,
1043
1073
  "config_scope": new_property.get("config_scope"),
1044
1074
  })
1075
+
1076
+ for property_data in properties.values():
1077
+ if hasattr(property_data, "get") and property_data.get("description"):
1078
+ property_data["description"] = _normalize_config_ref_macros(property_data["description"])
1045
1079
  return properties
1046
1080
 
1047
1081
 
@@ -23,12 +23,13 @@ const VALIDATION_RULES = [
23
23
  let match
24
24
  while ((match = urlPattern.exec(content)) !== null) {
25
25
  const url = match[1]
26
- // Check if it's a docs.redpanda.com URL (should always be xref)
26
+ // docs.redpanda.com URLs are converted to xrefs at site build time
27
+ // by the url-to-xref Antora extension, so a raw one is informational
27
28
  if (url.includes('docs.redpanda.com')) {
28
29
  issues.push({
29
30
  line: getLineNumber(content, match.index),
30
- message: `Raw docs.redpanda.com URL should be an xref: ${url}`,
31
- severity: 'error'
31
+ message: `Raw docs.redpanda.com URL (converted to an xref by the url-to-xref extension at build time): ${url}`,
32
+ severity: 'info'
32
33
  })
33
34
  }
34
35
  }
@@ -1,58 +0,0 @@
1
- const { URL } = require('url');
2
-
3
- /**
4
- * Converts a docs.redpanda.com URL, optionally suffixed with a label in brackets, into an Antora xref resource ID string.
5
- *
6
- * If the input includes a label in square brackets (for example, `[Label]`), the label is preserved and appended to the resulting xref.
7
- *
8
- * @param {string} input - A docs.redpanda.com URL, optionally followed by a label in square brackets.
9
- * @returns {string} The corresponding Antora xref resource ID, with the label preserved if present.
10
- *
11
- * @throws {Error} If the input is not a valid URL or does not belong to docs.redpanda.com.
12
- */
13
- function urlToXref(input) {
14
- // Peel off an optional “[label]”
15
- let urlPart = input;
16
- let label = '';
17
- const mLabel = input.match(/^(.*)\[([^\]]+)\]$/);
18
- if (mLabel) {
19
- urlPart = mLabel[1];
20
- label = mLabel[2];
21
- }
22
-
23
- //Parse & validate
24
- let url;
25
- try {
26
- url = new URL(urlPart);
27
- } catch {
28
- throw new Error(`Invalid URL: ${input}`);
29
- }
30
- if (!/docs\.redpanda\.com$/.test(url.hostname)) {
31
- throw new Error(`Not a docs.redpanda.com URL: ${input}`);
32
- }
33
-
34
- // Strip any leading “/docs”, “/docs/vX.Y”, “/docs/current”, “/vX.Y” or “/current”
35
- let p = url.pathname.replace(
36
- /^\/(?:docs(?:\/(?:v?\d+\.\d+|current))?|v?\d+\.\d+|current)\/?/,
37
- ''
38
- );
39
- // Drop trailing slash
40
- p = p.replace(/\/$/, '');
41
- const segments = p.split('/').filter(Boolean);
42
-
43
- // Build module + path + .adoc
44
- let xref;
45
- if (segments.length === 0) {
46
- xref = 'xref:index.adoc';
47
- } else {
48
- const moduleName = segments.shift();
49
- const pagePath = segments.join('/');
50
- const fileName = (pagePath || moduleName) + '.adoc';
51
- xref = `xref:${moduleName}:${fileName}`;
52
- }
53
-
54
- // Re-attach label if there was one
55
- return label ? `${xref}[${label}]` : xref;
56
- }
57
-
58
- module.exports = { urlToXref };