@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.
@@ -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
+ }