@redpanda-data/docs-extensions-and-macros 5.13.4 → 5.14.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 CHANGED
@@ -529,6 +529,103 @@ programCli
529
529
  }
530
530
  })
531
531
 
532
+ /**
533
+ * @description Copy this package's docs-data/*.schema.json files into a
534
+ * content repo's own docs-data/ (the file that documents docs-data/x.json
535
+ * lives alongside x.json itself, so a writer can change both in one PR
536
+ * without waiting on a package release first). Run this after bumping
537
+ * @redpanda-data/docs-extensions-and-macros so the local copy picks up
538
+ * whatever changed upstream.
539
+ * @why Nothing keeps a content repo's schema copy in sync with this
540
+ * package's copy today, in either direction. A content repo's copy can
541
+ * legitimately be ahead of this package's (confirmed happening in
542
+ * practice: the docs repo's rpk-overrides.schema.json documents a real,
543
+ * working `asPartial` override field that this package's own copy is
544
+ * missing) — overwriting it in that case would delete real, correct
545
+ * documentation, not fix drift. This only overwrites when this package's
546
+ * copy is a strict superset of the destination; otherwise it reports which
547
+ * keys only the destination has and leaves the file alone (status
548
+ * 'diverged') unless --force is passed. --check never writes, for a CI
549
+ * gate.
550
+ * @example
551
+ * # Sync into ./docs-data (writes any missing or out-of-date schema)
552
+ * npx doc-tools sync-schemas
553
+ *
554
+ * # Report drift without writing -- exits 1 if anything is out of sync
555
+ * npx doc-tools sync-schemas --check
556
+ *
557
+ * # Sync into a non-default location
558
+ * npx doc-tools sync-schemas --dest path/to/docs-data
559
+ *
560
+ * # Overwrite even a destination that has diverged (rarely correct --
561
+ * # read the 'diverged' output first; it names what would be lost)
562
+ * npx doc-tools sync-schemas --force
563
+ * @requirements None.
564
+ */
565
+ programCli
566
+ .command('sync-schemas')
567
+ .description("Sync this package's docs-data/*.schema.json into a content repo's docs-data/")
568
+ .option('--dest <path>', 'Destination docs-data directory', 'docs-data')
569
+ .option('--check', 'Report drift without writing; exit 1 if any schema is missing, out of date, or diverged')
570
+ .option('--force', 'Overwrite even a destination that has diverged (has content this package lacks)')
571
+ .action((options) => {
572
+ const { syncSchemas } = require('../cli-utils/sync-schemas')
573
+
574
+ try {
575
+ const { destDir, results, drift } = syncSchemas({
576
+ destDir: options.dest,
577
+ check: Boolean(options.check),
578
+ force: Boolean(options.force),
579
+ })
580
+
581
+ if (results.length === 0) {
582
+ console.log('No *.schema.json files found in this package to sync.')
583
+ process.exit(0)
584
+ }
585
+
586
+ console.log(`${options.check ? 'Checking' : 'Syncing'} against: ${destDir}`)
587
+ console.log('')
588
+ let hasUnresolvedDivergence = false
589
+ for (const { name, status, destOnlyPaths } of results) {
590
+ const label = {
591
+ unchanged: '✓ up to date',
592
+ created: '+ created',
593
+ updated: '↻ updated',
594
+ diverged: options.force ? '↻ updated (forced)' : '⚠ diverged, left alone',
595
+ }[status]
596
+ console.log(` ${label} ${name}`)
597
+ if (status === 'diverged' && !options.force) {
598
+ hasUnresolvedDivergence = true
599
+ for (const p of destOnlyPaths) console.log(` only in the destination: ${p}`)
600
+ }
601
+ }
602
+ console.log('')
603
+
604
+ if (options.check) {
605
+ if (drift) {
606
+ const hint = hasUnresolvedDivergence
607
+ ? 'A diverged file needs a human decision (see paths above), not just a re-run.'
608
+ : 'Run `npx doc-tools sync-schemas` to fix.'
609
+ console.log(`✗ One or more schemas are out of date. ${hint}`)
610
+ process.exit(1)
611
+ }
612
+ console.log('✓ All schemas are in sync.')
613
+ process.exit(0)
614
+ }
615
+
616
+ if (hasUnresolvedDivergence) {
617
+ console.log('⚠ Synced what was safe to sync. One or more files diverged and were left alone -- see paths above. Re-run with --force only after confirming the destination-only content should be lost.')
618
+ process.exit(1)
619
+ }
620
+
621
+ console.log(drift ? '✓ Synced.' : '✓ Already in sync -- nothing to do.')
622
+ process.exit(0)
623
+ } catch (err) {
624
+ console.error(`Error: ${err.message}`)
625
+ process.exit(1)
626
+ }
627
+ })
628
+
532
629
  /**
533
630
  * @description Show MCP server version information including available tools
534
631
  * and optionally usage statistics from previous sessions.
@@ -0,0 +1,144 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { isDeepStrictEqual } = require('util')
6
+
7
+ /**
8
+ * Schemas that document docs-data/*.json files (rpk-overrides.schema.json,
9
+ * property-overrides.schema.json, ...) are meant to live inside each content
10
+ * repo's own docs-data/, alongside the file they document — a writer can
11
+ * change both the override data and its schema in one PR, without waiting on
12
+ * a new @redpanda-data/docs-extensions-and-macros release first.
13
+ *
14
+ * That's exactly why this package's copy is not automatically authoritative:
15
+ * a content repo's copy can legitimately be AHEAD of this package's, when a
16
+ * writer documented a real override field here before anyone remembered to
17
+ * update the schema in this package. Confirmed happening in practice —
18
+ * docs-data/rpk-overrides.schema.json in this package is missing `asPartial`,
19
+ * a real field the generator (tools/rpk-docs/generate-rpk-docs.js) already
20
+ * reads, that the docs repo's copy documents correctly. A naive "package
21
+ * always wins" sync would have silently deleted that the first time someone
22
+ * ran it. So syncSchemas() only ever overwrites a destination file when this
23
+ * package's copy is a superset of it (every object key the destination has,
24
+ * the source also has) — otherwise it reports the destination-only keys and
25
+ * refuses, same as `check` mode, unless the caller passes `force: true`.
26
+ */
27
+
28
+ const PACKAGE_SCHEMA_DIR = path.resolve(__dirname, '..', 'docs-data')
29
+
30
+ /**
31
+ * List the *.schema.json files this package ships.
32
+ * @returns {Array<{name: string, sourcePath: string}>}
33
+ */
34
+ function listPackageSchemas () {
35
+ if (!fs.existsSync(PACKAGE_SCHEMA_DIR)) return []
36
+ return fs.readdirSync(PACKAGE_SCHEMA_DIR)
37
+ .filter((name) => name.endsWith('.schema.json'))
38
+ .sort()
39
+ .map((name) => ({ name, sourcePath: path.join(PACKAGE_SCHEMA_DIR, name) }))
40
+ }
41
+
42
+ /**
43
+ * Find object keys present in `dest` but absent from the same path in
44
+ * `source` — the shape of "the destination knows something the source
45
+ * doesn't". Arrays are compared as opaque leaves (JSON Schema's own
46
+ * structure keeps named, distinguishable content in objects — `properties`,
47
+ * `$defs` — not in arrays like `required`/`enum`, so this is where a
48
+ * destination-only capability would actually show up).
49
+ *
50
+ * @param {*} source
51
+ * @param {*} dest
52
+ * @param {string} [pathPrefix]
53
+ * @returns {string[]} Dotted paths that exist in dest but not source.
54
+ */
55
+ function findDestOnlyPaths (source, dest, pathPrefix = '') {
56
+ if (
57
+ dest === null || typeof dest !== 'object' || Array.isArray(dest) ||
58
+ source === null || typeof source !== 'object' || Array.isArray(source)
59
+ ) {
60
+ return []
61
+ }
62
+
63
+ const onlyInDest = []
64
+ for (const [key, destValue] of Object.entries(dest)) {
65
+ const keyPath = pathPrefix ? `${pathPrefix}.${key}` : key
66
+ if (!Object.hasOwn(source, key)) {
67
+ onlyInDest.push(keyPath)
68
+ continue
69
+ }
70
+ onlyInDest.push(...findDestOnlyPaths(source[key], destValue, keyPath))
71
+ }
72
+ return onlyInDest
73
+ }
74
+
75
+ /**
76
+ * Compare this package's schemas against the copies in a destination
77
+ * directory (a content repo's docs-data/), and optionally write updates.
78
+ *
79
+ * @param {object} [opts]
80
+ * @param {string} [opts.destDir] - Directory to sync into. Defaults to
81
+ * docs-data/ under the current repo root.
82
+ * @param {boolean} [opts.check=false] - When true, never writes — only
83
+ * reports what's missing or out of date. Use this in CI.
84
+ * @param {boolean} [opts.force=false] - Overwrite a destination file even
85
+ * when it has content this package's copy lacks. Rarely correct — see the
86
+ * module doc comment. Ignored when `check` is true.
87
+ * @returns {{
88
+ * destDir: string,
89
+ * results: Array<{
90
+ * name: string,
91
+ * status: ('created'|'updated'|'unchanged'|'diverged'),
92
+ * sourcePath: string,
93
+ * destPath: string,
94
+ * destOnlyPaths?: string[]
95
+ * }>,
96
+ * drift: boolean
97
+ * }} drift is true when any schema is missing, differs, or has diverged.
98
+ * status 'diverged' means the destination has content this package's copy
99
+ * doesn't — nothing was written for that file unless `force` was set.
100
+ */
101
+ function syncSchemas ({ destDir, check = false, force = false } = {}) {
102
+ const resolvedDest = path.resolve(destDir || 'docs-data')
103
+ const schemas = listPackageSchemas()
104
+
105
+ const results = schemas.map(({ name, sourcePath }) => {
106
+ const destPath = path.join(resolvedDest, name)
107
+ const sourceContent = fs.readFileSync(sourcePath, 'utf8')
108
+ const destExists = fs.existsSync(destPath)
109
+ const destContent = destExists ? fs.readFileSync(destPath, 'utf8') : null
110
+
111
+ let status
112
+ let destOnlyPaths
113
+ if (!destExists) {
114
+ status = 'created'
115
+ } else {
116
+ // Compare parsed content, not raw text — a destination reformatted by a
117
+ // content repo's own prettier/editorconfig (different indent, key
118
+ // order, trailing newline) with no real data change must not read as
119
+ // drift.
120
+ const sourceParsed = JSON.parse(sourceContent)
121
+ const destParsed = JSON.parse(destContent)
122
+ if (isDeepStrictEqual(sourceParsed, destParsed)) {
123
+ status = 'unchanged'
124
+ } else {
125
+ destOnlyPaths = findDestOnlyPaths(sourceParsed, destParsed)
126
+ status = destOnlyPaths.length > 0 ? 'diverged' : 'updated'
127
+ }
128
+ }
129
+
130
+ const shouldWrite = !check && (status === 'created' || status === 'updated' || (status === 'diverged' && force))
131
+ if (shouldWrite) {
132
+ fs.mkdirSync(resolvedDest, { recursive: true })
133
+ fs.writeFileSync(destPath, sourceContent)
134
+ }
135
+
136
+ return { name, status, sourcePath, destPath, ...(destOnlyPaths ? { destOnlyPaths } : {}) }
137
+ })
138
+
139
+ const drift = results.some((r) => r.status !== 'unchanged')
140
+
141
+ return { destDir: resolvedDest, results, drift }
142
+ }
143
+
144
+ module.exports = { listPackageSchemas, syncSchemas, findDestOnlyPaths, PACKAGE_SCHEMA_DIR }
@@ -137,6 +137,10 @@
137
137
  "type": "boolean",
