@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
package/macros/prop.js
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/* Inline macro for referencing Redpanda configuration properties in prose.
|
|
4
|
+
*
|
|
5
|
+
* Example use in a page:
|
|
6
|
+
*
|
|
7
|
+
* prop:cloud_storage_enabled[]
|
|
8
|
+
* prop:iceberg_enabled[link=true]
|
|
9
|
+
* prop:retention_ms[link=true,page=properties/topic-properties]
|
|
10
|
+
* prop:write_caching_default[text=write caching]
|
|
11
|
+
*
|
|
12
|
+
* The property renders as a marked <code> element that the docs UI decorates
|
|
13
|
+
* with a hover-documentation tooltip. Marking is opt-in: only properties
|
|
14
|
+
* referenced through this macro get tooltips, so ambiguous words such as
|
|
15
|
+
* `admin` or `rack` in unrelated contexts (Helm values, feature settings)
|
|
16
|
+
* are never decorated by mistake.
|
|
17
|
+
*
|
|
18
|
+
* The macro validates its target against the published property reference
|
|
19
|
+
* (the newest redpanda-properties-<tag>.json attachment in the reference
|
|
20
|
+
* module, the same data the tooltips fetch at runtime). Unknown names are
|
|
21
|
+
* reported according to the property-validate attribute. When no property
|
|
22
|
+
* JSON is available in the build, the macro renders unvalidated with a
|
|
23
|
+
* single warning.
|
|
24
|
+
*
|
|
25
|
+
* With link=true, the property also links to its reference page. The page
|
|
26
|
+
* is discovered dynamically: the macro indexes which reference-module page
|
|
27
|
+
* in the current component documents each property, by scanning property
|
|
28
|
+
* headings in the partials each page includes (respecting include tag
|
|
29
|
+
* filters such as tags=redpanda-cloud). Links therefore stay correct per
|
|
30
|
+
* component (streaming, cloud, agentic-data-plane, connect) and keep
|
|
31
|
+
* working if properties are split across different pages in the future.
|
|
32
|
+
* When discovery finds nothing, the page falls back deterministically to
|
|
33
|
+
* the property's config_scope (cluster, broker, or topic). The xref is
|
|
34
|
+
* component-relative (no component ID). Use page= to override the target
|
|
35
|
+
* entirely. This supersedes the config_ref macro's linking for most uses,
|
|
36
|
+
* without requiring writers to know the page.
|
|
37
|
+
*
|
|
38
|
+
* With helm-path=auto, pages rendered with the env-kubernetes attribute
|
|
39
|
+
* display the property as its Helm values path (storage.tiered.config.*
|
|
40
|
+
* for tiered storage properties, config.node.* for broker properties, and
|
|
41
|
+
* config.cluster.* for other cluster properties), so single-sourced
|
|
42
|
+
* content reads correctly for both Linux and Kubernetes audiences. This
|
|
43
|
+
* replaces the config_ref macro's hardcoded storage.tiered.config prefix,
|
|
44
|
+
* which mislabeled non-tiered properties.
|
|
45
|
+
*
|
|
46
|
+
* Document or site attributes:
|
|
47
|
+
*
|
|
48
|
+
* property-validate 'warn' (default) to log unknown property names,
|
|
49
|
+
* 'error' to fail the build, 'off' to disable
|
|
50
|
+
* validation.
|
|
51
|
+
* property-ref-role CSS class applied to the code element
|
|
52
|
+
* (default: property-ref).
|
|
53
|
+
*
|
|
54
|
+
* Example use in a playbook:
|
|
55
|
+
*
|
|
56
|
+
* asciidoc:
|
|
57
|
+
* extensions:
|
|
58
|
+
* - '@redpanda-data/docs-extensions-and-macros/macros/prop'
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
const chalk = require('chalk')
|
|
62
|
+
|
|
63
|
+
const $propertyRegistry = Symbol('$propertyRegistry')
|
|
64
|
+
|
|
65
|
+
const DEFAULT_ROLE = 'property-ref'
|
|
66
|
+
const PROPERTIES_JSON_RX = /^redpanda-properties-(v\d+\.\d+\.\d+(?:-[\w.]+)?)\.json$/
|
|
67
|
+
|
|
68
|
+
// Deterministic fallback only. The primary mechanism is dynamic discovery:
|
|
69
|
+
// the macro indexes which reference-module page actually documents each
|
|
70
|
+
// property (by scanning property headings in the partials each page
|
|
71
|
+
// includes, respecting include tag filters), so links keep working if
|
|
72
|
+
// properties are ever split across different pages.
|
|
73
|
+
const SCOPE_PAGES = {
|
|
74
|
+
cluster: 'properties/cluster-properties',
|
|
75
|
+
broker: 'properties/broker-properties',
|
|
76
|
+
topic: 'properties/topic-properties',
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const $propertyPageIndex = Symbol('$propertyPageIndex')
|
|
80
|
+
const HEADING_RX = /^=+\s+(\S+)\s*$/
|
|
81
|
+
const TAG_MARKER_RX = /^\/\/\s*(tag|end)::([\w-]+)\[\]\s*$/
|
|
82
|
+
const INCLUDE_RX = /^include::([^[]+)\[([^\]]*)\]/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Extract property-style headings from AsciiDoc source together with the
|
|
86
|
+
* include tags that enclose them.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} source
|
|
89
|
+
* @returns {{name: string, tags: string[]}[]}
|
|
90
|
+
*/
|
|
91
|
+
function extractHeadingsWithTags (source) {
|
|
92
|
+
const headings = []
|
|
93
|
+
const open = []
|
|
94
|
+
for (const line of source.split('\n')) {
|
|
95
|
+
const marker = line.match(TAG_MARKER_RX)
|
|
96
|
+
if (marker) {
|
|
97
|
+
if (marker[1] === 'tag') open.push(marker[2])
|
|
98
|
+
else {
|
|
99
|
+
const at = open.lastIndexOf(marker[2])
|
|
100
|
+
if (at !== -1) open.splice(at, 1)
|
|
101
|
+
}
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
const heading = line.match(HEADING_RX)
|
|
105
|
+
if (heading) headings.push({ name: heading[1], tags: [...open] })
|
|
106
|
+
}
|
|
107
|
+
return headings
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Evaluate an include tags= expression against the tags enclosing a heading.
|
|
112
|
+
* Supports the forms used by the property pages: a semicolon-separated list
|
|
113
|
+
* of tag names, each optionally negated with '!'.
|
|
114
|
+
*
|
|
115
|
+
* @param {string[]} headingTags
|
|
116
|
+
* @param {string|undefined} expression
|
|
117
|
+
* @returns {boolean} Whether the heading survives the include.
|
|
118
|
+
*/
|
|
119
|
+
function evaluateTagExpression (headingTags, expression) {
|
|
120
|
+
if (!expression) return true
|
|
121
|
+
const items = expression.split(/[;,]/).map((item) => item.trim()).filter(Boolean)
|
|
122
|
+
const positives = items.filter((item) => !item.startsWith('!'))
|
|
123
|
+
const negatives = items.filter((item) => item.startsWith('!')).map((item) => item.slice(1))
|
|
124
|
+
if (negatives.some((tag) => headingTags.includes(tag))) return false
|
|
125
|
+
if (positives.length > 0) return positives.some((tag) => headingTags.includes(tag))
|
|
126
|
+
return true
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve an include target such as
|
|
131
|
+
* 'streaming:reference:partial$properties/cluster-properties.adoc' into its
|
|
132
|
+
* component, module, and relative path. Component and module default to the
|
|
133
|
+
* including page's.
|
|
134
|
+
*/
|
|
135
|
+
function parsePartialTarget (target, pageSrc) {
|
|
136
|
+
const at = target.indexOf('partial$')
|
|
137
|
+
if (at === -1) return undefined
|
|
138
|
+
const prefix = target.slice(0, at).replace(/:$/, '')
|
|
139
|
+
const relative = target.slice(at + 'partial$'.length)
|
|
140
|
+
const parts = prefix ? prefix.split(':') : []
|
|
141
|
+
if (parts.length === 2) return { component: parts[0], module: parts[1], relative }
|
|
142
|
+
if (parts.length === 1) return { component: pageSrc.component, module: parts[0], relative }
|
|
143
|
+
return { component: pageSrc.component, module: pageSrc.module, relative }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build (and cache) the property -> page index for one component by scanning
|
|
148
|
+
* the reference-module pages and the partials they include.
|
|
149
|
+
*
|
|
150
|
+
* @param {object} contentCatalog
|
|
151
|
+
* @param {string} component
|
|
152
|
+
* @param {object} properties - Known property names from the published JSON.
|
|
153
|
+
* @returns {Map<string, string>} property name -> page path without .adoc
|
|
154
|
+
*/
|
|
155
|
+
function buildPageIndex (contentCatalog, component, properties, version) {
|
|
156
|
+
const cache = contentCatalog[$propertyPageIndex] || (contentCatalog[$propertyPageIndex] = {})
|
|
157
|
+
const cacheKey = `${component}@${version || ''}`
|
|
158
|
+
if (cache[cacheKey]) return cache[cacheKey]
|
|
159
|
+
const index = new Map()
|
|
160
|
+
const partialHeadings = new Map()
|
|
161
|
+
const headingsFor = (file) => {
|
|
162
|
+
if (!partialHeadings.has(file)) partialHeadings.set(file, extractHeadingsWithTags(file.contents.toString()))
|
|
163
|
+
return partialHeadings.get(file)
|
|
164
|
+
}
|
|
165
|
+
// Restrict the index to the page's own component version, so a page on an
|
|
166
|
+
// older docs branch never links into (or claims pages from) another version.
|
|
167
|
+
const query = { component, module: 'reference', family: 'page' }
|
|
168
|
+
if (version !== undefined && version !== '') query.version = version
|
|
169
|
+
const pages = contentCatalog.findBy(query) || []
|
|
170
|
+
// Antora replaces each page's contents with converted HTML as it goes, so
|
|
171
|
+
// an index built lazily mid-conversion can miss includes on pages that
|
|
172
|
+
// already converted. The property-page-index extension warms this cache
|
|
173
|
+
// before conversion starts; warn when that safety net is absent.
|
|
174
|
+
if (pages.some((candidate) => candidate.contents.toString('utf8', 0, 200).trimStart().startsWith('<'))) {
|
|
175
|
+
console.warn(chalk.yellow(`prop macro: building the property page index for ${component}@${version || 'any'} after conversion started; some pages are already HTML and their properties may not be indexed. Register '@redpanda-data/docs-extensions-and-macros/extensions/property-page-index' under antora.extensions to build the index up front.`))
|
|
176
|
+
}
|
|
177
|
+
for (const page of pages) {
|
|
178
|
+
const source = page.contents.toString()
|
|
179
|
+
const pagePath = page.src.relative.replace(/\.adoc$/, '')
|
|
180
|
+
const pageUrl = (page.pub && page.pub.url) || undefined
|
|
181
|
+
for (const line of source.split('\n')) {
|
|
182
|
+
const heading = line.match(HEADING_RX)
|
|
183
|
+
if (heading && Object.prototype.hasOwnProperty.call(properties, heading[1])) {
|
|
184
|
+
index.set(heading[1], { page: pagePath, url: pageUrl })
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
const include = line.match(INCLUDE_RX)
|
|
188
|
+
if (!include) continue
|
|
189
|
+
const partialRef = parsePartialTarget(include[1].trim(), page.src)
|
|
190
|
+
if (!partialRef) continue
|
|
191
|
+
const tagsMatch = include[2].match(/tags?=([^,\]]+)/)
|
|
192
|
+
const expression = tagsMatch && tagsMatch[1]
|
|
193
|
+
const partials = contentCatalog.findBy({ component: partialRef.component, module: partialRef.module, family: 'partial' }) || []
|
|
194
|
+
const matching = partials.filter((candidate) => candidate.src.relative === partialRef.relative)
|
|
195
|
+
// Same-component includes resolve within the page's version; for
|
|
196
|
+
// cross-component includes take whichever version the catalog has.
|
|
197
|
+
const partial = matching.find((candidate) => candidate.src.version === page.src.version) || matching[0]
|
|
198
|
+
if (!partial) continue
|
|
199
|
+
for (const entry of headingsFor(partial)) {
|
|
200
|
+
if (!Object.prototype.hasOwnProperty.call(properties, entry.name)) continue
|
|
201
|
+
if (!evaluateTagExpression(entry.tags, expression)) continue
|
|
202
|
+
if (!index.has(entry.name)) index.set(entry.name, { page: pagePath, url: pageUrl })
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
cache[cacheKey] = index
|
|
207
|
+
return index
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The anchor Asciidoctor generates for a property heading. With the docs'
|
|
212
|
+
* idseparator '-', invalid id characters (dots) become hyphens while
|
|
213
|
+
* underscores are valid and stay: '=== cloud_storage_enabled' gets the id
|
|
214
|
+
* 'cloud_storage_enabled', '=== redpanda.storage.mode' gets
|
|
215
|
+
* 'redpanda-storage-mode'.
|
|
216
|
+
*/
|
|
217
|
+
function propertyAnchor (name) {
|
|
218
|
+
return name.replace(/\./g, '-')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Helm values paths for setting properties on Kubernetes. Deterministic:
|
|
222
|
+
// tiered storage properties keep the chart's dedicated storage.tiered.config
|
|
223
|
+
// block (which also wires credentials), broker/node properties map to
|
|
224
|
+
// config.node, and every other cluster property maps to config.cluster --
|
|
225
|
+
// the path the docs recommend for all cluster properties.
|
|
226
|
+
function helmValuesPath (name, scope) {
|
|
227
|
+
if (name.startsWith('cloud_storage_')) return `storage.tiered.config.${name}`
|
|
228
|
+
if (scope === 'broker') return `config.node.${name}`
|
|
229
|
+
return `config.cluster.${name}`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let warnedNoRegistry = false
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Compare two vX.Y.Z(-pre) tags without a semver dependency. Good enough to
|
|
236
|
+
* pick the newest properties attachment.
|
|
237
|
+
*/
|
|
238
|
+
function compareTags (a, b) {
|
|
239
|
+
const parse = (tag) => tag.replace(/^v/, '').split('-')[0].split('.').map(Number)
|
|
240
|
+
const [a1, a2, a3] = parse(a)
|
|
241
|
+
const [b1, b2, b3] = parse(b)
|
|
242
|
+
return a1 - b1 || a2 - b2 || a3 - b3
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Load and cache the property map from the newest published properties JSON
|
|
247
|
+
* attachment in the content catalog. Properties are published per component
|
|
248
|
+
* (streaming, cloud, sometimes agentic-data-plane and connect), so the JSON
|
|
249
|
+
* from the page's own component wins, falling back to the streaming (or
|
|
250
|
+
* ROOT) component, then to the newest JSON anywhere in the catalog. Returns
|
|
251
|
+
* undefined when unavailable.
|
|
252
|
+
*/
|
|
253
|
+
function loadProperties (config) {
|
|
254
|
+
const contentCatalog = config && config.contentCatalog
|
|
255
|
+
if (!contentCatalog) return undefined
|
|
256
|
+
const pageComponent = (config.file && config.file.src && config.file.src.component) || ''
|
|
257
|
+
const pageVersion = (config.file && config.file.src && config.file.src.version) || ''
|
|
258
|
+
return loadPropertiesFor(contentCatalog, pageComponent, pageVersion)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Same as loadProperties, addressable by component and version so the
|
|
263
|
+
* property-page-index extension can warm the caches before conversion.
|
|
264
|
+
*/
|
|
265
|
+
function loadPropertiesFor (contentCatalog, pageComponent, pageVersion) {
|
|
266
|
+
const cacheKey = `${pageComponent}@${pageVersion}` || '$any'
|
|
267
|
+
const cache = contentCatalog[$propertyRegistry] || (contentCatalog[$propertyRegistry] = {})
|
|
268
|
+
if (cache[cacheKey] !== undefined) return cache[cacheKey] || undefined
|
|
269
|
+
|
|
270
|
+
// Versioned components publish one properties JSON per version, so a page
|
|
271
|
+
// must validate against its OWN version's data (a 25.3 page checked
|
|
272
|
+
// against 26.x data would produce wrong results in both directions).
|
|
273
|
+
// Priority: own component and version, own component any version (newest
|
|
274
|
+
// tag), then the streaming/ROOT fallbacks, then anything.
|
|
275
|
+
const attachments = contentCatalog.findBy({ family: 'attachment' }) || []
|
|
276
|
+
const candidates = []
|
|
277
|
+
for (const attachment of attachments) {
|
|
278
|
+
if (attachment.src.module !== 'reference') continue
|
|
279
|
+
const basename = attachment.src.relative.split('/').pop()
|
|
280
|
+
const match = basename.match(PROPERTIES_JSON_RX)
|
|
281
|
+
if (!match) continue
|
|
282
|
+
candidates.push({ tag: match[1], file: attachment, component: attachment.src.component, version: attachment.src.version })
|
|
283
|
+
}
|
|
284
|
+
const newest = (list) => list.reduce((best, entry) => (!best || compareTags(entry.tag, best.tag) > 0 ? entry : best), null)
|
|
285
|
+
const pick =
|
|
286
|
+
newest(candidates.filter((c) => c.component === pageComponent && c.version === pageVersion)) ||
|
|
287
|
+
newest(candidates.filter((c) => c.component === pageComponent)) ||
|
|
288
|
+
newest(candidates.filter((c) => c.component === 'streaming')) ||
|
|
289
|
+
newest(candidates.filter((c) => c.component === 'ROOT')) ||
|
|
290
|
+
newest(candidates)
|
|
291
|
+
|
|
292
|
+
let registry = null
|
|
293
|
+
if (pick) {
|
|
294
|
+
const data = JSON.parse(pick.file.contents.toString())
|
|
295
|
+
if (data && data.properties) {
|
|
296
|
+
registry = { tag: pick.tag, properties: data.properties }
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Cache null too, so a missing JSON is only searched for once per build.
|
|
300
|
+
cache[cacheKey] = registry
|
|
301
|
+
return registry || undefined
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function warnNoPropertyData () {
|
|
305
|
+
if (warnedNoRegistry) return
|
|
306
|
+
warnedNoRegistry = true
|
|
307
|
+
console.warn(chalk.yellow('Property reference data (redpanda-properties-<tag>.json attachment) not found; prop: targets are not validated.'))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Report an unknown property name according to the property-validate mode.
|
|
312
|
+
*/
|
|
313
|
+
function reportUnknownProperty ({ name, mode, registry, filePath }) {
|
|
314
|
+
if (mode === 'off') return
|
|
315
|
+
const fragment = name.trim().toLowerCase()
|
|
316
|
+
const commonPrefix = (a, b) => {
|
|
317
|
+
let i = 0
|
|
318
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++
|
|
319
|
+
return i
|
|
320
|
+
}
|
|
321
|
+
const candidates = Object.keys(registry.properties)
|
|
322
|
+
.map((candidate) => ({ candidate, shared: commonPrefix(candidate.toLowerCase(), fragment) }))
|
|
323
|
+
.filter((scored) => scored.shared >= Math.min(6, fragment.length))
|
|
324
|
+
.sort((a, b) => b.shared - a.shared)
|
|
325
|
+
.slice(0, 3)
|
|
326
|
+
.map((scored) => scored.candidate)
|
|
327
|
+
const hint = candidates.length ? ` Did you mean: ${candidates.join(', ')}?` : ''
|
|
328
|
+
const where = filePath ? ` in ${filePath}` : ''
|
|
329
|
+
const message = `prop:${name}[] does not match any property in the published property reference (${registry.tag})${where}.${hint}`
|
|
330
|
+
if (mode === 'error') throw new Error(message)
|
|
331
|
+
console.warn(chalk.yellow(message))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Build the AsciiDoc content emitted for one macro instance. Exported for
|
|
336
|
+
* unit testing.
|
|
337
|
+
*
|
|
338
|
+
* @param {object} opts
|
|
339
|
+
* @param {string} opts.name - Property name.
|
|
340
|
+
* @param {string} [opts.text] - Display text override.
|
|
341
|
+
* @param {boolean} opts.link - Whether to link to the reference page.
|
|
342
|
+
* @param {string} [opts.page] - Reference page override (module-relative, no .adoc).
|
|
343
|
+
* @param {string} [opts.scope] - config_scope from the published JSON.
|
|
344
|
+
* @param {string} opts.role - CSS class for the code element.
|
|
345
|
+
* @param {string} [opts.componentPrefix] - 'component:' prefix when the link
|
|
346
|
+
* must leave the current component (which publishes no property pages).
|
|
347
|
+
* @returns {string}
|
|
348
|
+
*/
|
|
349
|
+
function buildPropContent ({ name, text, link, page, scope, role, componentPrefix = '', helmPath = false, docUrl }) {
|
|
350
|
+
const display = text || (helmPath ? helmValuesPath(name, scope) : name)
|
|
351
|
+
let inner = display
|
|
352
|
+
if (link) {
|
|
353
|
+
const targetPage = page || SCOPE_PAGES[scope] || SCOPE_PAGES.cluster
|
|
354
|
+
inner = `xref:${componentPrefix}reference:${targetPage}.adoc#${propertyAnchor(name)}[${display}]`
|
|
355
|
+
}
|
|
356
|
+
// The published URL of the page that documents this property, discovered
|
|
357
|
+
// at build time. The docs UI prefers it for the tooltip's documentation
|
|
358
|
+
// link -- the client can't know which scopes a component publishes or
|
|
359
|
+
// which page a shared property landed on.
|
|
360
|
+
const docAttr = docUrl ? ` data-doc-url="${docUrl}"` : ''
|
|
361
|
+
return `<code class="${role}" data-property-name="${name}"${docAttr}>${inner}</code>`
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function propInlineMacro (config) {
|
|
365
|
+
return function () {
|
|
366
|
+
const self = this
|
|
367
|
+
self.named('prop')
|
|
368
|
+
self.process(function (parent, target, attributes) {
|
|
369
|
+
const document = parent.getDocument()
|
|
370
|
+
const name = target.trim()
|
|
371
|
+
let registry
|
|
372
|
+
let entry
|
|
373
|
+
if (config && config.contentCatalog) {
|
|
374
|
+
registry = loadProperties(config)
|
|
375
|
+
if (!registry) warnNoPropertyData()
|
|
376
|
+
}
|
|
377
|
+
if (registry) {
|
|
378
|
+
entry = registry.properties[name]
|
|
379
|
+
if (!entry) {
|
|
380
|
+
reportUnknownProperty({
|
|
381
|
+
name,
|
|
382
|
+
mode: document.getAttribute('property-validate', 'warn'),
|
|
383
|
+
registry,
|
|
384
|
+
filePath: config && config.file && config.file.src && config.file.src.path,
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// Discover which page documents the property. The current component
|
|
389
|
+
// wins. When it doesn't document this property -- either because it
|
|
390
|
+
// publishes no property pages at all (the connect component, this
|
|
391
|
+
// repo's preview site) or because it publishes only a subset (the
|
|
392
|
+
// cloud component has no topic-properties page) -- fall back to the
|
|
393
|
+
// streaming (or ROOT) component and emit a component-qualified xref.
|
|
394
|
+
let discoveredPage
|
|
395
|
+
let discoveredUrl
|
|
396
|
+
let componentPrefix = ''
|
|
397
|
+
let suppressLink = false
|
|
398
|
+
const linkRequested = attributes.link === 'true' || attributes.link === true
|
|
399
|
+
if (registry && config.contentCatalog && !attributes.page) {
|
|
400
|
+
const component = (config.file && config.file.src && config.file.src.component) || ''
|
|
401
|
+
const version = (config.file && config.file.src && config.file.src.version) || ''
|
|
402
|
+
const ownIndex = buildPageIndex(config.contentCatalog, component, registry.properties, version)
|
|
403
|
+
const ownEntry = ownIndex.get(name)
|
|
404
|
+
if (ownEntry) {
|
|
405
|
+
discoveredPage = ownEntry.page
|
|
406
|
+
discoveredUrl = ownEntry.url
|
|
407
|
+
} else {
|
|
408
|
+
let fallbackWithPages
|
|
409
|
+
for (const fallbackComponent of ['streaming', 'ROOT']) {
|
|
410
|
+
if (fallbackComponent === component) continue
|
|
411
|
+
// A component-qualified xref without a version resolves to the
|
|
412
|
+
// fallback component's latest version, so consult exactly that
|
|
413
|
+
// version's index (which the property-page-index extension warms).
|
|
414
|
+
const latest = typeof config.contentCatalog.getComponent === 'function' &&
|
|
415
|
+
config.contentCatalog.getComponent(fallbackComponent)
|
|
416
|
+
const fallbackVersion = (latest && latest.latest && latest.latest.version) || undefined
|
|
417
|
+
const fallbackIndex = buildPageIndex(config.contentCatalog, fallbackComponent, registry.properties, fallbackVersion)
|
|
418
|
+
if (fallbackIndex.size === 0) continue
|
|
419
|
+
if (!fallbackWithPages) fallbackWithPages = fallbackComponent
|
|
420
|
+
const fallbackEntry = fallbackIndex.get(name)
|
|
421
|
+
if (fallbackEntry) {
|
|
422
|
+
discoveredPage = fallbackEntry.page
|
|
423
|
+
discoveredUrl = fallbackEntry.url
|
|
424
|
+
componentPrefix = `${fallbackComponent}:`
|
|
425
|
+
break
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// Nothing documents the property anywhere: point the scope-derived
|
|
429
|
+
// fallback at a component that at least publishes property pages,
|
|
430
|
+
// unless the current component does.
|
|
431
|
+
if (!discoveredPage && ownIndex.size === 0 && fallbackWithPages) {
|
|
432
|
+
componentPrefix = `${fallbackWithPages}:`
|
|
433
|
+
}
|
|
434
|
+
// Property pages exist but none of them documents this property
|
|
435
|
+
// (for example, a cloud-only topic property when no site publishes
|
|
436
|
+
// a cloud topic-properties page). Linking would produce a broken
|
|
437
|
+
// xref, so render the marker without a link and say why. A plain
|
|
438
|
+
// tooltip marker for an undocumented property is fine -- only an
|
|
439
|
+
// explicit link request warrants the warning.
|
|
440
|
+
if (linkRequested && !discoveredPage && (ownIndex.size > 0 || fallbackWithPages)) {
|
|
441
|
+
const where = (config.file && config.file.src && config.file.src.path) || 'unknown file'
|
|
442
|
+
console.warn(chalk.yellow(`prop:${name}[link=true] in ${where}: no property reference page documents '${name}' in this build, so it renders without a link. Check the property's include tags on the reference pages.`))
|
|
443
|
+
suppressLink = true
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// helm-path=auto displays the property as its Helm values path on
|
|
448
|
+
// pages rendered with env-kubernetes, so single-sourced content reads
|
|
449
|
+
// correctly for both Linux and Kubernetes audiences (the successor to
|
|
450
|
+
// the config_ref macro's hardcoded storage.tiered.config prefixing).
|
|
451
|
+
const helmPath = attributes['helm-path'] === 'auto' && document.getAttribute('env-kubernetes') !== undefined
|
|
452
|
+
const content = buildPropContent({
|
|
453
|
+
name,
|
|
454
|
+
text: attributes.text,
|
|
455
|
+
link: linkRequested && !suppressLink,
|
|
456
|
+
page: attributes.page || discoveredPage,
|
|
457
|
+
scope: entry && entry.config_scope,
|
|
458
|
+
role: document.getAttribute('property-ref-role', DEFAULT_ROLE),
|
|
459
|
+
componentPrefix,
|
|
460
|
+
helmPath,
|
|
461
|
+
docUrl: discoveredUrl ? `${discoveredUrl}#${propertyAnchor(name)}` : undefined,
|
|
462
|
+
})
|
|
463
|
+
// The xref inside the code element is resolved by the 'macros'
|
|
464
|
+
// substitution, the same mechanism the enterprise macro relies on.
|
|
465
|
+
return self.createInline(parent, 'quoted', content, { attributes: { subs: 'macros' } })
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function register (registry, config = {}) {
|
|
471
|
+
if (typeof registry.register === 'function') {
|
|
472
|
+
registry.register(function () {
|
|
473
|
+
this.inlineMacro(propInlineMacro(config))
|
|
474
|
+
})
|
|
475
|
+
} else if (typeof registry.inlineMacro === 'function') {
|
|
476
|
+
registry.inlineMacro(propInlineMacro(config))
|
|
477
|
+
} else {
|
|
478
|
+
console.warn("no 'inlineMacro' method on alleged registry")
|
|
479
|
+
}
|
|
480
|
+
return registry
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
module.exports.register = register
|
|
484
|
+
module.exports.buildPropContent = buildPropContent
|
|
485
|
+
module.exports.propertyAnchor = propertyAnchor
|
|
486
|
+
module.exports.helmValuesPath = helmValuesPath
|
|
487
|
+
module.exports.loadPropertiesFor = loadPropertiesFor
|
|
488
|
+
module.exports.compareTags = compareTags
|
|
489
|
+
module.exports.extractHeadingsWithTags = extractHeadingsWithTags
|
|
490
|
+
module.exports.evaluateTagExpression = evaluateTagExpression
|
|
491
|
+
module.exports.buildPageIndex = buildPageIndex
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redpanda-data/docs-extensions-and-macros",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.9.0",
|
|
4
4
|
"description": "Antora extensions and macros developed for Redpanda documentation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"antora",
|
|
@@ -84,7 +84,10 @@
|
|
|
84
84
|
"./macros/rp-connect-components": "./macros/rp-connect-components.js",
|
|
85
85
|
"./macros/config-ref": "./macros/config-ref.js",
|
|
86
86
|
"./macros/helm-ref": "./macros/helm-ref.js",
|
|
87
|
-
"./macros/badge": "./macros/badge.js"
|
|
87
|
+
"./macros/badge": "./macros/badge.js",
|
|
88
|
+
"./macros/enterprise": "./macros/enterprise.js",
|
|
89
|
+
"./macros/prop": "./macros/prop.js",
|
|
90
|
+
"./extensions/property-page-index": "./extensions/property-page-index.js"
|
|
88
91
|
},
|
|
89
92
|
"files": [
|
|
90
93
|
"extensions",
|
|
@@ -141,6 +144,7 @@
|
|
|
141
144
|
"devDependencies": {
|
|
142
145
|
"@antora/cli": "3.1.4",
|
|
143
146
|
"@antora/site-generator": "3.1.4",
|
|
147
|
+
"@asciidoctor/core": "2.2.8",
|
|
144
148
|
"@web/dev-server": "^0.2.5",
|
|
145
149
|
"jest": "^29.7.0",
|
|
146
150
|
"jest-junit": "^16.0.0"
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/* Parsers for the sources of truth compared by `doc-tools validate
|
|
4
|
+
* enterprise-features`. Each parser is pure (string in, data out) so it can
|
|
5
|
+
* be unit-tested with trimmed fixtures.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const Papa = require('papaparse')
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Extract the license_required_feature enum values from
|
|
12
|
+
* src/v/features/enterprise_features.h.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} source - Header file contents.
|
|
15
|
+
* @returns {string[]} Enum value names in declaration order.
|
|
16
|
+
*/
|
|
17
|
+
function parseCoreEnum (source) {
|
|
18
|
+
const match = source.match(/enum\s+class\s+license_required_feature[^{]*\{([\s\S]*?)\}/)
|
|
19
|
+
if (!match) {
|
|
20
|
+
throw new Error('Could not find enum class license_required_feature in enterprise_features.h. The header layout may have changed.')
|
|
21
|
+
}
|
|
22
|
+
return match[1]
|
|
23
|
+
.split(',')
|
|
24
|
+
.map((entry) => entry.replace(/\/\/.*$/gm, '').trim())
|
|
25
|
+
.filter((entry) => /^[a-z_][a-z0-9_]*$/.test(entry))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract the names of config::enterprise<>-wrapped properties from
|
|
30
|
+
* src/v/config/configuration.h. Handles multi-line declarations by
|
|
31
|
+
* balancing angle brackets from each `enterprise<` occurrence.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} source - Header file contents.
|
|
34
|
+
* @returns {string[]} Property member names.
|
|
35
|
+
*/
|
|
36
|
+
function parseEnterpriseProperties (source) {
|
|
37
|
+
const names = []
|
|
38
|
+
const re = /\benterprise</g
|
|
39
|
+
let match
|
|
40
|
+
while ((match = re.exec(source)) !== null) {
|
|
41
|
+
let depth = 1
|
|
42
|
+
let i = match.index + match[0].length
|
|
43
|
+
while (i < source.length && depth > 0) {
|
|
44
|
+
if (source[i] === '<') depth++
|
|
45
|
+
else if (source[i] === '>') depth--
|
|
46
|
+
i++
|
|
47
|
+
}
|
|
48
|
+
const rest = source.slice(i)
|
|
49
|
+
const nameMatch = rest.match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*;/)
|
|
50
|
+
if (nameMatch) names.push(nameMatch[1])
|
|
51
|
+
}
|
|
52
|
+
return [...new Set(names)]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract the plugins marked enterprise in connect internal/plugins/info.csv.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} source - CSV contents.
|
|
59
|
+
* @returns {string[]} Plugin names with support == enterprise.
|
|
60
|
+
*/
|
|
61
|
+
function parseConnectEnterprisePlugins (source) {
|
|
62
|
+
// The real info.csv header has spaces after the commas (name, type, ...),
|
|
63
|
+
// so headers must be trimmed to get usable keys.
|
|
64
|
+
const parsed = Papa.parse(source.trim(), {
|
|
65
|
+
header: true,
|
|
66
|
+
skipEmptyLines: true,
|
|
67
|
+
transformHeader: (header) => header.trim(),
|
|
68
|
+
})
|
|
69
|
+
return parsed.data
|
|
70
|
+
.filter((row) => (row.support || '').trim().toLowerCase() === 'enterprise')
|
|
71
|
+
.map((row) => (row.name || '').trim())
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract feature name and disabling property from the table in
|
|
77
|
+
* disable-enterprise-features.adoc. Rows look like:
|
|
78
|
+
*
|
|
79
|
+
* | xref:...[Feature Name]
|
|
80
|
+
* | Set `property` to ... / rpk cluster config set property value
|
|
81
|
+
*
|
|
82
|
+
* @param {string} source - AsciiDoc page contents.
|
|
83
|
+
* @returns {{feature: string, properties: string[]}[]}
|
|
84
|
+
*/
|
|
85
|
+
function parseDisableTable (source) {
|
|
86
|
+
const tables = source.match(/\|===([\s\S]*?)\|===/g)
|
|
87
|
+
if (!tables) return []
|
|
88
|
+
const rows = []
|
|
89
|
+
for (const table of tables) {
|
|
90
|
+
// Split into cells that start a line with '|', keeping cell bodies.
|
|
91
|
+
const cells = table
|
|
92
|
+
.split(/\n(?=\|)/)
|
|
93
|
+
.map((cell) => cell.replace(/^\|/, '').trim())
|
|
94
|
+
.filter((cell) => cell && !cell.startsWith('==='))
|
|
95
|
+
// The header row (| Feature | Action ...) is one single-line chunk at
|
|
96
|
+
// index 0. Data cells follow, one chunk per cell.
|
|
97
|
+
for (let i = 1; i + 1 < cells.length; i += 2) {
|
|
98
|
+
const nameCell = cells[i]
|
|
99
|
+
const actionCell = cells[i + 1]
|
|
100
|
+
const xrefMatch = nameCell.match(/xref:[^[]+\[([^\]]+)\]/)
|
|
101
|
+
const feature = (xrefMatch ? xrefMatch[1] : nameCell.split('\n')[0]).trim()
|
|
102
|
+
// Capture only set-targets ("Set `prop` to ..." and "config set prop"),
|
|
103
|
+
// not backticked values such as `node_add`.
|
|
104
|
+
const properties = [...new Set(
|
|
105
|
+
[...actionCell.matchAll(/[Ss]et\s+`([a-z][a-z0-9_]+)`|config set ([a-z][a-z0-9_]+)/g)]
|
|
106
|
+
.map((m) => m[1] || m[2])
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
)]
|
|
109
|
+
if (feature) rows.push({ feature, properties })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return rows
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Extract the enterprise-components attribute list from rp-connect-docs
|
|
117
|
+
* antora.yml, without a YAML dependency on the full file structure.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} antoraConfig - Parsed antora.yml object.
|
|
120
|
+
* @returns {string[]|undefined} The list, or undefined when absent.
|
|
121
|
+
*/
|
|
122
|
+
function extractAntoraEnterpriseComponents (antoraConfig) {
|
|
123
|
+
const attributes = antoraConfig && antoraConfig.asciidoc && antoraConfig.asciidoc.attributes
|
|
124
|
+
const list = attributes && attributes['enterprise-components']
|
|
125
|
+
return Array.isArray(list) ? list.map(String) : undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = {
|
|
129
|
+
parseCoreEnum,
|
|
130
|
+
parseEnterpriseProperties,
|
|
131
|
+
parseConnectEnterprisePlugins,
|
|
132
|
+
parseDisableTable,
|
|
133
|
+
extractAntoraEnterpriseComponents,
|
|
134
|
+
}
|