@redpanda-data/docs-extensions-and-macros 5.0.4 → 5.1.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.
@@ -292,40 +292,95 @@ function generateIndex (playbook, contentCatalog, { indexLatestOnly = false, exc
292
292
  ? page.asciidoc.attributes['page-commercial-names'].split(',').map(name => name.trim())
293
293
  : []
294
294
 
295
- // FIXED: keywords now included in index item
296
- const indexItem = {
297
- title: documentTitle,
298
- version: version,
299
- text: text,
300
- intro: intro,
301
- objectID: urlPath + page.pub.url,
302
- titles: titles,
303
- keywords: keywords,
304
- categories: categories,
305
- commercialNames: commercialNames,
306
- unixTimestamp: unixTimestamp
307
- }
295
+ // Algolia only indexes roughly the first ~290 words of any single record. On
296
+ // long reference pages (cluster/topic properties, metrics) every property or
297
+ // metric name lives in the `titles` array, and there can be hundreds of them, so
298
+ // every name past that window is stored but NOT searchable (DOC-1878). Split the
299
+ // titles of such pages across multiple records, each small enough to be fully
300
+ // indexed. Normal pages keep a single record. Chunks after the first get a unique
301
+ // objectID anchored to their first heading so search results deep-link into the
302
+ // page; the first chunk keeps the page's base objectID.
303
+ const baseObjectID = urlPath + page.pub.url
304
+ const titleChunks = isPropertiesPage ? chunkTitles(titles) : [titles]
305
+
306
+ titleChunks.forEach((chunk, chunkIndex) => {
307
+ const objectID = chunkIndex === 0
308
+ ? baseObjectID
309
+ : `${baseObjectID}#${chunk[0].h}`
310
+
311
+ // keywords included in index item
312
+ const indexItem = {
313
+ title: documentTitle,
314
+ version: version,
315
+ text: text,
316
+ intro: intro,
317
+ objectID: objectID,
318
+ // Clean page path (no #fragment) shared by every chunk of a page. The search
319
+ // UI builds result links from `url` and appends its own matched-heading
320
+ // anchor, so chunk objectIDs (which carry a #anchor for uniqueness) must not
321
+ // be used for the href. `url` is also the attribute to dedupe chunks on.
322
+ url: baseObjectID,
323
+ titles: chunk,
324
+ keywords: keywords,
325
+ categories: categories,
326
+ commercialNames: commercialNames,
327
+ unixTimestamp: unixTimestamp
328
+ }
308
329
 
309
- if (component.name !== 'labs') {
310
- indexItem.product = component.title
311
- indexItem.breadcrumbs = breadcrumbs
312
- indexItem.type = 'Doc'
313
- indexItem._tags = Array.isArray(tag) ? tag : [tag]
314
- } else {
315
- indexItem.deployment = deployment
316
- indexItem.type = 'Lab'
317
- indexItem.interactive = false
318
- indexItem._tags = Array.isArray(tag) ? tag : [tag]
319
- }
330
+ if (component.name !== 'labs') {
331
+ indexItem.product = component.title
332
+ indexItem.breadcrumbs = breadcrumbs
333
+ indexItem.type = 'Doc'
334
+ indexItem._tags = Array.isArray(tag) ? tag : [tag]
335
+ } else {
336
+ indexItem.deployment = deployment
337
+ indexItem.type = 'Lab'
338
+ indexItem.interactive = false
339
+ indexItem._tags = Array.isArray(tag) ? tag : [tag]
340
+ }
320
341
 
321
- algolia[cname][version].push(indexItem)
322
- algoliaCount++
342
+ algolia[cname][version].push(indexItem)
343
+ algoliaCount++
344
+ })
323
345
  }
324
346
 
325
347
  logger.info(`Indexed ${algoliaCount} pages`)
326
348
  return algolia
327
349
  }
328
350
 