138
138
  "description": "If true, exclude this command from documentation entirely. Use for internal or hidden commands that shouldn't be documented."
139
139
  },
140
+ "asPartial": {
141
+ "type": "boolean",
142
+ "description": "If true, write this command's .adoc file to the partials directory instead of the pages directory. Inherited by child commands — set on a parent command (e.g., 'rpk ai') to route the entire subtree to partials."
143
+ },
140
144
  "excludeFlags": {
141
145
  "type": "array",
142
146
  "description": "List of flag names to exclude from documentation (without dashes)",
@@ -31,6 +31,12 @@
31
31
  * registry is available (for example, outside an Antora build), the macros
32
32
  * fall back to unvalidated rendering with a single warning.
33
33
  *
34
+ * A registry entry's `since` field, when set, is checked against the page
35
+ * being converted, not the newest release: a mention or table row for a
36
+ * feature whose `since` postdates the page's own version renders unstyled,
37
+ * the same way an unreleased feature renders on released docs, because as
38
+ * of that page's version the feature had not shipped yet.
39
+ *
34
40
  * Document or site attributes:
35
41
  *
36
42
  * enterprise-validate 'warn' (default) to log unknown feature names,
@@ -55,6 +61,7 @@
55
61
 
56
62
  const yaml = require('js-yaml')
57
63
  const chalk = require('chalk')
64
+ const semver = require('semver')
58
65
  const { buildBadgeHtml, DEFAULT_TOOLTIPS } = require('./badge')
59
66
 
60
67
  const $enterpriseRegistry = Symbol('$enterpriseRegistry')
@@ -211,6 +218,81 @@ function reportUnreleasedFeature ({ feature, mode, filePath }) {
211
218
  console.warn(chalk.yellow(message))
212
219
  }
213
220
 
221
+ /**
222
+ * Parse a version string into a semver for comparison, or undefined when it
223
+ * cannot be parsed. Lenient the same way `semver.coerce` is: Redpanda docs
224
+ * versions are "MAJOR.MINOR", not full semver, so an exact-match parse would
225
+ * reject every one of them.
226
+ */
227
+ function coerceVersion (raw) {
228
+ if (raw === undefined || raw === null) return undefined
229
+ return semver.coerce(String(raw)) || undefined
230
+ }
231
+
232
+ /**
233
+ * Whether a registry entry's `since` version has shipped as of the page
234
+ * being converted.
235
+ *
236
+ * enterprise-features.yml lives in the non-versioned 'shared' component, so
237
+ * every version branch reads the exact same entry -- unlike property data,
238
+ * which has one JSON file per release and is checked against the page's
239
+ * own version (see PROPERTY_AND_ENTERPRISE_REFERENCES.adoc). Without this
240
+ * check, a feature that ships in 26.3 renders as fully available -- linked,
241
+ * badge-free, no warning -- on a 24.1 page that predates it by two years.
242
+ *
243
+ * Returns true (shipped) when `since` is absent, unparsable, or the page's
244
+ * own version cannot be determined. That default favors data quality over
245
+ * release-readiness: unlike an unrecognized `status`, which fails closed
246
+ * because it is the one mistake that could publish an unreleased feature,
247
+ * a `since` typo or a build with no page-version context has no such
248
+ * asymmetry to protect against, so it defaults to not gating rather than
249
+ * hiding a shipped feature over unrelated bad data. An unparsable `since`
250
+ * is still reported, the same way an unrecognized `status` is: the failure
251
+ * mode differs (not gated, instead of gated), but a silent typo is exactly
252
+ * as easy to miss either way.
253
+ *
254
+ * @param {object} entry - Registry entry.
255
+ * @param {object} [config] - Extension config, used to read the page's own
256
+ * version.
257
+ * @param {object} [report] - {mode, filePath, contentCatalog} to report an
258
+ * unparsable since value. Omit to check silently.
259
+ */
260
+ function isFeatureShippedOnPage (entry, config, report) {
261
+ const since = entry && entry.since
262
+ if (since === undefined || since === null || String(since).trim() === '') return true
263
+ const sinceVersion = coerceVersion(since)
264
+ if (!sinceVersion) {
265
+ if (report && report.mode !== 'off') {
266
+ const where = report.filePath ? ` in ${report.filePath}` : ''
267
+ const message =
268
+ `enterprise:${entry.name}[]${where}: registry has since: '${since}', which is not a parsable version, so the since-gating check is skipped and the feature renders as already shipped. ` +
269
+ 'Fix the since value in enterprise-features.yml (e.g. "26.3"), or remove the key if the feature has always been available.'
270
+ if (report.contentCatalog) warnOnce(report.contentCatalog, `since:${entry.name}`, message)
271
+ else console.warn(chalk.yellow(message))
272
+ }
273
+ return true
274
+ }
275
+ const src = config && config.file && config.file.src
276
+ const pageVersion = src && coerceVersion(src.version)
277
+ if (!pageVersion) return true
278
+ return semver.gte(pageVersion, sinceVersion)
279
+ }
280
+
281
+ /**
282
+ * Report an inline mention of a feature before its `since` version, on a
283
+ * page whose own version predates it.
284
+ */
285
+ function reportFeatureNotYetShipped ({ feature, since, pageVersion, mode, filePath }) {
286
+ if (mode === 'off') return
287
+ const where = filePath ? ` in ${filePath}` : ''
288
+ const message =
289
+ `enterprise:${feature}[]${where}: '${feature}' is marked since: ${since} in the enterprise features registry, but this page is version ${pageVersion}, which predates it. ` +
290
+ 'The mention renders as plain text with no enterprise styling, tooltip, or link. ' +
291
+ 'Move the mention to a page whose version is at or after the since version, or remove since if the feature covers this version too.'
292
+ if (mode === 'error') throw new Error(message)
293
+ console.warn(chalk.yellow(message))
294
+ }
295
+
214
296
  /**
215
297
  * The badge for a registry entry, wrapped for use in generated AsciiDoc.
216
298
  *
@@ -436,6 +518,8 @@ function buildEnterpriseContent ({ feature, text, xref, url, tooltip, licensingP
436
518
  * @param {object} [opts]
437
519
  * @param {string} [opts.title] - Table title override.
438
520
  * @param {string} [opts.heading] - Third column heading override.
521
+ * @param {object} [opts.config] - Extension config, used to read the page's
522
+ * own version for since-gating (see isFeatureShippedOnPage).
439
523
  * @returns {string}
440
524
  */
441
525
  function buildFeatureTable (features, scope, opts = {}) {
@@ -456,7 +540,13 @@ function buildFeatureTable (features, scope, opts = {}) {
456
540
  // did not fail there either.
457
541
  .filter((feature) => {
458
542
  const status = entryStatus(feature, report)
459
- return includeUnreleased || status !== STATUS_UNRELEASED
543
+ if (!includeUnreleased && status === STATUS_UNRELEASED) return false
544
+ // since only describes a shipped feature's first version, so it does
545
+ // not apply to one that has not released at all yet. Silent, like the
546
+ // unreleased filter above: an older version's table naturally does not
547
+ // list a feature that did not exist yet on that version, which is not
548
+ // a writer mistake worth warning about.
549
+ return status === STATUS_UNRELEASED || isFeatureShippedOnPage(feature, opts.config, report)
460
550
  })
461
551
  .sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }))
