@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/macros/badge.js CHANGED
@@ -1,21 +1,43 @@
1
1
  'use strict';
2
2
 
3
+ /**
4
+ * Build the HTML for a badge.
5
+ *
6
+ * Extracted from the macro body so other macros can render an identical badge
7
+ * without depending on the badge macro being registered in the consuming
8
+ * playbook. The enterprise macro uses it to render the beta badge for registry
9
+ * entries marked `beta: true`, which keeps one definition of the markup and
10
+ * the CSS class names.
11
+ *
12
+ * @param {object} opts
13
+ * @param {string} [opts.label] - Badge text.
14
+ * @param {string} [opts.size] - `large` renders without surrounding brackets.
15
+ * @param {string} [opts.tooltip] - Tooltip text, rendered as `data-tooltip`.
16
+ * @returns {string} Badge HTML.
17
+ */
18
+ function buildBadgeHtml ({ label, size, tooltip } = {}) {
19
+ const text = label || 'label';
20
+ const className = `badge--${text.toLowerCase().replace(/\s+/g, '-')}`;
21
+ const isLarge = size === 'large';
22
+ const sizeClass = isLarge ? 'badge--large' : '';
23
+ const tooltipAttr = tooltip ? ` data-tooltip="${tooltip}"` : '';
24
+
25
+ // Add brackets if not large. The brackets matter beyond styling: they are
26
+ // real text content, so the label survives an HTML-to-Markdown conversion as
27
+ // "(beta)" rather than vanishing with the CSS class.
28
+ const renderedLabel = isLarge ? text : `(${text})`;
29
+
30
+ return `<span class="badge ${className} ${sizeClass}"${tooltipAttr}>${renderedLabel}</span>`;
31
+ }
32
+
33
+ module.exports.buildBadgeHtml = buildBadgeHtml;
34
+
3
35
  module.exports.register = function (registry) {
4
36
  registry.inlineMacro(function () {
5
37
  const self = this;
6
38
  self.named('badge');
7
- self.process((parent, target, attrs) => {
8
- const label = attrs.label || 'label';
9
- const className = `badge--${label.toLowerCase().replace(/\s+/g, '-')}`;
10
- const isLarge = attrs.size === 'large';
11
- const sizeClass = isLarge ? 'badge--large' : '';
12
- const tooltip = attrs.tooltip;
13
- const tooltipAttr = tooltip ? ` data-tooltip="${tooltip}"` : '';
14
-
15
- // Add brackets if not large
16
- const renderedLabel = isLarge ? label : `(${label})`;
17
-
18
- return `<span class="badge ${className} ${sizeClass}"${tooltipAttr}>${renderedLabel}</span>`;
19
- });
39
+ self.process((parent, target, attrs) =>
40
+ buildBadgeHtml({ label: attrs.label, size: attrs.size, tooltip: attrs.tooltip })
41
+ );
20
42
  });
21
43
  };
