@redpanda-data/docs-extensions-and-macros 5.2.5 → 5.3.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.
package/bin/doc-tools.js CHANGED
@@ -1109,6 +1109,17 @@ automation
1109
1109
  .option('-r, --ref <ref>', 'Git branch or tag to document (e.g., dev, v26.2.0). Clones from GitHub.')
1110
1110
  .option('--from-source <path>', 'Path to local rpk source (src/go/rpk directory)')
1111
1111
  .option('--from-json <path>', 'Regenerate docs from an existing versioned JSON file (skips building)')
1112
+ .option('--plugin <name>', 'Refresh a single rpk plugin\'s docs (ai, connect, k8s, check). Requires --from-json. Installs the plugin, splices its fresh subtree into the snapshot, and re-renders.')
1113
+ .option('--plugin-version <version>', 'Plugin version to install and record (for example, 4.102.0). Defaults to the latest published version.')
1114
+ .option('--plugin-pin <name=version>', 'Pin a plugin version for the installs during full generation (repeatable, for example --plugin-pin k8s=26.3.1-beta.1). Required for pre-GA plugins with no promoted latest version.', (value, pins) => {
1115
+ const eq = value.indexOf('=')
1116
+ if (eq < 1 || eq === value.length - 1) {
1117
+ throw new Error(`Invalid --plugin-pin '${value}': expected <name>=<version>`)
1118
+ }
1119
+ pins[value.slice(0, eq)] = value.slice(eq + 1)
1120
+ return pins
1121
+ }, {})
1122
+ .option('--rpk-bin <path>', 'Path to an existing rpk binary for the plugin refresh (skips download/build)')
1112
1123
  .option('--overrides <path>', 'Path to overrides JSON file', 'docs-data/rpk-overrides.json')
1113
1124
  .option('--diff <oldVersion>', 'Generate diff against previous version')
1114
1125
  .option('--update-whats-new [path]', 'Update what\'s-new file with rpk changes from diff (default: modules/get-started/pages/release-notes/redpanda.adoc)')
@@ -1124,6 +1135,12 @@ automation
1124
1135
  try {
1125
1136
  const { handleRpkDocsGeneration } = require('../tools/rpk-docs/rpk-docs-handler.js')
1126
1137
 
1138
+ if (options.plugin && !options.fromJson) {
1139
+ console.error('Error: --plugin requires --from-json <snapshot>')
1140
+ console.error('A plugin refresh splices the fresh subtree into an existing committed snapshot.')
1141
+ process.exit(1)
1142
+ }
1143
+
1127
1144
  // Handle --update-whats-new with optional path
1128
1145
  let whatsNewPath = null
1129
1146
  if (options.updateWhatsNew !== undefined) {
@@ -1137,6 +1154,10 @@ automation
1137
1154
  ref: options.ref,
1138
1155
  fromSource: options.fromSource,
1139
1156
  fromJson: options.fromJson,
1157
+ plugin: options.plugin,
1158
+ pluginVersion: options.pluginVersion,
1159
+ pluginPins: options.pluginPin,
1160
+ rpkBin: options.rpkBin,
1140
1161
  overrides: options.overrides,
1141
1162
  diff: options.diff,
1142
1163
  updateWhatsNew: whatsNewPath,
@@ -1150,6 +1171,10 @@ automation
1150
1171
  })
1151
1172
 
1152
1173
  if (result.success) {
1174
+ if (result.skipped) {
1175
+ console.log(`\n✓ Skipped: ${result.reason}`)
1176
+ process.exit(0)
1177
+ }
1153
1178
  console.log('\n✓ rpk documentation generated successfully')
1154
1179
 
1155
1180
  // Write PR summary to file if requested (useful for GitHub Actions)
@@ -1175,6 +1200,140 @@ automation
1175
1200
  }
1176
1201
  })
1177
1202
 
