@redpanda-data/docs-extensions-and-macros 5.11.1 → 5.12.1

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.
@@ -55,16 +55,127 @@
55
55
 
56
56
  const yaml = require('js-yaml')
57
57
  const chalk = require('chalk')
58
- const { buildBadgeHtml } = require('./badge')
58
+ const { buildBadgeHtml, DEFAULT_TOOLTIPS } = require('./badge')
59
59
 
60
60
  const $enterpriseRegistry = Symbol('$enterpriseRegistry')
61
+ const $enterpriseRegistryUnreadable = Symbol('$enterpriseRegistryUnreadable')
62
+ const $warned = Symbol('$warned')
63
+
64
+ /**
65
+ * Report a message once per build. Keyed off the content catalog, which Antora
66
+ * rebuilds per build, so watch mode keeps reporting instead of going quiet.
67
+ */
68
+ function warnOnce (contentCatalog, key, message) {
69
+ const seen = contentCatalog[$warned] || (contentCatalog[$warned] = new Set())
70
+ if (seen.has(key)) return false
71
+ seen.add(key)
72
+ console.warn(chalk.yellow(message))
73
+ return true
74
+ }
61
75
 
62
76
  const DEFAULT_LICENSING_PAGE = 'get-started:licensing/overview.adoc'
63
77
  const DEFAULT_ROLE = 'enterprise-feature'
64
78
  const BETA_LABEL = 'beta'
