@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 +159 -0
- package/package.json +1 -1
- package/tools/property-extractor/property_extractor.py +18 -1
- package/tools/property-extractor/transformers.py +29 -13
- package/tools/rpk-docs/generate-plugin-stubs.js +291 -0
- package/tools/rpk-docs/generate-rpk-docs.js +440 -25
- package/tools/rpk-docs/helpers/index.js +13 -3
- package/tools/rpk-docs/report-delta.js +314 -32
- package/tools/rpk-docs/rpk-docs-handler.js +1230 -102
- package/tools/rpk-docs/templates/command.hbs +5 -0
- package/tools/rpk-docs/templates/content-item.hbs +2 -2
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
|
@@ -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
|
-
|
|
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.
|
|
643
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
+
}
|