1203
+ /**
1204
+ * generate rpk-plugin-stubs
1205
+ *
1206
+ * @description
1207
+ * Reconciles a consumer repo's single-source stub pages and nav section
1208
+ * against the rpk plugin partials generated in the docs repo. Run from the
1209
+ * consumer repo root (for example, adp-docs for rpk ai). Creates stubs for
1210
+ * new partials, deletes managed stubs whose partial is gone, rebuilds the
1211
+ * plugin's nav block, and proposes page aliases for likely renames.
1212
+ * Full reconcile, so it is idempotent and heals pre-existing drift.
1213
+ */
1214
+ automation
1215
+ .command('rpk-plugin-stubs')
1216
+ .description('Reconcile single-source stub pages and nav against the docs repo\'s rpk plugin partials. Run from the consumer repo root.')
1217
+ .option('--plugin <name>', 'rpk plugin command name', 'ai')
1218
+ .option('--docs-repo <owner/repo>', 'Docs repo that owns the partials', 'redpanda-data/docs')
1219
+ .option('--docs-ref <ref>', 'Branch or tag to read partials from', 'main')
1220
+ .option('--partials-dir <path>', 'Local partials directory (skips cloning the docs repo)')
1221
+ .option('--source-path <path>', 'Path in the docs repo to read from (default: modules/reference/partials/rpk-<plugin>; use modules/reference/pages/rpk/rpk-connect for page-family content)')
1222
+ .option('--stub-dir <path>', 'Stub pages directory in the consumer repo (default: modules/reference/pages/rpk/rpk-<plugin>)')
1223
+ .option('--nav-file <path>', 'Nav file whose plugin block is rebuilt', 'modules/ROOT/nav.adoc')
1224
+ .option('--include-prefix <prefix>', 'Antora resource prefix for stub includes. Default: inferred from an existing stub.')
1225
+ .option('--attribute <line>', 'Page attribute line added to new stubs (repeatable)', (value, acc) => { acc.push(value); return acc }, [])
1226
+ .option('--summary-file <path>', 'Write a markdown summary (for PR bodies)')
1227
+ .option('--dry-run', 'Report what would change without writing')
1228
+ .action(async (options) => {
1229
+ try {
1230
+ const {
1231
+ readPartialTitles, fetchPartialsDir, inferIncludePrefix, reconcileStubs
1232
+ } = require('../tools/rpk-docs/generate-plugin-stubs.js')
1233
+
1234
+ const plugin = options.plugin
1235
+ const stubDir = options.stubDir || `modules/reference/pages/rpk/rpk-${plugin}`
1236
+ const partialsDir = options.partialsDir || fetchPartialsDir({
1237
+ docsRepo: options.docsRepo,
1238
+ docsRef: options.docsRef,
1239
+ plugin,
1240
+ sourcePath: options.sourcePath
1241
+ })
1242
+
1243
+ const includePrefix = options.includePrefix || inferIncludePrefix(stubDir, plugin)
1244
+ if (!includePrefix) {
1245
+ console.error('Error: could not infer the include prefix (no existing stubs). Pass --include-prefix, for example: streaming:reference:partial$rpk-ai/')
1246
+ process.exit(1)
1247
+ }
1248
+
1249
+ const partials = readPartialTitles(partialsDir)
1250
+ console.log(`Reconciling ${partials.length} partial(s) against ${stubDir}`)
1251
+
1252
+ const result = reconcileStubs({
1253
+ partials,
1254
+ stubDir,
1255
+ navFile: options.navFile,
1256
+ plugin,
1257
+ includePrefix,
1258
+ ...(options.attribute.length > 0 ? { attributes: options.attribute } : {}),
1259
+ dryRun: options.dryRun
1260
+ })
1261
+
1262
+ console.log(` Created: ${result.created.length}, deleted: ${result.deleted.length}, nav updated: ${result.navUpdated}`)
1263
+ for (const f of result.created) console.log(` + ${f}`)
1264
+ for (const f of result.deleted) console.log(` - ${f}`)
1265
+ for (const f of result.keptNonStub) console.log(` ! kept (not a managed stub): ${f}`)
1266
+
1267
+ const lines = []
1268
+ lines.push(`## rpk ${plugin} stub reconciliation`)
1269
+ lines.push('')
1270
+ lines.push(`Reconciled against \`${options.partialsDir ? partialsDir : `${options.docsRepo}@${options.docsRef}`}\`.`)
1271
+ lines.push('')
1272
+ if (result.created.length + result.deleted.length === 0 && !result.navUpdated) {
1273
+ lines.push('No changes: stubs and nav already match the partials.')
1274
+ }
1275
+ if (result.created.length > 0) {
1276
+ lines.push(`### New stubs (${result.created.length})`)
1277
+ lines.push('')
1278
+ result.created.forEach(f => lines.push(`- \`${f}\``))
1279
+ lines.push('')
1280
+ }
1281
+ if (result.deleted.length > 0) {
1282
+ lines.push(`### Deleted stubs (${result.deleted.length})`)
1283
+ lines.push('')
1284
+ result.deleted.forEach(f => lines.push(`- \`${f}\``))
1285
+ lines.push('')
1286
+ }
1287
+ if ((result.skippedAliasTargets || []).length > 0) {
1288
+ lines.push('### Skipped: names claimed as page aliases')
1289
+ lines.push('')
1290
+ lines.push('These partials exist upstream, but a page here already claims the name as a `:page-aliases:` target — creating the stub would make the Antora build fatal. Usually this means a rename alias exists while the upstream partial for the old name has not been cleaned up yet:')
1291
+ lines.push('')
1292
+ for (const t of result.skippedAliasTargets) {
1293
+ lines.push(`- \`${t.file}\` (claimed by \`${t.claimedBy}\`)`)
1294
+ }
1295
+ lines.push('')
1296
+ }
1297
+ const straightDeletions = result.deleted.filter(d => !result.renameCandidates.some(rc => rc.deleted === d))
1298
+ if (straightDeletions.length > 0) {
1299
+ lines.push('### Deletions with no rename partner')
1300
+ lines.push('')
1301
+ lines.push('These pages were removed with no successor detected. Their published URLs will 404 — consider adding a redirect or an alias on a related page:')
1302
+ lines.push('')
1303
+ straightDeletions.forEach(f => lines.push(`- \`${f}\``))
1304
+ lines.push('')
1305
+ }
1306
+ if (result.renameCandidates.length > 0) {
1307
+ lines.push('### Possible renames — reviewer decision needed')
1308
+ lines.push('')
1309
+ lines.push('These deleted/created pairs look like renames. If so, add `:page-aliases:` for the old page name to the new stub so published URLs keep working:')
1310
+ lines.push('')
1311
+ for (const rc of result.renameCandidates) {
1312
+ lines.push(`- \`${rc.deleted}\` → \`${rc.created}\`: add \`:page-aliases: reference:rpk/rpk-${plugin}/${rc.deleted}\` to the new stub`)
1313
+ }
1314
+ lines.push('')
1315
+ }
1316
+ if (result.keptNonStub.length > 0) {
1317
+ lines.push('### Kept (not managed stubs)')
1318
+ lines.push('')
1319
+ lines.push('These pages do not match the managed stub shape, so they were not touched:')
1320
+ lines.push('')
1321
+ result.keptNonStub.forEach(f => lines.push(`- \`${f}\``))
1322
+ lines.push('')
1323
+ }
1324
+ const summary = lines.join('\n')
1325
+ if (options.summaryFile) {
1326
+ fs.writeFileSync(options.summaryFile, summary, 'utf8')
1327
+ console.log(`Summary written to: ${options.summaryFile}`)
1328
+ }
1329
+
1330
+ process.exit(0)
1331
+ } catch (err) {
1332
+ console.error(`Error: ${err.message}`)
1333
+ process.exit(1)
1334
+ }
1335
+ })
1336
+
1178
1337
  /**
1179
1338
  * validate rpk-overrides
1180
1339
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.2.5",
3
+ "version": "5.3.1",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -79,7 +79,7 @@ ENUM_PATTERN = re.compile(r'^[a-zA-Z0-9_:]+::([a-zA-Z0-9_]+)$') # Match full qu
79
79
  CONSTRUCTOR_PATTERN = re.compile(r'([a-zA-Z0-9_:]+)\((.*)\)')
80
80
  BRACED_CONSTRUCTOR_PATTERN = re.compile(r'([a-zA-Z0-9_:]+)\{(.*)\}')
81
81
  DIGIT_SEPARATOR_PATTERN = re.compile(r"(?<=\d)'(?=\d)")
82
- FUNCTION_CALL_PATTERN = re.compile(r'([a-zA-Z0-9_:]+)\(\)')
82
+ FUNCTION_CALL_PATTERN = re.compile(r'([a-zA-Z0-9_:.]+)\(\)$')
83
83
  CHRONO_PATTERN = re.compile(r'std::chrono::([a-zA-Z]+)\s*\{\s*(\d+)\s*\}')
84
84
  CHRONO_PAREN_PATTERN = re.compile(r'(?:std::)?chrono::([a-zA-Z]+)\s*\(\s*([^)]+)\s*\)')
85
85
  TIME_UNIT_PATTERN = re.compile(r'(\d+)\s*(min|s|ms|h)')
@@ -159,6 +159,15 @@ class ConstexprCache:
159
159
  re.compile(r'const\s+model::ns\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(\s*"([^"]+)"\s*\)'),
160
160
  ]
161
161
 
162
+ # Member accessors on inline constants used in property defaults,
163
+ # for example model::schema_registry_internal_tp.topic() -> "_schemas".
164
+ # Each entry maps a variable declaration to the member name whose call
165
+ # returns the captured string, cached under "<variable>.<member>".
166
+ member_accessor_patterns = [
167
+ # Pattern: inline const model::topic_partition name{model::topic{"value"}, ...}
168
+ (re.compile(r'inline\s+const\s+(?:model::)?topic_partition\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\{\s*(?:model::)?topic\{\s*"([^"]+)"\s*\}'), 'topic'),
169
+ ]
170
+
162
171
  # Legacy specific patterns (kept for compatibility, but general patterns should cover these)
163
172
  function_patterns = {}
164
173
 
@@ -206,6 +215,17 @@ class ConstexprCache:
206
215
  qualified_name = f"{namespace}::{func_name}"
207
216
  self.function_cache[qualified_name] = func_value
208
217
 
218
+ # Extract member accessors on inline constants
219
+ for pattern, member in member_accessor_patterns:
220
+ for match in pattern.finditer(content):
221
+ var_name = match.group(1)
222
+ value = match.group(2)
223
+ key = f"{var_name}.{member}"
224
+ namespace = self._extract_namespace_for_function(content, match.start())
225
+ self.function_cache[key] = value
226
+ if namespace:
227
+ self.function_cache[f"{namespace}::{key}"] = value
228
+
209
229
  # Legacy: Extract function definitions from hardcoded patterns (if any)
210
230
  for func_name, patterns in function_patterns.items():
211
231
  for pattern in patterns:
@@ -753,7 +773,24 @@ def transform_files_with_properties(files_with_properties):
753
773
  continue
754
774
 
755
775
  if len(property_definition) > 0:
756
- all_properties[name] = property_definition
776
+ # Key the output by the property's registered name (the string
777
+ # literal passed to the constructor), which can differ from the
778
+ # C++ member identifier used as the parser key. For example,
779
+ # the member `default_topic_replication` registers the name
780
+ # "default_topic_replications".
781
+ output_key = property_definition.get("name") or name
782
+ existing = all_properties.get(output_key)
783
+ if existing is not None and existing.get("defined_in") == property_definition.get("defined_in"):
784
+ # Two members of the same config store register the same
785
+ # property name. config_store registers properties with
786
+ # emplace(), so the first registration wins at runtime.
787
+ # Match that behavior and keep the first definition.
788
+ logging.warning(
789
+ f"Duplicate property name '{output_key}' (member '{name}') in "
790
+ f"{property_definition.get('defined_in')}: keeping the first definition."
791
+ )
792
+ continue
793
+ all_properties[output_key] = property_definition
757
794
 
758
795
  return all_properties
759
796
 
@@ -639,8 +639,9 @@ class BasicInfoTransformer:
639
639
 
640
640
  NAME RESOLUTION PRIORITY:
641
641
  1. Pre-existing property["name"] (from previous processing)
642
- 2. info["name_in_file"] (C++ variable name from Tree-sitter)
643
- 3. First parameter value if it looks like an identifier
642
+ 2. First string-literal parameter if it looks like an identifier
643
+ (the name argument passed to the property constructor)
644
+ 3. info["name_in_file"] (C++ member name from Tree-sitter) as fallback
644
645
 
645
646
  DESCRIPTION EXTRACTION LOGIC:
646
647
  1. If first parameter is a string and differs from name → use as description
@@ -673,29 +674,44 @@ class BasicInfoTransformer:
673
674
  return property
674
675
 
675
676
  # --- Step 1: find the "real" start of the property definition ---
676
- # Skip lambdas, validators, and non-string literals at the start
677
- start_idx = 0
677
+ # Skip lambdas, validators, and non-string literals at the start.
678
+ # The parser normalizes string_literal values to unquoted strings, so
679
+ # select by parameter type rather than by surrounding quotes.
680
+ start_idx = None
678
681
  for i, p in enumerate(params):
679
- val = str(p.get("value", ""))
680
- typ = p.get("type", "")
681
682
  if is_validator_param(p):
682
683
  continue
683
- if typ in ("lambda_expression", "qualified_identifier", "unresolved_identifier"):
684
- continue
685
- if not (val.startswith('"') and val.endswith('"')):
684
+ if p.get("type") != "string_literal":
686
685
  continue
687
686
  # First string literal we hit is the name
688
687
  start_idx = i
689
688
  break
690
689
 
691
690
  # --- Step 2: extract name and description robustly ---
692
- name = property.get("name") or info.get("name_in_file")
693
- if not name and len(params) > start_idx:
694
- name = params[start_idx].get("value", "").strip('"')
691
+ # The property's registered name is the first string-literal constructor
692
+ # argument, NOT the C++ member identifier. They usually match, but not
693
+ # always: for example, the member `default_topic_replication` registers
694
+ # the name "default_topic_replications". info["name_in_file"] (the
695
+ # member identifier) is only a fallback.
696
+ name = property.get("name")
697
+ if not name:
698
+ for p in params:
699
+ if p.get("type") != "string_literal":
700
+ continue
701
+ candidate = str(p.get("value", "")).strip().strip('"')
702
+ # The name literal is a single identifier with no whitespace.
703
+ # If the first string literal looks like a description instead
704
+ # (for example, the name was passed as a constant), fall back
705
+ # to the member identifier below.
706
+ if candidate and not re.search(r"\s", candidate):
707
+ name = candidate
708
+ break # only the first string literal can be the name
709
+ if not name:
710
+ name = info.get("name_in_file")
695
711
  property["name"] = name
696
712
 
697
713
  desc = None
698
- if len(params) > start_idx + 1:
714
+ if start_idx is not None and len(params) > start_idx + 1:
699
715
  v0 = params[start_idx].get("value")
700
716
  v1 = params[start_idx + 1].get("value")
701
717
  if isinstance(v1, str) and len(v1) > 10 and " " in v1: