@redpanda-data/docs-extensions-and-macros 5.2.5 → 5.3.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
@@ -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.0",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -753,7 +753,24 @@ def transform_files_with_properties(files_with_properties):
753
753
  continue
754
754
 
755
755
  if len(property_definition) > 0:
756
- all_properties[name] = property_definition
756
+ # Key the output by the property's registered name (the string
757
+ # literal passed to the constructor), which can differ from the
758
+ # C++ member identifier used as the parser key. For example,
759
+ # the member `default_topic_replication` registers the name
760
+ # "default_topic_replications".
761
+ output_key = property_definition.get("name") or name
762
+ existing = all_properties.get(output_key)
763
+ if existing is not None and existing.get("defined_in") == property_definition.get("defined_in"):
764
+ # Two members of the same config store register the same
765
+ # property name. config_store registers properties with
766
+ # emplace(), so the first registration wins at runtime.
767
+ # Match that behavior and keep the first definition.
768
+ logging.warning(
769
+ f"Duplicate property name '{output_key}' (member '{name}') in "
770
+ f"{property_definition.get('defined_in')}: keeping the first definition."
771
+ )
772
+ continue
773
+ all_properties[output_key] = property_definition
757
774
 
758
775
  return all_properties
759
776
 
@@ -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:
@@ -0,0 +1,291 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Stub reconciler for consumer repos that publish rpk plugin docs through
5
+ * single-source stubs (adp-docs for rpk ai).
6
+ *
7
+ * The docs repo owns the generated partials; consumer repos own one static
8
+ * stub page per command plus a nav entry. When a plugin release adds or
9
+ * removes commands, the stubs drift: a new partial has no stub (command
10
+ * invisible on the consumer site) and a deleted partial leaves a stub with an
11
+ * unresolved include (broken page). This module reconciles the stub set
12
+ * against the current partials rather than applying a diff, so it also heals
13
+ * pre-existing drift and is idempotent.
14
+ */
15
+
16
+ const fs = require('fs')
17
+ const path = require('path')
18
+ const os = require('os')
19
+ const { spawnSync } = require('child_process')
20
+
21
+ /**
22
+ * Read command titles from generated partials. The title line is
23
+ * authoritative: dashified filenames cannot be reversed unambiguously
24
+ * (rpk-ai-llm-provider could be `llm provider` or `llm-provider`).
25
+ * @param {string} partialsDir - Directory of generated .adoc partials
26
+ * @returns {Array<{file: string, title: string}>} Sorted by command path
27
+ */
28
+ function readPartialTitles(partialsDir) {
29
+ const partials = []
30
+ for (const file of fs.readdirSync(partialsDir)) {
31
+ if (!file.endsWith('.adoc')) continue
32
+ const content = fs.readFileSync(path.join(partialsDir, file), 'utf8')
33
+ const match = content.match(/^= (.+)$/m)
34
+ if (!match) {
35
+ console.warn(`Warning: no title line in ${file}; skipping`)
36
+ continue
37
+ }
38
+ partials.push({ file, title: match[1].trim() })
39
+ }
40
+ // Hierarchical order: sort by command words so parents precede children
41
+ partials.sort((a, b) => {
42
+ const aw = a.title.split(' ')
43
+ const bw = b.title.split(' ')
44
+ for (let i = 0; i < Math.max(aw.length, bw.length); i++) {
45
+ if (aw[i] === bw[i]) continue
46
+ if (aw[i] === undefined) return -1
47
+ if (bw[i] === undefined) return 1
48
+ return aw[i] < bw[i] ? -1 : 1
49
+ }
50
+ return 0
51
+ })
52
+ return partials
53
+ }
54
+
55
+ /**
56
+ * Sparse-clone the docs repo and return the path to a plugin's partials dir.
57
+ * @param {Object} params
58
+ * @param {string} params.docsRepo - owner/repo (e.g. redpanda-data/docs)
59
+ * @param {string} params.docsRef - Branch or tag to read (e.g. main)
60
+ * @param {string} params.plugin - Plugin command name (e.g. ai)
61
+ * @returns {string} Local path to the partials directory
62
+ */
63
+ function fetchPartialsDir({ docsRepo, docsRef, plugin, sourcePath }) {
64
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-stubs-'))
65
+ const repoDir = path.join(tmpDir, 'docs')
66
+ // ai and cloud content renders as partials; connect renders as pages —
67
+ // both carry the single-source tag, so either family can be stubbed
68
+ const sparsePath = sourcePath || `modules/reference/partials/rpk-${plugin}`
69
+
70
+ console.log(`Fetching ${sparsePath} from ${docsRepo}@${docsRef}...`)
71
+ const cloneResult = spawnSync('git', [
72
+ 'clone', '--depth', '1', '--filter=blob:none', '--sparse',
73
+ '--branch', docsRef,
74
+ `https://github.com/${docsRepo}.git`, repoDir
75
+ ], { encoding: 'utf8', timeout: 180000 })
76
+ if (cloneResult.status !== 0) {
77
+ throw new Error(`Failed to clone ${docsRepo}@${docsRef}: ${cloneResult.stderr}`)
78
+ }
79
+
80
+ const sparseResult = spawnSync('git', ['sparse-checkout', 'set', sparsePath], {
81
+ cwd: repoDir, encoding: 'utf8', timeout: 60000
82
+ })
83
+ if (sparseResult.status !== 0) {
84
+ throw new Error(`Failed sparse checkout of ${sparsePath}: ${sparseResult.stderr}`)
85
+ }
86
+
87
+ const partialsDir = path.join(repoDir, sparsePath)
88
+ if (!fs.existsSync(partialsDir)) {
89
+ throw new Error(`Partials directory not found in ${docsRepo}@${docsRef}: ${sparsePath}`)
90
+ }
91
+ return partialsDir
92
+ }
93
+
94
+ /**
95
+ * Infer the include prefix from an existing managed stub, so the reconciler
96
+ * follows whatever component/module coordinates the consumer repo uses.
97
+ * @param {string} stubDir - Consumer repo stub directory
98
+ * @param {string} plugin - Plugin command name
99
+ * @returns {string|null} e.g. "streaming:reference:partial$rpk-ai/"
100
+ */
101
+ function inferIncludePrefix(stubDir, plugin) {
102
+ if (!fs.existsSync(stubDir)) return null
103
+ for (const file of fs.readdirSync(stubDir)) {
104
+ if (!file.endsWith('.adoc')) continue
105
+ const content = fs.readFileSync(path.join(stubDir, file), 'utf8')
106
+ const match = content.match(new RegExp(`include::([^\\[]*(?:partial|page)\\$[^\\[]*rpk-${plugin}/)`))
107
+ if (match) return match[1]
108
+ }
109
+ return null
110
+ }
111
+
112
+ /**
113
+ * Render a stub page.
114
+ * @param {Object} params
115
+ * @param {string} params.title - Command path (e.g. "rpk ai auth login")
116
+ * @param {string} params.file - Partial filename
117
+ * @param {string} params.includePrefix - Antora resource prefix
118
+ * @param {Array<string>} params.attributes - Page attribute lines
119
+ * @returns {string}
120
+ */
121
+ function renderStub({ title, file, includePrefix, attributes }) {
122
+ const lines = [`= ${title}`]
123
+ for (const attr of attributes) lines.push(attr)
124
+ lines.push('')
125
+ lines.push(`include::${includePrefix}${file}[tag=single-source]`)
126
+ lines.push('')
127
+ return lines.join('\n')
128
+ }
129
+
130
+ /**
131
+ * Reconcile a consumer repo's stub pages and nav section against the
132
+ * current set of generated partials.
133
+ * @param {Object} params
134
+ * @param {Array<{file: string, title: string}>} params.partials
135
+ * @param {string} params.stubDir - Consumer stub directory (created if missing)
136
+ * @param {string} params.navFile - Consumer nav.adoc path
137
+ * @param {string} params.plugin - Plugin command name (e.g. ai)
138
+ * @param {string} params.includePrefix - Antora resource prefix for includes
139
+ * @param {Array<string>} [params.attributes] - Page attributes for new stubs
140
+ * @param {boolean} [params.dryRun]
141
+ * @returns {Object} { created, deleted, keptNonStub, navUpdated, renameCandidates }
142
+ */
143
+ function reconcileStubs({
144
+ partials,
145
+ stubDir,
146
+ navFile,
147
+ plugin,
148
+ includePrefix,
149
+ attributes = [':page-preview: true'],
150
+ dryRun = false
151
+ }) {
152
+ const managedStubRe = new RegExp(`include::[^\\[]*(?:partial|page)\\$[^\\[]*rpk-${plugin}/([\\w.-]+\\.adoc)\\[`)
153
+ const partialByFile = new Map(partials.map(p => [p.file, p]))
154
+
155
+ fs.mkdirSync(stubDir, { recursive: true })
156
+ const existingStubs = fs.readdirSync(stubDir).filter(f => f.endsWith('.adoc'))
157
+
158
+ const created = []
159
+ const deleted = []
160
+ const keptNonStub = []
161
+ const skippedAliasTargets = []
162
+
163
+ // Page names already claimed as aliases by other pages in this directory.
164
+ // Creating a page whose resource ID is an alias target makes the Antora
165
+ // build fatal ("Page alias cannot reference an existing page"), which
166
+ // happens when a rename alias exists here while the upstream partial for
167
+ // the old name still lingers. Skip those creations and surface them.
168
+ const aliasClaims = new Map()
169
+ for (const file of existingStubs) {
170
+ const content = fs.readFileSync(path.join(stubDir, file), 'utf8')
171
+ const aliasLine = content.match(/^:page-aliases:\s*(.+)$/m)
172
+ if (!aliasLine) continue
173
+ for (const target of aliasLine[1].split(',')) {
174
+ const base = target.trim().split('/').pop()
175
+ if (base) aliasClaims.set(base, file)
176
+ }
177
+ }
178
+
179
+ // Delete managed stubs whose partial no longer exists. Pages that do not
180
+ // match the managed-stub shape are never deleted: they may be hand-written.
181
+ const deletedTitles = new Map()
182
+ for (const file of existingStubs) {
183
+ const stubPath = path.join(stubDir, file)
184
+ const content = fs.readFileSync(stubPath, 'utf8')
185
+ const match = content.match(managedStubRe)
186
+ if (!match) {
187
+ if (!partialByFile.has(file)) keptNonStub.push(file)
188
+ continue
189
+ }
190
+ if (!partialByFile.has(match[1])) {
191
+ const titleMatch = content.match(/^= (.+)$/m)
192
+ deletedTitles.set(file, titleMatch ? titleMatch[1].trim() : '')
193
+ if (!dryRun) fs.unlinkSync(stubPath)
194
+ deleted.push(file)
195
+ }
196
+ }
197
+
198
+ // Create stubs for partials that have none
199
+ const remainingStubs = new Set(
200
+ fs.existsSync(stubDir) ? fs.readdirSync(stubDir).filter(f => f.endsWith('.adoc')) : []
201
+ )
202
+ for (const partial of partials) {
203
+ if (remainingStubs.has(partial.file)) continue
204
+ if (aliasClaims.has(partial.file)) {
205
+ skippedAliasTargets.push({ file: partial.file, claimedBy: aliasClaims.get(partial.file) })
206
+ continue
207
+ }
208
+ if (!dryRun) {
209
+ fs.writeFileSync(
210
+ path.join(stubDir, partial.file),
211
+ renderStub({ ...partial, includePrefix, attributes }),
212
+ 'utf8'
213
+ )
214
+ }
215
+ created.push(partial.file)
216
+ }
217
+
218
+ // Rename candidates: same parent command, same depth, related last words
219
+ // (llm -> llm-provider). Proposed for the reviewer, who decides whether
220
+ // the new stub gets a page alias.
221
+ const renameCandidates = []
222
+ for (const [dFile, dTitle] of deletedTitles) {
223
+ if (!dTitle) continue
224
+ const dWords = dTitle.split(' ')
225
+ for (const cFile of created) {
226
+ const cTitle = (partialByFile.get(cFile) || {}).title || ''
227
+ const cWords = cTitle.split(' ')
228
+ if (cWords.length !== dWords.length) continue
229
+ if (cWords.slice(0, -1).join(' ') !== dWords.slice(0, -1).join(' ')) continue
230
+ const dLast = dWords[dWords.length - 1]
231
+ const cLast = cWords[cWords.length - 1]
232
+ if (cLast.startsWith(dLast) || dLast.startsWith(cLast)) {
233
+ renameCandidates.push({ deleted: dFile, created: cFile })
234
+ }
235
+ }
236
+ }
237
+
238
+ // Rebuild the plugin's nav block: keep the labeled parent line, regenerate
239
+ // child entries from titles at star depth = parent depth + (words - 2)
240
+ let navUpdated = false
241
+ if (navFile && fs.existsSync(navFile)) {
242
+ const navLines = fs.readFileSync(navFile, 'utf8').split('\n')
243
+ const parentRe = new RegExp(`^(\\*+) xref:[^\\[]*rpk-${plugin}/rpk-${plugin}\\.adoc\\[`)
244
+ const parentIdx = navLines.findIndex(l => parentRe.test(l))
245
+ if (parentIdx === -1) {
246
+ console.warn(`Warning: no rpk-${plugin} parent entry found in ${navFile}; nav not updated`)
247
+ } else {
248
+ const parentStars = navLines[parentIdx].match(parentRe)[1].length
249
+ const navPathPrefix = navLines[parentIdx].match(/xref:([^\[]*rpk-\w+\/)/)[1]
250
+
251
+ // The block ends at the first line that is not a deeper entry
252
+ let end = parentIdx + 1
253
+ while (end < navLines.length) {
254
+ const starMatch = navLines[end].match(/^(\*+) /)
255
+ if (!starMatch || starMatch[1].length <= parentStars) break
256
+ end++
257
+ }
258
+
259
+ const skippedFiles = new Set(skippedAliasTargets.map(t => t.file))
260
+ const entries = []
261
+ for (const partial of partials) {
262
+ if (skippedFiles.has(partial.file)) continue
263
+ const words = partial.title.split(' ').length
264
+ if (words <= 2) continue // the parent line represents the root command
265
+ const stars = '*'.repeat(parentStars + (words - 2))
266
+ entries.push(`${stars} xref:${navPathPrefix}${partial.file}[]`)
267
+ }
268
+
269
+ const rebuilt = [
270
+ ...navLines.slice(0, parentIdx + 1),
271
+ ...entries,
272
+ ...navLines.slice(end)
273
+ ].join('\n')
274
+
275
+ if (rebuilt !== navLines.join('\n')) {
276
+ if (!dryRun) fs.writeFileSync(navFile, rebuilt, 'utf8')
277
+ navUpdated = true
278
+ }
279
+ }
280
+ }
281
+
282
+ return { created, deleted, keptNonStub, navUpdated, renameCandidates, skippedAliasTargets }
283
+ }
284
+
285
+ module.exports = {
286
+ readPartialTitles,
287
+ fetchPartialsDir,
288
+ inferIncludePrefix,
289
+ renderStub,
290
+ reconcileStubs
291
+ }