@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 +159 -0
- package/package.json +1 -1
- package/tools/property-extractor/property_extractor.py +39 -2
- package/tools/property-extractor/transformers.py +29 -13
- package/tools/rpk-docs/detect-platform-commands.js +566 -0
- package/tools/rpk-docs/generate-plugin-stubs.js +291 -0
- package/tools/rpk-docs/generate-rpk-docs.js +466 -30
- 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 +1329 -208
- package/tools/rpk-docs/templates/command.hbs +17 -0
- package/tools/rpk-docs/templates/content-item.hbs +2 -2
- package/tools/rpk-docs/validate-overrides.js +16 -0
|
@@ -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
|
+
}
|