@redpanda-data/docs-extensions-and-macros 5.2.1 → 5.2.3

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
@@ -26,6 +26,7 @@ const {
26
26
  diffDirs,
27
27
  generatePropertyComparisonReport,
28
28
  updatePropertyOverridesWithVersion,
29
+ updatePropertiesJsonWithVersion,
29
30
  cleanupOldDiffs
30
31
  } = require('../cli-utils/diff-utils')
31
32
 
@@ -1002,6 +1003,12 @@ automation
1002
1003
 
1003
1004
  if (overridesPath && fs.existsSync(overridesPath)) {
1004
1005
  updatePropertyOverridesWithVersion(overridesPath, diffData, newTag)
1006
+ // The overrides were baked into the extracted JSON during Phase 1,
1007
+ // before the stamp above existed. Stamp the JSON too, so the
1008
+ // AsciiDoc generated in Phase 3 shows "Introduced in" for
1009
+ // properties new in this release instead of one release late.
1010
+ const extractedJsonPath = path.resolve(outputDir, 'attachments', `redpanda-properties-${newTag}.json`)
1011
+ updatePropertiesJsonWithVersion(extractedJsonPath, diffData, newTag)
1005
1012
  }
1006
1013
  }
1007
1014
  } catch (err) {
@@ -193,6 +193,88 @@ function updatePropertyOverridesWithVersion (overridesPath, diffData, newTag) {
193
193
  }
194
194
  }
195
195
 