462
552
  const title = opts.title || TABLE_TITLES[scope]
@@ -517,6 +607,18 @@ function enterpriseInlineMacro (config) {
517
607
  reportUnreleasedFeature({ feature, mode, filePath })
518
608
  return self.createInline(parent, 'quoted', attributes.text || feature)
519
609
  }
610
+ if (!isFeatureShippedOnPage(entry, config, { mode, filePath, contentCatalog: config && config.contentCatalog })) {
611
+ // isFeatureShippedOnPage only returns false when entry.since parsed,
612
+ // so entry is defined here.
613
+ reportFeatureNotYetShipped({
614
+ feature,
615
+ since: entry.since,
616
+ pageVersion: config && config.file && config.file.src && config.file.src.version,
617
+ mode,
618
+ filePath,
619
+ })
620
+ return self.createInline(parent, 'quoted', attributes.text || feature)
621
+ }
520
622
  let content = buildEnterpriseContent({
521
623
  feature,
522
624
  text: attributes.text,
@@ -568,6 +670,7 @@ function enterpriseFeaturesBlockMacro (config) {
568
670
  // Prerelease docs describe the upcoming release, so they list features
569
671
  // that are still only in a release candidate.
570
672
  includeUnreleased: isPrereleasePage(config, parent.getDocument()),
673
+ config,
571
674
  })
572
675
  // A block anchor before the macro call ([[my-id]]) is consumed into the
573
676
  // macro's id attribute. Re-emit it so crossrefs to the table keep working.
@@ -598,3 +701,4 @@ module.exports.buildFeatureTable = buildFeatureTable
598
701
  module.exports.parseRegistry = parseRegistry
599
702
  module.exports.resolveEntryXref = resolveEntryXref
600
703
  module.exports.resolveTooltipAttribute = resolveTooltipAttribute
704
+ module.exports.isFeatureShippedOnPage = isFeatureShippedOnPage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.13.4",
3
+ "version": "5.14.0",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  const yaml = require('js-yaml')
15
+ const semver = require('semver')
15
16
 
16
17
  const VALID_SCOPES = ['redpanda', 'console', 'connect', 'operator', 'cloud']
17
18
  const SOURCE_KINDS = ['core-enum', 'core-property', 'connect-plugin', 'manual']
@@ -58,6 +59,14 @@ function lintRegistry (source) {
58
59
  } else if (!feature.source.value || !String(feature.source.value).trim()) {
59
60
  findings.push(finding('error', 'registry-lint', `'${name}' has an empty source value; manual entries need a justification.`))
60
61
  }
62
+ if (feature.since !== undefined && feature.since !== null && String(feature.since).trim() !== '') {
63
+ const since = String(feature.since).trim()
64
+ // Lenient the same way the enterprise macro's own coerceVersion is:
65
+ // docs versions are "MAJOR.MINOR", not full semver.
66
+ if (!semver.coerce(since)) {
67
+ findings.push(finding('error', 'registry-lint', `'${name}' has since '${since}', which is not a parsable version.`))
68
+ }
69
+ }
61
70
  }
62
71
  return { features: data.features, findings }
63
72
  }