@redpanda-data/docs-extensions-and-macros 5.8.0 → 5.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/doc-tools.js +168 -0
- package/extensions/convert-to-markdown.js +115 -0
- package/extensions/property-page-index.js +44 -0
- package/extensions/validate-attributes.js +1 -1
- package/macros/PROPERTY_AND_ENTERPRISE_REFERENCES.adoc +90 -0
- package/macros/README.adoc +2 -0
- package/macros/REFERENCE.adoc +167 -0
- package/macros/badge.js +35 -13
- package/macros/enterprise.js +390 -0
- package/macros/prop.js +491 -0
- package/package.json +6 -2
- package/tools/enterprise-features/parsers.js +134 -0
- package/tools/enterprise-features/verify.js +244 -0
- package/tools/generate-cli-docs.js +19 -1
- package/tools/migrate-property-refs.js +206 -0
- package/tools/property-extractor/generate-handlebars-docs.js +28 -1
- package/tools/property-extractor/property_extractor.py +34 -0
|
@@ -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
|
|