196
+ /**
197
+ * Stamp version information for new properties directly into an extracted
198
+ * properties JSON file (redpanda-properties-<tag>.json).
199
+ *
200
+ * Overrides are baked into this JSON during extraction, which happens before
201
+ * updatePropertyOverridesWithVersion stamps new properties into the overrides
202
+ * file — so without this step the AsciiDoc rendered from the JSON misses the
203
+ * "Introduced in" line for properties added in the release being generated
204
+ * (it would only appear on the next release's run).
205
+ *
206
+ * @param {string} jsonPath - Path to the extracted properties JSON file
207
+ * @param {object} diffData - Diff data from property comparison
208
+ * @param {string} newTag - Version tag for new properties (e.g., "v26.1.13")
209
+ */
210
+ function updatePropertiesJsonWithVersion (jsonPath, diffData, newTag) {
211
+ try {
212
+ const newProperties = diffData.details?.newProperties || []
213
+ if (newProperties.length === 0) return
214
+ if (!fs.existsSync(jsonPath)) {
215
+ console.warn(`Warning: Cannot stamp versions: ${jsonPath} does not exist`)
216
+ return
217
+ }
218
+
219
+ // The stamp is a textual insertion, NOT a parse/re-serialize round-trip:
220
+ // JSON.stringify would corrupt values JavaScript numbers cannot represent
221
+ // (uint64 maxima like 18446744073709551615) and reformat floats (0.0 → 0)
222
+ // throughout the Python-generated file.
223
+ let raw = fs.readFileSync(jsonPath, 'utf8')
224
+ const indentUnit = (raw.match(/^([ \t]+)"/m) || [null, ' '])[1]
225
+
226
+ let stampedCount = 0
227
+ newProperties.forEach(prop => {
228
+ const updated = insertVersionIntoRawJson(raw, prop.name, newTag, indentUnit)
229
+ if (updated) {
230
+ raw = updated
231
+ stampedCount++
232
+ }
233
+ })
234
+
235
+ if (stampedCount > 0) {
236
+ JSON.parse(raw) // validate the surgical edits before touching the file
237
+ fs.writeFileSync(jsonPath, raw, 'utf8')
238
+ console.log(`Done: Stamped "Introduced in ${newTag}" on ${stampedCount} new ${stampedCount === 1 ? 'property' : 'properties'} in ${path.basename(jsonPath)}`)
239
+ }
240
+ } catch (error) {
241
+ console.error(`Warning: Failed to stamp versions into properties JSON: ${error.message}`)
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Insert a `"version": "<newTag>",` field at the top of one property's object
247
+ * in the raw JSON text, leaving every other byte untouched.
248
+ *
249
+ * @param {string} raw - Full JSON file content
250
+ * @param {string} propName - Property key to stamp
251
+ * @param {string} newTag - Version tag to insert
252
+ * @param {string} indentUnit - One level of the file's indentation
253
+ * @returns {string|null} Updated content, or null when the property was not
254
+ * found or already carries a version field.
255
+ */
256
+ function insertVersionIntoRawJson (raw, propName, newTag, indentUnit) {
257
+ // Anchor the search to the top-level "properties" section when present, so
258
+ // an identically named key elsewhere (e.g. under "definitions") is not hit.
259
+ const sectionMatch = raw.match(new RegExp(`^${indentUnit}"properties": \\{`, 'm'))
260
+ const searchFrom = sectionMatch ? raw.indexOf(sectionMatch[0]) : 0
261
+
262
+ const escapedName = propName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
263
+ const keyMatch = new RegExp(`^([ \t]+)"${escapedName}": \\{[ \t]*$`, 'm').exec(raw.slice(searchFrom))
264
+ if (!keyMatch) return null
265
+
266
+ const keyIndent = keyMatch[1]
267
+ const objStart = searchFrom + keyMatch.index + keyMatch[0].length
268
+
269
+ // Skip when the object already has a version field (scan up to its closing brace).
270
+ const close = new RegExp(`^${keyIndent}\\}`, 'm').exec(raw.slice(objStart))
271
+ const objBody = raw.slice(objStart, close ? objStart + close.index : raw.length)
272
+ if (new RegExp(`^${keyIndent}${indentUnit}"version":`, 'm').test(objBody)) return null
273
+
274
+ const insertion = `\n${keyIndent}${indentUnit}"version": ${JSON.stringify(newTag)},`
275
+ return raw.slice(0, objStart) + insertion + raw.slice(objStart)
276
+ }
277
+
196
278
  /**
197
279
  * Create a unified diff patch between two directories and clean them up.
198
280
  *
@@ -269,5 +351,6 @@ module.exports = {
269
351
  cleanupOldDiffs,
270
352
  generatePropertyComparisonReport,
271
353
  updatePropertyOverridesWithVersion,
354
+ updatePropertiesJsonWithVersion,
272
355
  diffDirs
273
356
  }
@@ -30,11 +30,25 @@ module.exports.register = function ({ config }) {
30
30
  return require('../cli-utils/octokit-client')
31
31
  }
32
32
 
33
+ // Translated CSV rows, kept for the documentsConverted hook below
34
+ let translatedRows = null
35
+
33
36
  // Use 'on' and return the promise so Antora waits for async completion
34
37
  this.on('contentClassified', ({ contentCatalog }) => {
35
38
  return processContent(contentCatalog)
36
39
  })
37
40
 
41
+ // Set sticky-bar page attributes (type context switcher and availability badges) so the UI
42
+ // templates (article.hbs) render them server-side, with no flash of client-side rearranging.
43
+ // This must run on documentsConverted: Antora extracts page attributes for UI templates in a
44
+ // header-only parse with Asciidoctor extensions disabled, so these attributes cannot be set
45
+ // from the component_type_dropdown macro during conversion. Mutating page.asciidoc.attributes
46
+ // after conversion is the supported way to feed computed attributes to UI templates.
47
+ this.on('documentsConverted', ({ contentCatalog }) => {
48
+ if (!translatedRows) return
49
+ setStickyBarPageAttributes(contentCatalog, translatedRows, logger)
50
+ })
51
+
38
52
  async function processContent (contentCatalog) {
39
53
  const redpandaConnect = contentCatalog.getComponents().find(component => component.name === 'connect')
40
54
  const redpandaCloud = contentCatalog.getComponents().find(component => component.name === 'cloud-data-platform')
@@ -56,6 +70,7 @@ module.exports.register = function ({ config }) {
56
70
  const parsedData = Papa.parse(csvData, { header: true, skipEmptyLines: true })
57
71
  const enrichedData = translateCsvData(parsedData, pages, logger)
58
72
  parsedData.data = enrichedData
73
+ translatedRows = enrichedData
59
74
 
60
75
  // Set csvData on all relevant components
61
76
  const componentsToEnrich = [redpandaConnect, redpandaCloud, preview].filter(Boolean)
@@ -343,4 +358,83 @@ module.exports.register = function ({ config }) {
343
358
 
344
359
  return csvCommercialNames
345
360
  }
361
+
362
+ /**
363
+ * Sets sticky-bar metadata attributes on connector pages after conversion, so the UI templates
364
+ * (article.hbs and the context-switcher partial) render the Type dropdown and availability
365
+ * badges server-side:
366
+ * - page-context-switcher: Type dropdown entries (when the connector has multiple types)
367
+ * - page-cloud-available + page-cloud-available-url: on Self-Managed pages with a Cloud variant
368
+ * - page-self-managed-available + page-self-managed-available-url: on Cloud pages with a
369
+ * Self-Managed variant
370
+ * - page-self-managed-only: on Self-Managed pages with no Cloud variant
371
+ */
372
+ function setStickyBarPageAttributes (contentCatalog, rows, logger) {
373
+ const pagesByUrl = new Map()
374
+ contentCatalog.getPages((page) => page.out && page.pub && page.asciidoc).forEach((page) => {
375
+ pagesByUrl.set(page.pub.url, page)
376
+ })
377
+
378
+ // Group CSV rows by connector name; each row is one type (input, output, and so on)
379
+ const rowsByConnector = new Map()
380
+ for (const row of rows) {
381
+ const connector = (row.connector || '').trim().toLowerCase()
382
+ if (!connector) continue
383
+ if (!rowsByConnector.has(connector)) rowsByConnector.set(connector, [])
384
+ rowsByConnector.get(connector).push(row)
385
+ }
386
+
387
+ let decoratedCount = 0
388
+ for (const connectorRows of rowsByConnector.values()) {
389
+ const isCloudSupported = connectorRows.some((row) => row.is_cloud_supported === 'y')
390
+ for (const row of connectorRows) {
391
+ // Self-Managed (Connect) variant of this connector page
392
+ const connectPage = row.redpandaConnectUrl && pagesByUrl.get(row.redpandaConnectUrl)
393
+ if (connectPage) {
394
+ const attrs = connectPage.asciidoc.attributes
395
+ if (connectorRows.length > 1) {
396
+ attrs['page-context-switcher'] = buildContextSwitcher(connectorRows, row, 'connect')
397
+ }
398
+ if (isCloudSupported && row.redpandaCloudUrl) {
399
+ attrs['page-cloud-available'] = 'true'
400
+ attrs['page-cloud-available-url'] = row.redpandaCloudUrl
401
+ } else if (!isCloudSupported) {
402
+ attrs['page-self-managed-only'] = 'true'
403
+ }
404
+ decoratedCount++
405
+ }
406
+ // Cloud variant of this connector page
407
+ const cloudPage = row.redpandaCloudUrl && pagesByUrl.get(row.redpandaCloudUrl)
408
+ if (cloudPage) {
409
+ const attrs = cloudPage.asciidoc.attributes
410
+ if (connectorRows.length > 1) {
411
+ attrs['page-context-switcher'] = buildContextSwitcher(connectorRows, row, 'cloud')
412
+ }
413
+ if (row.redpandaConnectUrl) {
414
+ attrs['page-self-managed-available'] = 'true'
415
+ attrs['page-self-managed-available-url'] = row.redpandaConnectUrl
416
+ }
417
+ decoratedCount++
418
+ }
419
+ }
420
+ }
421
+ logger.info(`Set sticky-bar metadata attributes on ${decoratedCount} connector pages`)
422
+ }
423
+
424
+ /**
425
+ * Builds the page-context-switcher JSON: one Type dropdown entry per connector type, linking to
426
+ * that type's page in the current site variant. The current page's type is listed first.
427
+ */
428
+ function buildContextSwitcher (connectorRows, currentRow, variant) {
429
+ const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1)
430
+ const orderedRows = [currentRow, ...connectorRows.filter((row) => row !== currentRow)]
431
+ const data = {}
432
+ for (const row of orderedRows) {
433
+ const link = (variant === 'cloud' && row.redpandaCloudUrl) || row.redpandaConnectUrl
434
+ if (!link) continue
435
+ const type = row.type.trim()
436
+ data[type.toLowerCase()] = { name: capitalize(type), to: link }
437
+ }
438
+ return JSON.stringify(data)
439
+ }
346
440
  }
@@ -1053,36 +1053,6 @@ module.exports.register = function (registry, context) {
1053
1053
  </div>
1054
1054
  </div>`);
1055
1055
  }
1056
- // Process types and metadata from CSV
1057
- const types = componentRows.map(row => ({
1058
- type: row.type.trim(),
1059
- support: row.support_level.trim(),
1060
- isCloudSupported: row.is_cloud_supported === 'y',
1061
- redpandaConnectUrl: row.redpandaConnectUrl,
1062
- redpandaCloudUrl: row.redpandaCloudUrl
1063
- }));
1064
- // Move the current page's type to the first position in the dropdown
1065
- const sortedTypes = [...types];
1066
- const currentTypeIndex = sortedTypes.findIndex(typeObj => typeObj.type === type);
1067
- if (currentTypeIndex !== -1) {
1068
- const [currentType] = sortedTypes.splice(currentTypeIndex, 1);
1069
- sortedTypes.unshift(currentType);
1070
- }
1071
-
1072
- // Set context-switcher attribute for UI template to render compact dropdown in sticky bar
1073
- if (sortedTypes.length > 1) {
1074
- const contextSwitcherData = {};
1075
- sortedTypes.forEach(typeObj => {
1076
- const link = (component === 'Cloud' && typeObj.redpandaCloudUrl) || typeObj.redpandaConnectUrl;
1077
- contextSwitcherData[typeObj.type.toLowerCase()] = {
1078
- name: capitalize(typeObj.type),
1079
- to: link
1080
- };
1081
- });
1082
- // Set as page attribute (not document attribute) so it's accessible in UI template
1083
- attributes['page-context-switcher'] = JSON.stringify(contextSwitcherData);
1084
- parent.getDocument().setAttribute('page-context-switcher', JSON.stringify(contextSwitcherData));
1085
- }
1086
1056
  // Check if the component requires an Enterprise license (based on support level)
1087
1057
  let enterpriseLicenseInfo = '';
1088
1058
  if (component !== 'Cloud') {
@@ -1092,121 +1062,23 @@ module.exports.register = function (registry, context) {
1092
1062
  <p><strong>License</strong>: This component requires an <a href="https://docs.redpanda.com/redpanda-connect/get-started/licensing/" target="_blank">enterprise license</a>. You can either <a href="https://www.redpanda.com/upgrade" target="_blank">upgrade to an Enterprise Edition license</a>, or <a href="http://redpanda.com/try-enterprise" target="_blank" rel="noopener">generate a trial license key</a> that's valid for 30 days.</p>`;
1093
1063
  }
1094
1064
  }
1095
- const isCloudSupported = componentRows.some(row => row.is_cloud_supported === 'y');
1096
-
1097
- // Set availability attributes for UI template badges with URLs
1098
- if (isCloudSupported) {
1099
- const cloudUrl = sortedTypes[0].redpandaCloudUrl;
1100
- const connectUrl = sortedTypes[0].redpandaConnectUrl;
1101
-
1102
- if (component === 'Connect' && cloudUrl) {
1103
- // Viewing Self-Managed, but also available in Cloud - show Cloud badge with link
1104
- parent.getDocument().setAttribute('cloud-available', 'true');
1105
- parent.getDocument().setAttribute('cloud-available-url', cloudUrl);
1106
- } else if (component === 'Cloud' && connectUrl) {
1107
- // Viewing Cloud, but also available in Self-Managed - show Self-Managed badge with link
1108
- parent.getDocument().setAttribute('self-managed-available', 'true');
1109
- parent.getDocument().setAttribute('self-managed-available-url', connectUrl);
1110
- }
1111
- } else if (!isCloudSupported && component === 'Connect') {
1112
- // Only available in Self-Managed - show "Self-Managed Only" badge (no link)
1113
- parent.getDocument().setAttribute('self-managed-only', 'true');
1114
- }
1115
-
1116
- let availableInInfo = '';
1117
-
1118
- if (isCloudSupported) {
1119
- const availableInLinks = [];
1120
-
1121
- // Check if the component is Cloud and apply the `current-version` class
1122
- if (sortedTypes[0].redpandaCloudUrl) {
1123
- if (component === 'Cloud') {
1124
- availableInLinks.push('<span title="You are viewing the Cloud version of this component" class="current-version">Cloud</span>'); // Highlight the current version
1125
- } else {
1126
- availableInLinks.push(`<a title="View the Cloud version of this component" href="${sortedTypes[0].redpandaCloudUrl}">Cloud</a>`);
1127
- }
1128
- }
1129
-
1130
- // Check if the component is Connect and apply the `current-version` class
1131
- if (sortedTypes[0].redpandaConnectUrl) {
1132
- if (component === 'Connect') {
1133
- availableInLinks.push('<span title="You are viewing the Self-Managed version of this component" class="current-version">Self-Managed</span>'); // Highlight the current version
1134
- } else {
1135
- availableInLinks.push(`<a title="View the Self-Managed version of this component" href="${sortedTypes[0].redpandaConnectUrl}">Self-Managed</a>`);
1136
- }
1137
- }
1138
- availableInInfo = `<p><strong>Available in:</strong> ${availableInLinks.join(', ')}</p>`;
1139
- } else {
1140
- availableInInfo = `<p><strong>Available in:</strong> <span title="You are viewing the Self-Managed version of this component" class="current-version">Self-Managed</span></p>`;
1141
- }
1142
- // Build the dropdown for types with links depending on the current component
1143
- let typeDropdown = '';
1144
- if (sortedTypes.length > 1) {
1145
- const dropdownOptions = sortedTypes.map(typeObj => {
1146
- const link = (component === 'Cloud' && typeObj.redpandaCloudUrl) || typeObj.redpandaConnectUrl;
1147
- return `<a href="${link}" class="dropdown-option" role="menuitem" tabindex="-1">${capitalize(typeObj.type)}</a>`;
1148
- }).join('');
1149
- typeDropdown = `
1150
- <div class="dropdown-wrapper">
1151
- <p class="type-dropdown-container"><strong>Type:</strong>
1152
- <button type="button" class="dropdown-toggle" id="componentTypeDropdownToggle" onclick="toggleComponentTypeDropdown()" aria-expanded="false" aria-haspopup="true" aria-controls="componentTypeDropdownMenu">
1153
- <span class="dropdown-text">${capitalize(sortedTypes[0].type)}</span>
1154
- <span class="dropdown-arrow">▼</span>
1155
- </button>
1156
- <div class="dropdown-menu" id="componentTypeDropdownMenu" role="menu" aria-labelledby="componentTypeDropdownToggle">
1157
- ${dropdownOptions}
1158
- </div>
1159
- </p>
1160
- </div>`;
1065
+ // The Type dropdown and availability info are rendered server-side in the sticky bar by the
1066
+ // UI (article.hbs) from page attributes (page-context-switcher, page-cloud-available, and
1067
+ // so on). Those attributes cannot be set from this macro: Antora extracts page attributes
1068
+ // for UI templates in a header-only parse that runs before conversion with Asciidoctor
1069
+ // extensions disabled, so anything set here never reaches the templates. The
1070
+ // generate-rp-connect-info extension sets them on page.asciidoc.attributes on the
1071
+ // documentsConverted event instead. Only the license notice belongs in the page body,
1072
+ // where it must stay visible.
1073
+ if (!enterpriseLicenseInfo) {
1074
+ return self.createBlock(parent, 'pass', '');
1161
1075
  }
1162
- // Return the metadata block with consistent layout
1163
1076
  return self.createBlock(parent, 'pass', `
1164
1077
  <div class="metadata-block">
1165
1078
  <div class="metadata-content">
1166
- ${typeDropdown}
1167
- ${availableInInfo}
1168
1079
  ${enterpriseLicenseInfo}
1169
1080
  </div>
1170
- </div>
1171
- <script>
1172
- // Define global dropdown functions directly (shared between macros)
1173
- if (!window.toggleComponentTypeDropdown) {
1174
- window.toggleComponentTypeDropdown = function() {
1175
- const toggle = document.getElementById('componentTypeDropdownToggle');
1176
- const menu = document.getElementById('componentTypeDropdownMenu');
1177
-
1178
- if (!toggle || !menu) return;
1179
-
1180
- const isOpen = menu.classList.contains('show');
1181
-
1182
- // Close all other dropdowns first (including filter dropdowns)
1183
- document.querySelectorAll('.dropdown-checkbox-menu.show, .dropdown-menu.show').forEach(dropdown => {
1184
- if (dropdown !== menu) {
1185
- dropdown.classList.remove('show');
1186
- const otherToggle = dropdown.parentNode.querySelector('.dropdown-checkbox-toggle, .dropdown-toggle');
1187
- if (otherToggle) {
1188
- otherToggle.classList.remove('open');
1189
- otherToggle.setAttribute('aria-expanded', 'false');
1190
- }
1191
- }
1192
- });
1193
-
1194
- // Toggle current dropdown
1195
- if (isOpen) {
1196
- menu.classList.remove('show');
1197
- toggle.classList.remove('open');
1198
- toggle.setAttribute('aria-expanded', 'false');
1199
- } else {
1200
- menu.classList.add('show');
1201
- toggle.classList.add('open');
1202
- toggle.setAttribute('aria-expanded', 'true');
1203
- // Focus first option
1204
- const firstOption = menu.querySelector('.dropdown-option');
1205
- if (firstOption) firstOption.focus();
1206
- }
1207
- };
1208
- }
1209
- </script>`);
1081
+ </div>`);
1210
1082
  });
1211
1083
  });
1212
1084
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.2.1",
3
+ "version": "5.2.3",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -14,10 +14,17 @@
14
14
  */
15
15
 
16
16
  const METADATA_HEADING = /^==\s+Metadata\s*$/;
17
- const LEVEL2_HEADING = /^==\s+\S/;
18
17
  // AsciiDoc listing/literal block delimiter (`----`, possibly longer). Lines
19
18
  // inside such blocks must not be treated as headings.
20
19
  const BLOCK_DELIMITER = /^-{4,}$/;
20
+ // The metadata block ends at the next structural element. Besides the next
21
+ // level-2 heading, this also covers page-level constructs that follow the
22
+ // section when locateMetadata runs against a full reference page (not just a
23
+ // connector description): Antora include directives (for example the fields or
24
+ // examples partials) and single-source tag comments. Without these, a metadata
25
+ // section that is the last heading on a page would run to end-of-string and
26
+ // swallow the trailing `include::...partial$fields[]` and `// end::single-source[]`.
27
+ const SECTION_END = /^(?:==\s+\S|include::|\/\/\s*(?:tag|end)::)/;
21
28
 
22
29
  /**
23
30
  * Locate the `== Metadata` section within a description.
@@ -38,12 +45,13 @@ function locateMetadata (description) {
38
45
  }
39
46
  if (headingLine === -1) return null;
40
47
 
41
- // Find the terminating heading (next level-2 heading after the metadata one).
48
+ // Find the terminating element after the metadata heading: the next level-2
49
+ // heading, an Antora include directive, or a single-source tag comment.
42
50
  let endLine = lines.length;
43
51
  inBlock = false;
44
52
  for (let i = headingLine + 1; i < lines.length; i++) {
45
53
  if (BLOCK_DELIMITER.test(lines[i])) { inBlock = !inBlock; continue; }
46
- if (!inBlock && LEVEL2_HEADING.test(lines[i])) { endLine = i; break; }
54
+ if (!inBlock && SECTION_END.test(lines[i])) { endLine = i; break; }
47
55
  }
48
56
 
49
57
  // Trim trailing blank lines inside the section so the block ends cleanly.
@@ -29,6 +29,21 @@ const PARTIAL_BANNER =
29
29
  const PAGES_ROOT = path.resolve(process.cwd(), 'modules/components/pages');
30
30
  const PARTIALS_ROOT = path.resolve(process.cwd(), 'modules/components/partials/metadata');
31
31
 
32
+ /**
33
+ * True when a generated metadata partial carries no actual metadata — either it
34
+ * is only the autogeneration banner / comments, or it has no `== Metadata`
35
+ * heading. The generator writes such a partial when the connector's upstream
36
+ * description has no `== Metadata` section.
37
+ * @param {string} partial
38
+ * @returns {boolean}
39
+ */
40
+ function isEmptyMetadataPartial (partial) {
41
+ // Reuse the same literal-block-aware parser used for extraction, so a
42
+ // `== Metadata` line inside a `----` code block (for example an example that
43
+ // literally contains that text) is not mistaken for a real metadata section.
44
+ return !locateMetadata(partial);
45
+ }
46
+
32
47
  /** Recursively collect .adoc files under a directory (minimatch glob). */
33
48
  function collectAdocFiles (dir) {
34
49
  if (!fs.existsSync(dir)) return [];
@@ -75,6 +90,18 @@ function migrateMetadataToPartials ({ write = false } = {}) {
75
90
  // migration works standalone but never clobbers generated content.
76
91
  const partialExists = fs.existsSync(partialPath);
77
92
 
93
+ // Guard against metadata loss: when the generator produced an EMPTY partial
94
+ // (the connector's upstream description has no == Metadata section), the
95
+ // page's inline block is the only real metadata. Migrating it to an include
96
+ // would replace live metadata with an empty partial. Skip these — the
97
+ // connector's upstream description must gain a == Metadata section before
98
+ // the page can be migrated.
99
+ if (partialExists && isEmptyMetadataPartial(fs.readFileSync(partialPath, 'utf8'))) {
100
+ console.log(`SKIP (generated partial is empty; keeping inline metadata): ${typeDir}/${name}`);
101
+ skipped++;
102
+ continue;
103
+ }
104
+
78
105
  console.log(`${write ? 'MIGRATE' : 'WOULD MIGRATE'}: ${typeDir}/${name}`);
79
106
  console.log(` -> partial: ${path.relative(process.cwd(), partialPath)}${partialExists ? ' (exists, kept)' : ' (seeded from page)'}`);
80
107
 
@@ -94,7 +121,7 @@ function migrateMetadataToPartials ({ write = false } = {}) {
94
121
  return { migrated, skipped };
95
122
  }
96
123
 
97
- module.exports = { migrateMetadataToPartials };
124
+ module.exports = { migrateMetadataToPartials, isEmptyMetadataPartial };
98
125
 
99
126
  // Allow direct execution for local development; the supported entry point is
100
127
  // `npx doc-tools generate migrate-rpcn-metadata`.