79
+ const UNRELEASED_LABEL = 'unreleased'
80
+ // The badge macro supplies the default hover text for these labels, so the same
81
+ // wording appears whether a badge comes from the registry or from a
82
+ // badge::[label=beta] call in a page.
83
+ const UNRELEASED_TOOLTIP = DEFAULT_TOOLTIPS.unreleased
84
+
85
+ // Release status of a feature, which decides where it may be referenced.
86
+ //
87
+ // ga Shipped. Referenced anywhere. The default.
88
+ // beta Publicly available as a beta. Referenced anywhere, badged, so
89
+ // readers know the feature is not yet stable.
90
+ // unreleased In a release candidate only, not yet public. Referenced ONLY
91
+ // from prerelease docs (a beta branch). A mention on a released
92
+ // page describes something readers cannot get, so it is reported
93
+ // and rendered as plain text.
94
+ const STATUS_GA = 'ga'
95
+ const STATUS_BETA = 'beta'
96
+ const STATUS_UNRELEASED = 'unreleased'
97
+ const VALID_STATUSES = [STATUS_GA, STATUS_BETA, STATUS_UNRELEASED]
98
+
99
+ /**
100
+ * The release status of a registry entry.
101
+ *
102
+ * `beta: true` predates the status field and still means beta, so old entries
103
+ * keep working. An explicit status wins. An unrecognized status is reported AND
104
+ * treated as unreleased: a typo must not be the thing that publishes an
105
+ * unreleased feature. The two failure directions are not symmetric -- gating a
106
+ * shipped feature shows a writer a warning and an unstyled mention they will
107
+ * notice, while publishing an unreleased one promises readers a feature they
108
+ * cannot get, and nothing on the page looks wrong.
109
+ *
110
+ * @param {object} entry - Registry entry.
111
+ * @param {object} [report] - {mode, filePath} to report an invalid status.
112
+ * @returns {string} One of VALID_STATUSES.
113
+ */
114
+ function entryStatus (entry, report) {
115
+ if (!entry) return STATUS_GA
116
+ // Any present key goes through validation, including a blank value. An absent
117
+ // key means no status was intended, so GA is the right default; `status:` with
118
+ // nothing after it means the writer meant to say something and did not, which
119
+ // is the same mistake as a typo and must fail the same way. Treating blank as
120
+ // absent reopened the hole this function exists to close: a forgotten value
121
+ // published an unreleased feature with no warning at all.
122
+ if (entry.status !== undefined) {
123
+ const raw = entry.status === null ? '' : String(entry.status).trim()
124
+ const status = raw.toLowerCase()
125
+ if (VALID_STATUSES.includes(status)) return status
126
+ if (report && report.mode !== 'off') {
127
+ const where = report.filePath ? ` in ${report.filePath}` : ''
128
+ const what = raw === '' ? 'an empty status' : `status '${entry.status}'`
129
+ const message =
130
+ `enterprise:${entry.name}[]${where}: registry has ${what}, which is not one of ${VALID_STATUSES.join(', ')}, so the feature is treated as unreleased and is left unpublished on released pages. ` +
131
+ 'Fix the status in enterprise-features.yml, or remove the key entirely if the feature has shipped.'
132
+ // Honour error mode like every other registry diagnostic. A typo here is
133
+ // exactly what publishes an unreleased feature, so the strictest setting
134
+ // has to stop it.
135
+ if (report.mode === 'error') throw new Error(message)
136
+ // One registry typo needs one fix, so report it once per build rather
137
+ // than once per mention of the feature.
138
+ if (report.contentCatalog) warnOnce(report.contentCatalog, `status:${entry.name}`, message)
139
+ else console.warn(chalk.yellow(message))
140
+ }
141
+ return STATUS_UNRELEASED
142
+ }
143
+ return entry.beta === true ? STATUS_BETA : STATUS_GA
144
+ }
145
+
146
+ /**
147
+ * Whether the page being converted belongs to a prerelease component version,
148
+ * which is where an unreleased feature may be referenced.
149
+ *
150
+ * Read from the component version's own prerelease flag (antora.yml) rather
151
+ * than inferred, with a document attribute as an escape hatch for playbooks
152
+ * that surface it themselves.
153
+ */
154
+ function isPrereleasePage (config, document) {
155
+ const attribute = document && document.getAttribute('page-component-version-is-prerelease')
156
+ if (attribute !== undefined) {
157
+ const value = String(attribute).trim().toLowerCase()
158
+ if (value === 'true' || value === '') return true
159
+ if (value === 'false') return false
160
+ // Anything else is a typo. Falling through to the catalog is safer than
161
+ // guessing, and silently treating it as "released" produced a warning that
162
+ // told the writer their prerelease page belonged to a released version.
163
+ console.warn(chalk.yellow(
164
+ `enterprise macro: page-component-version-is-prerelease is '${attribute}', which is neither true nor false, so it is ignored and the component version's own prerelease flag is used instead.`
165
+ ))
166
+ }
167
+ const contentCatalog = config && config.contentCatalog
168
+ const src = config && config.file && config.file.src
169
+ if (!contentCatalog || !src || typeof contentCatalog.getComponent !== 'function') return false
170
+ const component = contentCatalog.getComponent(src.component)
171
+ if (!component) return false
172
+ const versions = Array.isArray(component.versions) ? component.versions : []
173
+ const componentVersion = versions.find((entry) => entry.version === src.version)
174
+ return Boolean(componentVersion && componentVersion.prerelease)
175
+ }
65
176
 
66
177
  /**
67
- * Render the beta badge for a registry entry marked `beta: true`.
178
+ * Render the release-status badge for a registry entry.
68
179
  *
69
180
  * The badge HTML is built directly rather than emitted as `badge:[...]`
70
181
  * AsciiDoc, so it renders whether or not the consuming playbook registers the
@@ -73,14 +184,31 @@ const BETA_LABEL = 'beta'
73
184
  * `badge::[label=beta]` in any build without that macro registered.
74
185
  *
75
186
  * @param {object} entry - Registry entry.
76
- * @returns {string} Badge HTML, or an empty string when the entry is not beta.
187
+ * @returns {string} Badge HTML, or an empty string for a shipped feature.
77
188
  */