@@ -0,0 +1,390 @@
1
+ 'use strict'
2
+
3
+ /* Macros for marking and listing enterprise features.
4
+ *
5
+ * Inline macro — mark a feature in prose:
6
+ *
7
+ * enterprise:Continuous Data Balancing[]
8
+ * enterprise:Tiered Storage[xref=manage:tiered-storage.adoc]
9
+ * enterprise:Audit Logging[text=audit logging]
10
+ * enterprise:Iceberg Topics[tooltip=Iceberg Topics requires an Enterprise Edition license and object storage.]
11
+ *
12
+ * The feature name renders as a uniquely styled term with a tooltip that
13
+ * explains the feature requires an Enterprise Edition license. The term
14
+ * links to the feature's documentation when an xref attribute is given,
15
+ * and otherwise to the licensing page, so readers can always reach an
16
+ * explanation of what having (or not having) a license means.
17
+ *
18
+ * Block macro — render the licensing feature table for one scope:
19
+ *
20
+ * enterprise_features::redpanda[]
21
+ * enterprise_features::connect[title=Enterprise features in Redpanda Connect]
22
+ *
23
+ * Registry validation:
24
+ *
25
+ * Both macros read the canonical enterprise features registry from the
26
+ * 'shared' component (modules/ROOT/partials/enterprise-features.yml in the
27
+ * shared folder of the docs repo). Inline targets are resolved against the
28
+ * registry case-insensitively, including aliases, and the canonical name,
29
+ * feature xref, and tooltip come from the matching entry. Unknown targets
30
+ * are reported according to the enterprise-validate attribute. When no
31
+ * registry is available (for example, outside an Antora build), the macros
32
+ * fall back to unvalidated rendering with a single warning.
33
+ *
34
+ * Document or site attributes:
35
+ *
36
+ * enterprise-validate 'warn' (default) to log unknown feature names,
37
+ * 'error' to fail the build, 'off' to disable
38
+ * validation.
39
+ * enterprise-licensing-page Resource ID of the licensing page used when
40
+ * no feature page is known
41
+ * (default: get-started:licensing/overview.adoc).
42
+ * enterprise-feature-role CSS class applied to the wrapping span
43
+ * (default: enterprise-feature).
44
+ * enterprise-tooltip 'title' (default), 'true' (renders
45
+ * data-enterprise-tooltip), any attribute name
46
+ * starting with 'data-', or 'false' to disable.
47
+ * enterprise-links 'true' (default) to render the term as a link.
48
+ *
49
+ * Example use in a playbook:
50
+ *
51
+ * asciidoc:
52
+ * extensions:
53
+ * - '@redpanda-data/docs-extensions-and-macros/macros/enterprise'
54
+ */
55
+
56
+ const yaml = require('js-yaml')
57
+ const chalk = require('chalk')
58
+ const { buildBadgeHtml } = require('./badge')
59
+
60
+ const $enterpriseRegistry = Symbol('$enterpriseRegistry')
61
+
62
+ const DEFAULT_LICENSING_PAGE = 'get-started:licensing/overview.adoc'
63
+ const DEFAULT_ROLE = 'enterprise-feature'
64
+ const BETA_LABEL = 'beta'
65
+
66
+ /**
67
+ * Render the beta badge for a registry entry marked `beta: true`.
68
+ *
69
+ * The badge HTML is built directly rather than emitted as `badge:[...]`
70
+ * AsciiDoc, so it renders whether or not the consuming playbook registers the
71
+ * badge macro. Writing the macro call into a registry field used to be the only
72
+ * way to flag a beta feature, and it silently produced the literal text
73
+ * `badge::[label=beta]` in any build without that macro registered.
74
+ *
75
+ * @param {object} entry - Registry entry.
76
+ * @returns {string} Badge HTML, or an empty string when the entry is not beta.
77
+ */
78
+ function buildBetaBadge (entry) {
79
+ if (!entry || entry.beta !== true) return ''
80
+ return buildBadgeHtml({
81
+ label: BETA_LABEL,
82
+ tooltip: entry['beta-tooltip'] || undefined,
83
+ })
84
+ }
85
+
86
+ /**
87
+ * The badge for a registry entry, wrapped for use in generated AsciiDoc.
88
+ *
89
+ * The block macro returns AsciiDoc source that Asciidoctor then parses, so raw
90
+ * HTML placed in a table cell is escaped and the reader sees the markup as
91
+ * text. An inline passthrough emits it verbatim instead, and unlike a
92
+ * `badge:[...]` macro call it does not require the badge macro to be registered
93
+ * in the consuming playbook.
94
+ *
95
+ * @param {object} entry - Registry entry.
96
+ * @returns {string} Passthrough-wrapped badge, or an empty string.
97
+ */
98
+ function buildBetaBadgeAsciiDoc (entry) {
99
+ const html = buildBetaBadge(entry)
100
+ return html ? `pass:[${html}]` : ''
101
+ }
102
+ const REGISTRY_FILENAME = 'enterprise-features.yml'
103
+ const VALID_SCOPES = ['redpanda', 'console', 'connect', 'operator', 'cloud']
104
+
105
+ const TABLE_TITLES = {
106
+ redpanda: 'Enterprise features in Redpanda',
107
+ console: 'Enterprise features in Redpanda Console',
108
+ connect: 'Enterprise features in Redpanda Connect',
109
+ operator: 'Enterprise features in the Redpanda Operator',
110
+ cloud: 'Enterprise features in Redpanda Cloud',
111
+ }
112
+
113
+ const THIRD_COLUMN_HEADINGS = {
114
+ redpanda: 'Behavior Upon Expiration',
115
+ default: 'Restrictions Without Valid License',
116
+ }
117
+
118
+ let warnedNoRegistry = false
119
+
120
+ /**
121
+ * Parse the registry YAML into a lookup structure. Exported for unit testing.
122
+ *
123
+ * @param {string} source - Raw YAML from enterprise-features.yml.
124
+ * @param {string} origin - Human-readable location for error messages.
125
+ * @returns {{features: object[], lookup: Map<string, object>}}
126
+ */
127
+ function parseRegistry (source, origin = REGISTRY_FILENAME) {
128
+ const data = yaml.load(source)
129
+ if (!data || !Array.isArray(data.features)) {
130
+ throw new Error(`Enterprise features registry ${origin} has no 'features' list.`)
131
+ }
132
+ const lookup = new Map()
133
+ const claim = (rawKey, feature, kind) => {
134
+ const key = rawKey.trim().toLowerCase()
135
+ if (!key) return
136
+ const existing = lookup.get(key)
137
+ if (existing && existing !== feature) {
138
+ throw new Error(`Duplicate enterprise feature ${kind} '${rawKey}' in ${origin}: used by both '${existing.name}' and '${feature.name}'.`)
139
+ }
140
+ lookup.set(key, feature)
141
+ }
142
+ for (const feature of data.features) {
143
+ if (!feature || !feature.name) {
144
+ throw new Error(`Enterprise features registry ${origin} has an entry without a name.`)
145
+ }
146
+ if (feature.scope && !VALID_SCOPES.includes(feature.scope)) {
147
+ throw new Error(`Enterprise feature '${feature.name}' in ${origin} has unknown scope '${feature.scope}'.`)
148
+ }
149
+ claim(feature.name, feature, 'name')
150
+ for (const alias of feature.aliases || []) claim(String(alias), feature, 'alias')
151
+ }
152
+ return { features: data.features, lookup }
153
+ }
154
+
155
+ /**
156
+ * Load and cache the registry from the shared component in the Antora
157
+ * content catalog. Returns undefined when no registry is available.
158
+ */
159
+ function loadRegistry (config) {
160
+ const contentCatalog = config && config.contentCatalog
161
+ if (!contentCatalog) return undefined
162
+ if (contentCatalog[$enterpriseRegistry] !== undefined) return contentCatalog[$enterpriseRegistry] || undefined
163
+ let registry = null
164
+ const partials = contentCatalog.findBy({ component: 'shared', module: 'ROOT', family: 'partial' }) || []
165
+ const registryFile = partials.find((file) => file.path && file.path.endsWith(REGISTRY_FILENAME))
166
+ if (registryFile) {
167
+ registry = parseRegistry(registryFile.contents.toString(), registryFile.path)
168
+ }
169
+ // Cache null too, so a missing registry is only searched for once per build.
170
+ contentCatalog[$enterpriseRegistry] = registry
171
+ return registry || undefined
172
+ }
173
+
174
+ function warnNoRegistry () {
175
+ if (warnedNoRegistry) return
176
+ warnedNoRegistry = true
177
+ console.warn(chalk.yellow(`Enterprise features registry (${REGISTRY_FILENAME} in the shared component) not found; enterprise: targets are not validated.`))
178
+ }
179
+
180
+ /**
181
+ * Report an unknown feature name according to the enterprise-validate mode.
182
+ */
183
+ function reportUnknownFeature ({ feature, mode, registry, filePath }) {
184
+ if (mode === 'off') return
185
+ const candidates = registry.features
186
+ .map((entry) => entry.name)
187
+ .filter((name) => name.toLowerCase().includes(feature.trim().toLowerCase().split(/\s+/)[0] || ''))
188
+ .slice(0, 3)
189
+ const hint = candidates.length ? ` Did you mean: ${candidates.join(', ')}?` : ''
190
+ const where = filePath ? ` in ${filePath}` : ''
191
+ const message = `enterprise:${feature}[] does not match any feature in the enterprise features registry${where}.${hint} Add the feature to ${REGISTRY_FILENAME} in the shared component first.`
192
+ if (mode === 'error') throw new Error(message)
193
+ console.warn(chalk.yellow(message))
194
+ }
195
+
196
+ /**
197
+ * Pick the environment-appropriate feature page from a registry entry.
198
+ * Some features have separate documentation for Kubernetes, Linux
199
+ * (self-managed), and Redpanda Cloud. Mirrors the config_ref macro's
200
+ * environment-awareness: the env-cloud and env-kubernetes page attributes
201
+ * select the xref-cloud and xref-kubernetes registry fields, falling back
202
+ * to the default xref.
203
+ *
204
+ * @param {object} entry - Registry entry.
205
+ * @param {object} document - Asciidoctor document (for env attributes).
206
+ * @returns {string|undefined}
207
+ */
208
+ function resolveEntryXref (entry, document) {
209
+ if (!entry) return undefined
210
+ if (document.getAttribute('env-cloud') !== undefined && entry['xref-cloud']) return entry['xref-cloud']
211
+ if (document.getAttribute('env-kubernetes') !== undefined && entry['xref-kubernetes']) return entry['xref-kubernetes']
212
+ return entry.xref
213
+ }
214
+
215
+ /**
216
+ * Resolve the tooltip attribute name from the enterprise-tooltip document
217
+ * attribute. Mirrors the glossary macro's contract.
218
+ *
219
+ * @param {string|undefined} raw - Raw attribute value.
220
+ * @returns {string|undefined} Attribute name to emit, or undefined when disabled.
221
+ */
222
+ function resolveTooltipAttribute (raw) {
223
+ if (raw === 'false') return undefined
224
+ if (raw === undefined || raw === 'title') return 'title'
225
+ if (raw === 'true') return 'data-enterprise-tooltip'
226
+ if (raw.startsWith('data-')) return raw
227
+ console.warn(`enterprise-tooltip attribute '${raw}' must be 'title', 'true', 'false', or start with 'data-'. Falling back to 'title'.`)
228
+ return 'title'
229
+ }
230
+
231
+ /**
232
+ * Build the AsciiDoc content emitted for one inline macro instance. Exported
233
+ * for unit testing.
234
+ *
235
+ * @param {object} opts
236
+ * @param {string} opts.feature - Canonical feature name.
237
+ * @param {string} [opts.text] - Display text override.
238
+ * @param {string} [opts.xref] - Resource ID of the feature documentation.
239
+ * @param {string} [opts.url] - Absolute link used when no xref exists.
240
+ * @param {string} [opts.tooltip] - Tooltip text override.
241
+ * @param {string} opts.licensingPage - Resource ID of the licensing page.
242
+ * @param {string} opts.role - CSS class for the wrapping span.
243
+ * @param {string|undefined} opts.tooltipAttr - Tooltip attribute name, or undefined to omit.
244
+ * @param {boolean} opts.links - Whether to render a link.
245
+ * @returns {string}
246
+ */
247
+ function buildEnterpriseContent ({ feature, text, xref, url, tooltip, licensingPage, role, tooltipAttr, links }) {
248
+ const display = text || feature
249
+ const tooltipText = tooltip || `${feature} requires an Enterprise Edition license.`
250
+ const escapedTooltip = tooltipText.replace(/"/g, '&quot;')
251
+ const tooltipHtml = tooltipAttr ? ` ${tooltipAttr}="${escapedTooltip}"` : ''
252
+ let inner = display
253
+ if (links) {
254
+ inner = xref ? `xref:${xref}[${display}]` : (url ? `link:${url}[${display}]` : `xref:${licensingPage}[${display}]`)
255
+ }
256
+ return `<span class="${role}"${tooltipHtml}>${inner}</span>`
257
+ }
258
+
259
+ /**
260
+ * Build the AsciiDoc source of the licensing feature table for one scope.
261
+ * Exported for unit testing.
262
+ *
263
+ * @param {object[]} features - All registry entries.
264
+ * @param {string} scope - Scope to render.
265
+ * @param {object} [opts]
266
+ * @param {string} [opts.title] - Table title override.
267
+ * @param {string} [opts.heading] - Third column heading override.
268
+ * @returns {string}
269
+ */
270
+ function buildFeatureTable (features, scope, opts = {}) {
271
+ const rows = features
272
+ .filter((feature) => feature.scope === scope)
273
+ .sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }))
274
+ const title = opts.title || TABLE_TITLES[scope]
275
+ const heading = opts.heading || THIRD_COLUMN_HEADINGS[scope] || THIRD_COLUMN_HEADINGS.default
276
+ const lines = [`.${title}`, '[cols="1a,2a,2a"]', '|===', `| Feature | Description | ${heading}`, '']
277
+ for (const feature of rows) {
278
+ let cell = feature.xref
279
+ ? `xref:${feature.xref}[${feature.name}]`
280
+ : (feature.url ? `link:${feature.url}[${feature.name}]` : feature.name)
281
+ if (feature['feature-suffix']) cell += ` ${feature['feature-suffix']}`
282
+ const betaBadge = buildBetaBadgeAsciiDoc(feature)
283
+ if (betaBadge) cell += ` ${betaBadge}`
284
+ if (feature['show-gating-property'] && feature['gating-property']) cell += `\n(\`${feature['gating-property']}\`)`
285
+ lines.push(`| ${cell}`)
286
+ lines.push(`| ${(feature.description || '').trim()}`)
287
+ lines.push(`| ${(feature.expiration || '').trim()}`)
288
+ lines.push('')
289
+ }
290
+ lines.push('|===')
291
+ return lines.join('\n')
292
+ }
293
+
294
+ function enterpriseInlineMacro (config) {
295
+ return function () {
296
+ const self = this
297
+ self.named('enterprise')
298
+ // Specifying the regexp allows spaces in the feature name.
299
+ self.$option('regexp', /enterprise:([^[]+)\[(|.*?[^\\])\]/)
300
+ self.process(function (parent, target, attributes) {
301
+ const document = parent.getDocument()
302
+ let registry
303
+ if (config && config.contentCatalog) {
304
+ registry = loadRegistry(config)
305
+ if (!registry) warnNoRegistry()
306
+ }
307
+ let feature = target
308
+ let entry
309
+ if (registry) {
310
+ entry = registry.lookup.get(target.trim().toLowerCase())
311
+ if (entry) {
312
+ feature = entry.name
313
+ } else {
314
+ reportUnknownFeature({
315
+ feature: target,
316
+ mode: document.getAttribute('enterprise-validate', 'warn'),
317
+ registry,
318
+ filePath: config && config.file && config.file.src && config.file.src.path,
319
+ })
320
+ }
321
+ }
322
+ let content = buildEnterpriseContent({
323
+ feature,
324
+ text: attributes.text,
325
+ xref: attributes.xref || resolveEntryXref(entry, document),
326
+ url: entry && entry.url,
327
+ tooltip: attributes.tooltip || (entry && entry.tooltip) || undefined,
328
+ licensingPage: document.getAttribute('enterprise-licensing-page', DEFAULT_LICENSING_PAGE),
329
+ role: document.getAttribute('enterprise-feature-role', DEFAULT_ROLE),
330
+ tooltipAttr: resolveTooltipAttribute(document.getAttribute('enterprise-tooltip')),
331
+ links: document.getAttribute('enterprise-links', 'true') === 'true',
332
+ })
333
+ // A feature marked beta in the registry is beta wherever it is
334
+ // referenced, so prose gets the same badge as the generated tables.
335
+ // Set enterprise-beta-badge to false to suppress it in prose only.
336
+ if (document.getAttribute('enterprise-beta-badge', 'true') === 'true') {
337
+ const betaBadge = buildBetaBadge(entry)
338
+ if (betaBadge) content += ` ${betaBadge}`
339
+ }
340
+ // The xref inside the span is resolved by the 'macros' substitution,
341
+ // the same mechanism the config_ref macro relies on.
342
+ return self.createInline(parent, 'quoted', content, { attributes: { subs: 'macros' } })
343
+ })
344
+ }
345
+ }
346
+
347
+ function enterpriseFeaturesBlockMacro (config) {
348
+ return function () {
349
+ const self = this
350
+ self.named('enterprise_features')
351
+ self.process(function (parent, target, attributes) {
352
+ const scope = (target || attributes.scope || '').trim()
353
+ if (!VALID_SCOPES.includes(scope)) {
354
+ throw new Error(`enterprise_features::[] needs a scope of ${VALID_SCOPES.join(', ')} as its target, got '${scope}'.`)
355
+ }
356
+ const registry = config && config.contentCatalog ? loadRegistry(config) : undefined
357
+ if (!registry) {
358
+ warnNoRegistry()
359
+ return self.parseContent(parent, `WARNING: The enterprise features registry is unavailable, so the ${scope} feature table cannot be rendered.`)
360
+ }
361
+ const table = buildFeatureTable(registry.features, scope, { title: attributes.title, heading: attributes.heading })
362
+ // A block anchor before the macro call ([[my-id]]) is consumed into the
363
+ // macro's id attribute. Re-emit it so crossrefs to the table keep working.
364
+ const source = attributes.id ? `[[${attributes.id}]]\n${table}` : table
365
+ return self.parseContent(parent, source)
366
+ })
367
+ }
368
+ }
369
+
370
+ function register (registry, config = {}) {
371
+ if (typeof registry.register === 'function') {
372
+ registry.register(function () {
373
+ this.inlineMacro(enterpriseInlineMacro(config))
374
+ this.blockMacro(enterpriseFeaturesBlockMacro(config))
375
+ })
376
+ } else if (typeof registry.inlineMacro === 'function') {
377
+ registry.inlineMacro(enterpriseInlineMacro(config))
378
+ if (typeof registry.blockMacro === 'function') registry.blockMacro(enterpriseFeaturesBlockMacro(config))
379
+ } else {
380
+ console.warn("no 'inlineMacro' method on alleged registry")
381
+ }
382
+ return registry
383
+ }
384
+
385
+ module.exports.register = register
386
+ module.exports.buildEnterpriseContent = buildEnterpriseContent
387
+ module.exports.buildFeatureTable = buildFeatureTable
388
+ module.exports.parseRegistry = parseRegistry
389
+ module.exports.resolveEntryXref = resolveEntryXref
390
+ module.exports.resolveTooltipAttribute = resolveTooltipAttribute