@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.
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.8.0",
3
+ "version": "5.10.0",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -24,6 +24,7 @@
24
24
  "build": "antora --to-dir docs --fetch local-antora-playbook.yml",
25
25
  "serve": "wds --node-resolve --open preview/test/ --watch --root-dir docs",
26
26
  "test": "jest",
27
+ "test:e2e": "jest __tests__/e2e/",
27
28
  "test:mcp": "jest __tests__/mcp/",
28
29
  "test:mcp:integration": "jest __tests__/mcp/integration.test.js",
29
30
  "test:mcp:contract": "jest __tests__/mcp/cli-contract.test.js",
@@ -80,11 +81,16 @@
80
81
  "./extensions/resolve-xrefs-in-attachments": "./extensions/resolve-xrefs-in-attachments.js",
81
82
  "./extensions/unified-navigation": "./extensions/unified-navigation.js",
82
83
  "./extensions/conditional-home-redirect": "./extensions/conditional-home-redirect.js",
84
+ "./extensions/url-to-xref": "./extensions/url-to-xref.js",
85
+ "./extensions/external-link-checker": "./extensions/external-link-checker.js",
83
86
  "./macros/glossary": "./macros/glossary.js",
84
87
  "./macros/rp-connect-components": "./macros/rp-connect-components.js",
85
88
  "./macros/config-ref": "./macros/config-ref.js",
86
89
  "./macros/helm-ref": "./macros/helm-ref.js",
87
- "./macros/badge": "./macros/badge.js"
90
+ "./macros/badge": "./macros/badge.js",
91
+ "./macros/enterprise": "./macros/enterprise.js",
92
+ "./macros/prop": "./macros/prop.js",
93
+ "./extensions/property-page-index": "./extensions/property-page-index.js"
88
94
  },
89
95
  "files": [
90
96
  "extensions",
@@ -98,6 +104,9 @@
98
104
  "mcp"
99
105
  ],
100
106
  "license": "ISC",
107
+ "engines": {
108
+ "node": ">=18"
109
+ },
101
110
  "repository": {
102
111
  "type": "git",
103
112
  "url": "git+https://github.com/redpanda-data/docs-extensions-and-macros"
@@ -141,6 +150,7 @@
141
150
  "devDependencies": {
142
151
  "@antora/cli": "3.1.4",
143
152
  "@antora/site-generator": "3.1.4",
153
+ "@asciidoctor/core": "2.2.8",
144
154
  "@web/dev-server": "^0.2.5",
145
155
  "jest": "^29.7.0",
146
156
  "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
+ }