78
189
  function buildBetaBadge (entry) {
79
- if (!entry || entry.beta !== true) return ''
80
- return buildBadgeHtml({
81
- label: BETA_LABEL,
82
- tooltip: entry['beta-tooltip'] || undefined,
83
- })
190
+ const status = entryStatus(entry)
191
+ if (status === STATUS_BETA) {
192
+ return buildBadgeHtml({ label: BETA_LABEL, tooltip: entry['beta-tooltip'] || undefined })
193
+ }
194
+ if (status === STATUS_UNRELEASED) {
195
+ return buildBadgeHtml({ label: UNRELEASED_LABEL, tooltip: entry['status-tooltip'] || UNRELEASED_TOOLTIP })
196
+ }
197
+ return ''
198
+ }
199
+
200
+ /**
201
+ * Report an unreleased feature referenced from released documentation.
202
+ */
203
+ function reportUnreleasedFeature ({ feature, mode, filePath }) {
204
+ if (mode === 'off') return
205
+ const where = filePath ? ` in ${filePath}` : ''
206
+ const message =
207
+ `enterprise:${feature}[]${where}: '${feature}' is marked status: unreleased in the enterprise features registry, so it is only documented for an upcoming release. ` +
208
+ 'This page belongs to a released version, so the mention renders as plain text with no enterprise styling, tooltip, or link. ' +
209
+ 'Move the mention to the prerelease (beta) branch, or change the status once the feature ships.'
210
+ if (mode === 'error') throw new Error(message)
211
+ console.warn(chalk.yellow(message))
84
212
  }
85
213
 
86
214
  /**
@@ -125,7 +253,14 @@ let warnedNoRegistry = false
125
253
  * @returns {{features: object[], lookup: Map<string, object>}}
126
254
  */