351
+ /**
352
+ * Split a titles array into chunks whose combined token count stays within
353
+ * Algolia's per-record indexing window (~290 words; we leave a safety margin).
354
+ * Each returned chunk is small enough that every heading it contains is actually
355
+ * indexed for search, which keeps property/metric names on long reference pages
356
+ * searchable (DOC-1878).
357
+ *
358
+ * @param {Array<{t: string, h: string}>} titles - Section headings for a page.
359
+ * @param {number} [maxTokens=180] - Approximate token budget per chunk.
360
+ * @returns {Array<Array<{t: string, h: string}>>} One or more title chunks. An
361
+ * empty input yields a single empty chunk so the page still gets a record.
362
+ */
363
+ function chunkTitles (titles, maxTokens = 180) {
364
+ if (!titles.length) return [[]]
365
+ const chunks = []
366
+ let current = []
367
+ let tokens = 0
368
+ for (const entry of titles) {
369
+ // Algolia tokenizes on separators (whitespace, underscores, dots, etc.), so a
370
+ // name like `partition_autobalancing_mode` counts as several tokens.
371
+ const entryTokens = String(entry.t || '').split(/[\s_./:-]+/).filter(Boolean).length || 1
372
+ if (current.length && tokens + entryTokens > maxTokens) {
373
+ chunks.push(current)
374
+ current = []
375
+ tokens = 0
376
+ }
377
+ current.push(entry)
378
+ tokens += entryTokens
379
+ }
380
+ if (current.length) chunks.push(current)
381
+ return chunks
382
+ }
383
+
329
384
  /**
330
385
  * Extract the path from a URL
331
386
  * @param {string} url - The URL to extract path from
@@ -135,97 +135,12 @@ module.exports.register = function ({ config }) {
135
135
  }
136
136
  });
137
137
 
138
- // Find latest available Redpanda properties JSON in content catalog
139
- // This ensures we use a version that actually exists, even if latest-redpanda-tag
140
- // points to a newer release that doesn't have docs/attachments yet
141
- // Component name is 'streaming' (or 'ROOT' for backwards compatibility)
142
- const propertiesFiles = contentCatalog.findBy({
143
- family: 'attachment'
144
- }).filter(f => {
145
- if (!f?.src?.stem || !f?.src?.component) return false;
146
- const isStreamingComponent = f.src.component === 'streaming' || f.src.component === 'ROOT';
147
- const isReferenceModule = f.src.module === 'reference';
148
- const isPropertiesFile = f.src.stem.startsWith('redpanda-properties-');
149
- return isStreamingComponent && isReferenceModule && isPropertiesFile;
150
- });
151
-
152
- let availablePropertiesTag = null;
153
- if (propertiesFiles.length > 0) {
154
- const versions = propertiesFiles
155
- .map(f => f.src.stem.replace('redpanda-properties-', ''))
156
- // Only accept stable versions (no prerelease suffix like -backup, -rc1, etc.)
157
- .filter(v => {
158
- const normalized = v.replace(/^v/, '');
159
- return semver.valid(normalized) && semver.prerelease(normalized) === null;
160
- });
161
-
162
- if (versions.length > 0) {
163
- availablePropertiesTag = versions.sort((a, b) =>
164
- semver.rcompare(a.replace(/^v/, ''), b.replace(/^v/, ''))
165
- )[0];
166
-
167
- // Set on all component versions
168
- components.forEach(component => {
169
- component.versions.forEach(({ asciidoc }) => {
170
- asciidoc.attributes['available-properties-tag'] = availablePropertiesTag;
171
- });
172
- });
173
-
174
- logger.debug(`Found ${propertiesFiles.length} properties JSON files, latest available: ${availablePropertiesTag}`);
175
- }
176
- } else {
177
- logger.debug('No properties JSON files found in content catalog');
178
- }
179
-
180
- // Find latest available Connect JSON in content catalog
181
- // Component name is 'connect' (or 'redpanda-connect' for backwards compatibility)
182
- const connectFiles = contentCatalog.findBy({
183
- family: 'attachment'
184
- }).filter(f => {
185
- if (!f?.src?.stem || !f?.src?.component) return false;
186
- const isConnectComponent = f.src.component === 'connect' || f.src.component === 'redpanda-connect';
187
- const isComponentsModule = f.src.module === 'components';
188
- const isConnectFile = f.src.stem.startsWith('connect-');
189
- return isConnectComponent && isComponentsModule && isConnectFile;
190
- });
191
-
192
- let availableConnectVersion = null;
193
- if (connectFiles.length > 0) {
194
- const versions = connectFiles
195
- .map(f => f.src.stem.replace('connect-', ''))
196
- // Only accept stable versions (no prerelease suffix)
197
- .filter(v => semver.valid(v) && semver.prerelease(v) === null);
198
-
199
- if (versions.length > 0) {
200
- availableConnectVersion = versions.sort((a, b) =>
201
- semver.rcompare(a, b)
202
- )[0];
203
-
204
- // Set on all component versions
205
- components.forEach(component => {
206
- component.versions.forEach(({ asciidoc }) => {
207
- asciidoc.attributes['available-connect-version'] = availableConnectVersion;
208
- });
209
- });
210
-
211
- logger.debug(`Found ${connectFiles.length} connect JSON files, latest available: ${availableConnectVersion}`);
212
- }
213
- } else {
214
- logger.debug('No connect JSON files found in content catalog');
215
- }
216
-
217
138
  logger.info('Updated Redpanda documentation versions successfully:');
218
139
  logger.info(`- Redpanda: ${latestVersions.redpanda.latestRedpandaRelease.version}${latestVersions.redpanda.latestRcRelease ? ', beta: ' + latestVersions.redpanda.latestRcRelease.version : ''}`);
219
140
  logger.info(`- Connect: ${latestVersions.connect}`);
220
141
  logger.info(`- Console: ${latestVersions.console.latestStableRelease}${latestVersions.console.latestBetaRelease ? ', beta: ' + latestVersions.console.latestBetaRelease : ''}`);
221
142
  logger.info(`- Operator: ${latestVersions.operator?.latestStableRelease || 'unknown'}${latestVersions.operator?.latestBetaRelease ? ', beta: ' + latestVersions.operator.latestBetaRelease : ''}`);
222
143
  logger.info(`- Helm chart: ${latestVersions.helmChart?.latestStableRelease || 'unknown'}${latestVersions.helmChart?.latestBetaRelease ? ', beta: ' + latestVersions.helmChart.latestBetaRelease : ''}`);
223
- if (availablePropertiesTag) {
224
- logger.info(`- Available properties JSON: ${availablePropertiesTag}`);
225
- }
226
- if (availableConnectVersion) {
227
- logger.info(`- Available Connect JSON: ${availableConnectVersion}`);
228
- }
229
144
  } catch (error) {
230
145
  logger.error(`Error updating versions: ${error}`);
231
146
  }
@@ -259,50 +174,4 @@ module.exports.register = function ({ config }) {
259
174
  }
260
175
  });
261
176
  }
262
- };
263
-
264
- // Export helper functions for testing
265
- module.exports.filterPropertiesFiles = function(files) {
266
- return files.filter(f => {
267
- if (!f?.src?.stem || !f?.src?.component) return false;
268
- const isStreamingComponent = f.src.component === 'streaming' || f.src.component === 'ROOT';
269
- const isReferenceModule = f.src.module === 'reference';
270
- const isPropertiesFile = f.src.stem.startsWith('redpanda-properties-');
271
- return isStreamingComponent && isReferenceModule && isPropertiesFile;
272
- });
273
- };
274
-
275
- module.exports.filterConnectFiles = function(files) {
276
- return files.filter(f => {
277
- if (!f?.src?.stem || !f?.src?.component) return false;
278
- const isConnectComponent = f.src.component === 'connect' || f.src.component === 'redpanda-connect';
279
- const isComponentsModule = f.src.module === 'components';
280
- const isConnectFile = f.src.stem.startsWith('connect-');
281
- return isConnectComponent && isComponentsModule && isConnectFile;
282
- });
283
- };
284
-
285
- module.exports.extractLatestPropertiesVersion = function(files, semver) {
286
- const versions = files
287
- .map(f => f.src.stem.replace('redpanda-properties-', ''))
288
- .filter(v => {
289
- const normalized = v.replace(/^v/, '');
290
- return semver.valid(normalized) && semver.prerelease(normalized) === null;
291
- });
292
-
293
- if (versions.length === 0) return null;
294
-
295
- return versions.sort((a, b) =>
296
- semver.rcompare(a.replace(/^v/, ''), b.replace(/^v/, ''))
297
- )[0];
298
- };
299
-
300
- module.exports.extractLatestConnectVersion = function(files, semver) {
301
- const versions = files
302
- .map(f => f.src.stem.replace('connect-', ''))
303
- .filter(v => semver.valid(v) && semver.prerelease(v) === null);
304
-
305
- if (versions.length === 0) return null;
306
-
307
- return versions.sort((a, b) => semver.rcompare(a, b))[0];
308
177
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.0.4",
3
+ "version": "5.1.1",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -109,7 +109,7 @@
109
109
  "@octokit/plugin-retry": "^7.1.1",
110
110
  "@octokit/rest": "^21.1.1",
111
111
  "@redocly/cli": "^2.2.0",
112
- "ajv": "^8.12.0",
112
+ "ajv": "^8.20.0",
113
113
  "algoliasearch": "^4.17.0",
114
114
  "chalk": "4.1.2",
115
115
  "cheerio": "^1.1.2",
@@ -0,0 +1,192 @@
1
+ # Automatic Deprecation Detection for rpk Commands
2
+
3
+ ## Overview
4
+
5
+ The rpk documentation automation now automatically scans Go source code to detect commands marked as `Hidden: true` or `Deprecated: "message"` in Cobra command definitions. This ensures deprecated commands are properly documented with warnings even though they don't appear in `rpk --print-tree` output.
6
+
7
+ ## How It Works
8
+
9
+ ### 1. Source Code Scanning
10
+
11
+ The `scan-deprecated-commands.js` module scans the rpk Go source code looking for:
12
+
13
+ ```go
14
+ cmd := &cobra.Command{
15
+ Use: "admin",
16
+ Hidden: true,
17
+ Deprecated: "use `rpk cluster` subcommands; see...",
18
+ }
19
+ ```
20
+
21
+ It extracts:
22
+ - Command name (`Use` field)
23
+ - Hidden status (`Hidden: true`)
24
+ - Deprecation message (`Deprecated: "..."`)
25
+
26
+ ### 2. Automatic Override Generation
27
+
28
+ When detected, the scanner automatically generates overrides in `rpk-overrides.json`:
29
+
30
+ ```json
31
+ {
32
+ "commands": {
33
+ "rpk redpanda admin": {
34
+ "deprecated": true,
35
+ "deprecatedInVersion": "v26.x",
36
+ "replacement": "Use xref:reference:rpk/rpk-cluster/rpk-cluster.adoc[`rpk cluster`] instead.",
37
+ "deprecatedMessage": "use `rpk cluster` subcommands; see...",
38
+ "_note": "Hidden: true, found by scanning Go source"
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ ### 3. Template Rendering
45
+
46
+ The Handlebars template (`command.hbs`) automatically renders deprecation warnings:
47
+
48
+ ```handlebars
49
+ {{#if deprecated}}
50
+ [CAUTION]
51
+ ====
52
+ This command is deprecated{{#if deprecatedInVersion}} as of {{deprecatedInVersion}}{{/if}}.
53
+ {{#if replacement}} {{{replacement}}}{{/if}}
54
+ ====
55
+ {{/if}}
56
+ ```
57
+
58
+ This generates:
59
+
60
+ ```asciidoc
61
+ [CAUTION]
62
+ ====
63
+ This command is deprecated as of v26.x. Use xref:reference:rpk/rpk-cluster/rpk-cluster.adoc[`rpk cluster`] instead.
64
+ ====
65
+ ```
66
+
67
+ ## Integration
68
+
69
+ The deprecation scanner runs automatically during documentation generation:
70
+
71
+ 1. **After building rpk** - Source code is available
72
+ 2. **Before generating docs** - Overrides are updated first
73
+ 3. **Writes to overrides.json** - Automatically merges detected deprecations
74
+
75
+ To trigger manually:
76
+
77
+ ```bash
78
+ # Scan source and update overrides
79
+ node tools/rpk-docs/scan-deprecated-commands.js \
80
+ /path/to/redpanda/src/go/rpk \
81
+ docs-data/rpk-overrides.json
82
+ ```
83
+
84
+ ## Currently Detected Commands
85
+
86
+ As of the latest scan (June 8, 2026):
87
+
88
+ 1. `rpk benchmark` - Hidden (internal testing command)
89
+ 2. `rpk cloud resourcegroup` - Hidden (internal/unreleased)
90
+ 3. `rpk redpanda admin` - **Deprecated** in favor of `rpk cluster`
91
+ 4. `rpk redpanda admin brokers` - **Deprecated** → use `rpk cluster brokers`
92
+ 5. `rpk redpanda admin config` - **Deprecated** → use `rpk cluster config`
93
+ 6. `rpk redpanda admin partitions` - **Deprecated** → use `rpk cluster info --detailed`
94
+
95
+ ## Limitations
96
+
97
+ ### Hidden Commands Not in Tree
98
+
99
+ Commands with `Hidden: true` don't appear in `rpk --print-tree` output, so:
100
+ - They won't be auto-generated from scratch
101
+ - Existing docs files are preserved
102
+ - Overrides contain metadata for future use
103
+
104
+ ### Subcommand Detection
105
+
106
+ The scanner finds commands with their own `NewCommand()` function but doesn't detect subcommands added via `cmd.AddCommand()`. For example:
107
+
108
+ - ✅ Detects: `rpk redpanda admin` (has NewCommand in admin.go)
109
+ - ❌ Misses: `rpk redpanda admin brokers list` (subcommand of brokers)
110
+
111
+ **Workaround**: Manually add subcommand deprecations to overrides or enhance scanner to follow AddCommand calls.
112
+
113
+ ## File Locations
114
+
115
+ - **Scanner**: `tools/rpk-docs/scan-deprecated-commands.js`
116
+ - **Integration**: `tools/rpk-docs/rpk-docs-handler.js` (Step 5)
117
+ - **Template**: `tools/rpk-docs/templates/command.hbs`
118
+ - **Overrides**: `docs-data/rpk-overrides.json`
119
+
120
+ ## Future Enhancements
121
+
122
+ 1. **Follow AddCommand chains** - Detect all subcommand deprecations
123
+ 2. **Extract deprecation versions** - Parse version info from comments/git history
124
+ 3. **Build xref links automatically** - Generate xrefs from deprecation messages
125
+ 4. **Scan for removed commands** - Compare against previous versions to detect removals
126
+
127
+ ## Example: rpk redpanda admin
128
+
129
+ ### Source Code
130
+
131
+ ```go
132
+ func NewCommand(fs afero.Fs, p *config.Params) *cobra.Command {
133
+ cmd := &cobra.Command{
134
+ Use: "admin",
135
+ Short: "Talk to the Redpanda admin listener",
136
+ Hidden: true,
137
+ Deprecated: "use `rpk cluster` subcommands; see `rpk cluster brokers`, `rpk cluster info --detailed`, `rpk cluster config list --node-id`, and `rpk cluster loggers`",
138
+ }
139
+ // ...
140
+ }
141
+ ```
142
+
143
+ ### Generated Override
144
+
145
+ ```json
146
+ {
147
+ "rpk redpanda admin": {
148
+ "deprecated": true,
149
+ "replacement": "Use xref:reference:rpk/rpk-cluster/rpk-cluster.adoc[`rpk cluster`] instead.",
150
+ "deprecatedMessage": "use `rpk cluster` subcommands; see `rpk cluster brokers`, `rpk cluster info --detailed`, `rpk cluster config list --node-id`, and `rpk cluster loggers`",
151
+ "_note": "Hidden: true, found by scanning Go source"
152
+ }
153
+ }
154
+ ```
155
+
156
+ ### Generated Documentation
157
+
158
+ ```asciidoc
159
+ = rpk redpanda admin
160
+ :unsupported-os: macOS, Windows
161
+
162
+ include::reference:partial$unsupported-os-rpk.adoc[]
163
+
164
+ [CAUTION]
165
+ ====
166
+ This command is deprecated. Use xref:reference:rpk/rpk-cluster/rpk-cluster.adoc[`rpk cluster`] instead.
167
+ ====
168
+
169
+ Talk to the Redpanda admin listener.
170
+ ```
171
+
172
+ ## Testing
173
+
174
+ Test the scanner:
175
+
176
+ ```bash
177
+ # Test on local source
178
+ node tools/rpk-docs/scan-deprecated-commands.js ~/Documents/redpanda/src/go/rpk
179
+
180
+ # Test with override merging
181
+ node tools/rpk-docs/scan-deprecated-commands.js \
182
+ ~/Documents/redpanda/src/go/rpk \
183
+ docs-data/rpk-overrides.json
184
+ ```
185
+
186
+ Test full automation:
187
+
188
+ ```bash
189
+ cd /path/to/docs
190
+ npx doc-tools generate rpk-docs --ref dev
191
+ # Check for "Scanning source for deprecated/hidden commands..." in output
192
+ ```
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Extract conditional blocks (ifdef/ifndef) and includes from original rpk docs
5
+ * and add them to rpk-overrides.json
6
+ */
7
+
8
+ const fs = require('fs')
9
+ const path = require('path')
10
+ const { spawnSync } = require('child_process')
11
+
12
+ const docsDir = process.argv[2] || '../docs'
13
+ const rpkDir = path.join(docsDir, 'modules/reference/pages/rpk')
14
+ const overridesPath = path.join(__dirname, '../../docs-data/rpk-overrides.json')
15
+
16
+ // Validate docsDir exists and is a directory
17
+ if (!fs.existsSync(docsDir) || !fs.statSync(docsDir).isDirectory()) {
18
+ console.error(`ERROR: Invalid docs directory: ${docsDir}`)
19
+ process.exit(1)
20
+ }
21
+
22
+ console.log('Extracting conditional content from original rpk docs...')
23
+ console.log(` Docs directory: ${docsDir}`)
24
+ console.log(` Overrides file: ${overridesPath}`)
25
+
26
+ // Get list of modified files using spawnSync to avoid command injection
27
+ const gitDiffResult = spawnSync('git', ['diff', '--name-only', 'modules/reference/pages/rpk/'], {
28
+ cwd: docsDir,
29
+ encoding: 'utf8'
30
+ })
31
+
32
+ if (gitDiffResult.error) {
33
+ console.error(`ERROR: Failed to run git diff: ${gitDiffResult.error.message}`)
34
+ process.exit(1)
35
+ }
36
+
37
+ const modifiedFilesOutput = gitDiffResult.stdout
38
+
39
+ const modifiedFiles = modifiedFilesOutput
40
+ .trim()
41
+ .split('\n')
42
+ .filter(f => f.endsWith('.adoc'))
43
+
44
+ console.log(`\nFound ${modifiedFiles.length} modified files`)
45
+
46
+ // Load existing overrides
47
+ let overrides = {}
48
+ if (fs.existsSync(overridesPath)) {
49
+ overrides = JSON.parse(fs.readFileSync(overridesPath, 'utf8'))
50
+ }
51
+
52
+ let extractedCount = 0
53
+ let filesWithContent = 0
54
+
55
+ for (const relPath of modifiedFiles) {
56
+ // Get the command path from filename
57
+ // e.g., "rpk/rpk-cluster/rpk-cluster-config.adoc" → "rpk cluster config"
58
+ const filename = path.basename(relPath, '.adoc')
59
+ const commandPath = filename.replace(/-/g, ' ')
60
+
61
+ // Get the original content (before changes)
62
+ try {
63
+ const gitShowResult = spawnSync('git', ['show', `HEAD:${relPath}`], {
64
+ cwd: docsDir,
65
+ encoding: 'utf8'
66
+ })
67
+
68
+ if (gitShowResult.error || gitShowResult.status !== 0) {
69
+ throw new Error(gitShowResult.stderr || 'git show failed')
70
+ }
71
+
72
+ const originalContent = gitShowResult.stdout
73
+
74
+ // Extract conditional blocks and includes
75
+ const conditionals = []
76
+ const includes = []
77
+
78
+ const lines = originalContent.split('\n')
79
+ let inConditional = false
80
+ let conditionalStart = -1
81
+ let conditionalLines = []
82
+ let conditionalType = null
83
+
84
+ for (let i = 0; i < lines.length; i++) {
85
+ const line = lines[i]
86
+
87
+ // Check for conditional start
88
+ if (/^(ifdef|ifndef)::/.test(line)) {
89
+ inConditional = true
90
+ conditionalStart = i
91
+ conditionalLines = [line]
92
+ conditionalType = line
93
+ } else if (inConditional) {
94
+ conditionalLines.push(line)
95
+ if (/^endif::/.test(line)) {
96
+ // End of conditional block
97
+ conditionals.push({
98
+ startLine: conditionalStart,
99
+ endLine: i,
100
+ type: conditionalType,
101
+ content: conditionalLines.join('\n')
102
+ })
103
+ inConditional = false
104
+ conditionalLines = []
105
+ }
106
+ }
107
+
108
+ // Check for includes
109
+ if (/^include::/.test(line)) {
110
+ includes.push({
111
+ line: i,
112
+ content: line
113
+ })
114
+ }
115
+ }
116
+
117
+ // Add to overrides if we found content
118
+ if (conditionals.length > 0 || includes.length > 0) {
119
+ filesWithContent++
120
+
121
+ if (!overrides[commandPath]) {
122
+ overrides[commandPath] = { content: [] }
123
+ }
124
+
125
+ if (!overrides[commandPath].content) {
126
+ overrides[commandPath].content = []
127
+ }
128
+
129
+ // Add conditionals
130
+ for (const cond of conditionals) {
131
+ const lines = cond.content.split('\n')
132
+ const isCloudOnly = cond.type.includes('ifdef::env-cloud')
133
+ const isSelfHostedOnly = cond.type.includes('ifndef::env-cloud')
134
+
135
+ // Extract the actual content (skip ifdef/endif lines)
136
+ const innerContent = lines.slice(1, -1).join('\n').trim()
137
+
138
+ if (innerContent) {
139
+ overrides[commandPath].content.push({
140
+ type: isCloudOnly ? 'cloud-only' : 'self-hosted',
141
+ position: 'after_description',
142
+ content: innerContent,
143
+ note: `Extracted from line ${cond.startLine + 1}`
144
+ })
145
+ extractedCount++
146
+ }
147
+ }
148
+
149
+ // Add includes
150
+ for (const inc of includes) {
151
+ overrides[commandPath].content.push({
152
+ type: 'include',
153
+ position: 'after_description',
154
+ path: inc.content,
155
+ note: `Extracted from line ${inc.line + 1}`
156
+ })
157
+ extractedCount++
158
+ }
159
+ }
160
+ } catch (err) {
161
+ console.warn(` ⚠ Could not read original ${relPath}: ${err.message}`)
162
+ }
163
+ }
164
+
165
+ console.log(`\nExtracted ${extractedCount} content items from ${filesWithContent} files`)
166
+
167
+ // Save overrides
168
+ fs.writeFileSync(overridesPath, JSON.stringify(overrides, null, 2))
169
+ console.log(`\n✓ Updated ${overridesPath}`)