127
255
  function parseRegistry (source, origin = REGISTRY_FILENAME) {
128
- const data = yaml.load(source)
256
+ let data
257
+ try {
258
+ data = yaml.load(source)
259
+ } catch (error) {
260
+ // Every other throw below names the file, so this one does too: the caller
261
+ // then reports whatever it catches verbatim, and the path appears once.
262
+ throw new Error(`Enterprise features registry ${origin} is not valid YAML (${error.message}).`)
263
+ }
129
264
  if (!data || !Array.isArray(data.features)) {
130
265
  throw new Error(`Enterprise features registry ${origin} has no 'features' list.`)
131
266
  }
@@ -164,17 +299,44 @@ function loadRegistry (config) {
164
299
  const partials = contentCatalog.findBy({ component: 'shared', module: 'ROOT', family: 'partial' }) || []
165
300
  const registryFile = partials.find((file) => file.path && file.path.endsWith(REGISTRY_FILENAME))
166
301
  if (registryFile) {
167
- registry = parseRegistry(registryFile.contents.toString(), registryFile.path)
302
+ try {
303
+ registry = parseRegistry(registryFile.contents.toString(), registryFile.path)
304
+ } catch (error) {
305
+ // A malformed registry must not take the whole build down. Unvalidated
306
+ // enterprise mentions are the same graceful degradation as no registry at
307
+ // all, and this matches how prop.js treats a corrupt properties JSON.
308
+ // Caching the failure also stops the error being re-raised per macro call
309
+ // and attributed to whichever page happened to convert first.
310
+ registry = null
311
+ // Remember that the file was found and unreadable, so the caller does not
312
+ // then also report it missing. Saying "not found" about a file we just
313
+ // read and failed to parse sends the writer looking for the wrong problem.
314
+ contentCatalog[$enterpriseRegistryUnreadable] = true
315
+ // Report the error as thrown. It already names the file and says exactly
316
+ // what is wrong -- invalid YAML, a missing features list, an entry with no
317
+ // name, an unknown scope, or a duplicate name or alias. Wrapping it in
318
+ // "could not be read" was inaccurate for all but the first (the file read
319
+ // fine; its contents are invalid) and printed the path twice.
320
+ warnOnce(contentCatalog, 'badregistry',
321
+ `${error.message} No enterprise: target is validated, no feature is gated by release status, and the licensing tables are empty until this is fixed.`)
322
+ }
168
323
  }
169
324
  // Cache null too, so a missing registry is only searched for once per build.
170
325
  contentCatalog[$enterpriseRegistry] = registry
171
326
  return registry || undefined
172
327
  }
173
328
 
174
- function warnNoRegistry () {
329
+ function warnNoRegistry (contentCatalog) {
330
+ // Already reported as unreadable: one problem, one diagnostic.
331
+ if (contentCatalog && contentCatalog[$enterpriseRegistryUnreadable]) return
332
+ const message = "Enterprise features registry (enterprise-features.yml in the 'shared' component) not found; enterprise: targets are not validated."
333
+ // Deduplicate per build, not per process: Antora's watch mode reuses one
334
+ // process across builds, and a module-level guard reported this only on a
335
+ // session's first build -- exactly when a writer is iterating.
336
+ if (contentCatalog) return warnOnce(contentCatalog, 'noregistry', message)
175
337
  if (warnedNoRegistry) return
176
338
  warnedNoRegistry = true
177
- console.warn(chalk.yellow(`Enterprise features registry (${REGISTRY_FILENAME} in the shared component) not found; enterprise: targets are not validated.`))
339
+ console.warn(chalk.yellow(message))
178
340
  }
179
341
 
180
342
  /**
@@ -247,7 +409,16 @@ function resolveTooltipAttribute (raw) {
247
409
  function buildEnterpriseContent ({ feature, text, xref, url, tooltip, licensingPage, role, tooltipAttr, links }) {
248
410
  const display = text || feature
249
411
  const tooltipText = tooltip || `${feature} requires an Enterprise Edition license.`
250
- const escapedTooltip = tooltipText.replace(/"/g, '&quot;')
412
+ // All four, matching badge.js. Escaping only the quote kept the attribute
413
+ // intact but let < and > through from a registry tooltip field, which any
414
+ // consumer that re-parses the page then reads as markup -- the docs UI
415
+ // promotes this attribute into a tooltip, the Markdown converter and the
416
+ // search indexer both re-read it.
417
+ const escapedTooltip = tooltipText
418
+ .replace(/&/g, '&amp;')
419
+ .replace(/"/g, '&quot;')
420
+ .replace(/</g, '&lt;')
421
+ .replace(/>/g, '&gt;')
251
422
  const tooltipHtml = tooltipAttr ? ` ${tooltipAttr}="${escapedTooltip}"` : ''
252
423
  let inner = display
253
424
  if (links) {
@@ -268,8 +439,25 @@ function buildEnterpriseContent ({ feature, text, xref, url, tooltip, licensingP
268
439
  * @returns {string}
269
440
  */
270
441
  function buildFeatureTable (features, scope, opts = {}) {
442
+ // On released docs an unreleased feature is omitted entirely: the licensing
443
+ // table is a list of what a licence covers today, and listing something
444
+ // unavailable makes it wrong. Prerelease docs list it with its badge.
445
+ const includeUnreleased = opts.includeUnreleased === true
446
+ // Report a bad status from here too. Without a reporter a typo'd status was
447
+ // silently downgraded to released, so an unreleased feature that no page
448
+ // happens to reference inline was published in the table with no diagnostic.
449
+ const report = opts.report
271
450
  const rows = features
272
451
  .filter((feature) => feature.scope === scope)
452
+ // Evaluate the status first: `includeUnreleased || entryStatus(...)` skipped
453
+ // the call entirely on a prerelease page, so a typo'd status went unreported
454
+ // on the beta branch -- the one branch where unreleased features are
455
+ // actually authored and the table renders them -- and enterprise-validate=error
456
+ // did not fail there either.
457
+ .filter((feature) => {
458
+ const status = entryStatus(feature, report)
459
+ return includeUnreleased || status !== STATUS_UNRELEASED
460
+ })
273
461
  .sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }))
274
462
  const title = opts.title || TABLE_TITLES[scope]
275
463
  const heading = opts.heading || THIRD_COLUMN_HEADINGS[scope] || THIRD_COLUMN_HEADINGS.default
@@ -302,7 +490,7 @@ function enterpriseInlineMacro (config) {
302
490
  let registry
303
491
  if (config && config.contentCatalog) {
304
492
  registry = loadRegistry(config)
305
- if (!registry) warnNoRegistry()
493
+ if (!registry) warnNoRegistry(config && config.contentCatalog)
306
494
  }
307
495
  let feature = target
308
496
  let entry
@@ -319,6 +507,16 @@ function enterpriseInlineMacro (config) {
319
507
  })
320
508
  }
321
509
  }
510
+ const mode = document.getAttribute('enterprise-validate', 'warn')
511
+ const filePath = config && config.file && config.file.src && config.file.src.path
512
+ const status = entryStatus(entry, { mode, filePath, contentCatalog: config && config.contentCatalog })
513
+ if (status === STATUS_UNRELEASED && !isPrereleasePage(config, document)) {
514
+ // Documented for an upcoming release, referenced from released docs.
515
+ // Styling it as an available enterprise feature would promise readers
516
+ // something they cannot get, so render the name and say so.
517
+ reportUnreleasedFeature({ feature, mode, filePath })
518
+ return self.createInline(parent, 'quoted', attributes.text || feature)
519
+ }
322
520
  let content = buildEnterpriseContent({
323
521
  feature,
324
522
  text: attributes.text,
@@ -330,12 +528,13 @@ function enterpriseInlineMacro (config) {
330
528
  tooltipAttr: resolveTooltipAttribute(document.getAttribute('enterprise-tooltip')),
331
529
  links: document.getAttribute('enterprise-links', 'true') === 'true',
332
530
  })
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}`
531
+ // A feature's status holds wherever it is referenced, so prose gets the
532
+ // same badge as the generated tables. enterprise-beta-badge=false
533
+ // suppresses the beta badge in prose; the unreleased badge is not
534
+ // suppressible, because "not in a release yet" is not decoration.
535
+ if (status === STATUS_UNRELEASED || document.getAttribute('enterprise-beta-badge', 'true') === 'true') {
536
+ const badge = buildBetaBadge(entry)
537
+ if (badge) content += ` ${badge}`
339
538
  }
340
539
  // The xref inside the span is resolved by the 'macros' substitution,
341
540
  // the same mechanism the config_ref macro relies on.
@@ -355,10 +554,21 @@ function enterpriseFeaturesBlockMacro (config) {
355
554
  }
356
555
  const registry = config && config.contentCatalog ? loadRegistry(config) : undefined
357
556
  if (!registry) {
358
- warnNoRegistry()
557
+ warnNoRegistry(config && config.contentCatalog)
359
558
  return self.parseContent(parent, `WARNING: The enterprise features registry is unavailable, so the ${scope} feature table cannot be rendered.`)
360
559
  }
361
- const table = buildFeatureTable(registry.features, scope, { title: attributes.title, heading: attributes.heading })
560
+ const table = buildFeatureTable(registry.features, scope, {
561
+ title: attributes.title,
562
+ heading: attributes.heading,
563
+ report: {
564
+ mode: parent.getDocument().getAttribute('enterprise-validate', 'warn'),
565
+ filePath: config && config.file && config.file.src && config.file.src.path,
566
+ contentCatalog: config && config.contentCatalog,
567
+ },
568
+ // Prerelease docs describe the upcoming release, so they list features
569
+ // that are still only in a release candidate.
570
+ includeUnreleased: isPrereleasePage(config, parent.getDocument()),
571
+ })
362
572
  // A block anchor before the macro call ([[my-id]]) is consumed into the
363
573
  // macro's id attribute. Re-emit it so crossrefs to the table keep working.
364
574
  const source = attributes.id ? `[[${attributes.id}]]\n${table}` : table