@redpanda-data/docs-extensions-and-macros 5.2.2 → 5.2.4

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.
@@ -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.2",
3
+ "version": "5.2.4",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -1,13 +1,15 @@
1
1
  'use strict';
2
2
 
3
3
  const { extractMetadata } = require('../metadata-utils.js');
4
+ const { normalizeMetadataBlock } = require('../normalize-metadata.js');
4
5
 
5
6
  /**
6
7
  * Handlebars helper: return the `== Metadata` block extracted from a connector
7
- * description, or '' when there is none. Used by the metadata partial template.
8
+ * description with consistent field-list formatting, or '' when there is none.
9
+ * Used by the metadata partial template.
8
10
  * @param {string} description
9
11
  * @returns {string}
10
12
  */
11
13
  module.exports = function renderConnectMetadata (description) {
12
- return extractMetadata(description);
14
+ return normalizeMetadataBlock(extractMetadata(description));
13
15
  };
@@ -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`.
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Normalize the formatting of an extracted `== Metadata` block so the generated
5
+ * metadata partials are consistent regardless of how each connector authored
6
+ * its metadata section upstream.
7
+ *
8
+ * Two inconsistencies are normalized:
9
+ * 1. Some connectors wrap their field list in a fenced code block
10
+ * (```text ... ``` or ``` ... ```) of bare names; others use an AsciiDoc
11
+ * bullet list with the field name in inline code. We strip the fences
12
+ * around a field list and render the fields as a plain bullet list.
13
+ * 2. Within a field bullet, the field name (a lowercase snake_case
14
+ * identifier) is wrapped in inline code. Descriptive bullets such as
15
+ * "All headers (only first values are taken)" are left as prose, and
16
+ * bullets that already contain inline code are left untouched.
17
+ *
18
+ * Everything else (intro sentences, notes, `===` subheadings that legitimately
19
+ * group metadata by operation, non-field-list fenced blocks, and AsciiDoc
20
+ * `----` literal blocks) is preserved verbatim.
21
+ */
22
+
23
+ const FENCE_OPEN = /^(\s*)(```|~~~)(.*)$/;
24
+ const BULLET = /^(\s*[-*]\s+)(.*)$/;
25
+ // AsciiDoc literal/listing block delimiter. Content inside `----` blocks is
26
+ // rendered verbatim, so backticks would show up literally — such regions must
27
+ // pass through untouched (no field-name inline-coding).
28
+ const BLOCK_DELIMITER = /^-{4,}$/;
29
+ // A metadata field bullet: the leading token is a lowercase snake_case
30
+ // identifier (gcs_key, http_server_verb, header), optionally followed by a
31
+ // parenthetical annotation (for example "(RFC3339)"), and optionally a
32
+ // description separated by ":" or " - ". This distinguishes real field bullets
33
+ // from descriptive ones like "All headers ..." (which start with a capital).
34
+ // The description may itself contain inline code, so it is matched loosely.
35
+ const FIELD_BULLET = /^([a-z][a-z0-9_]*)((?:\s*\([^)]*\))?(?:\s*(?::|-)\s.*)?)$/s;
36
+
37
+ function normalizeBullet (prefix, content) {
38
+ // Skip only when the field name itself is already inline-coded; a backtick
39
+ // later in the description must not prevent coding the field name.
40
+ if (content.startsWith('`')) return prefix + content;
41
+ const m = content.match(FIELD_BULLET);
42
+ if (!m) return prefix + content; // descriptive, leave as prose
43
+ return `${prefix}\`${m[1]}\`${m[2]}`; // inline-code the field name
44
+ }
45
+
46
+ /** True when a fenced block's info string and content are a bare field list. */
47
+ function isFieldListFence (infoString, contentLines) {
48
+ const info = infoString.trim();
49
+ if (info !== '' && info !== 'text') return false;
50
+ const nonBlank = contentLines.filter((l) => l.trim() !== '');
51
+ if (nonBlank.length === 0 || !nonBlank.every((l) => BULLET.test(l))) return false;
52
+ // Require at least one real field-name bullet. A fenced block of purely
53
+ // descriptive bullets (no snake_case field name) is not a field list, so its
54
+ // fences must be preserved rather than stripped.
55
+ return nonBlank.some((l) => {
56
+ const b = l.match(BULLET);
57
+ return b && FIELD_BULLET.test(b[2]);
58
+ });
59
+ }
60
+
61
+ /**
62
+ * @param {string} block the extracted `== Metadata` block
63
+ * @returns {string} the block with consistent field-list formatting
64
+ */
65
+ function normalizeMetadataBlock (block) {
66
+ if (!block || typeof block !== 'string') return block;
67
+ const lines = block.split('\n');
68
+ const out = [];
69
+
70
+ for (let i = 0; i < lines.length; i++) {
71
+ // AsciiDoc literal/listing block: pass the whole `----` … `----` region
72
+ // through verbatim. Field-name lines inside are rendered literally, so they
73
+ // must not be inline-coded (backticks would appear as visible characters).
74
+ if (BLOCK_DELIMITER.test(lines[i].trim())) {
75
+ out.push(lines[i]);
76
+ let j = i + 1;
77
+ for (; j < lines.length; j++) {
78
+ out.push(lines[j]);
79
+ if (BLOCK_DELIMITER.test(lines[j].trim())) break;
80
+ }
81
+ i = j;
82
+ continue;
83
+ }
84
+
85
+ const fence = lines[i].match(FENCE_OPEN);
86
+ if (fence) {
87
+ // Collect the fenced content up to a closing fence of the SAME marker
88
+ // (a ```-opened block is not closed by ~~~, and vice versa).
89
+ const marker = fence[2];
90
+ const content = [];
91
+ let j = i + 1;
92
+ let closed = false;
93
+ for (; j < lines.length; j++) {
94
+ if (lines[j].trim() === marker) { closed = true; break; }
95
+ content.push(lines[j]);
96
+ }
97
+ if (closed && isFieldListFence(fence[3], content)) {
98
+ // Drop the fences and render the fields as a normal bullet list.
99
+ for (const c of content) {
100
+ const b = c.match(BULLET);
101
+ out.push(b ? normalizeBullet(b[1], b[2]) : c);
102
+ }
103
+ } else {
104
+ // Not a field list (e.g. a YAML example) — keep verbatim.
105
+ out.push(lines[i]);
106
+ for (const c of content) out.push(c);
107
+ if (closed) out.push(lines[j]);
108
+ }
109
+ i = closed ? j : lines.length;
110
+ } else {
111
+ // Bare bullet outside a fence: normalize the field name too.
112
+ const b = lines[i].match(BULLET);
113
+ out.push(b ? normalizeBullet(b[1], b[2]) : lines[i]);
114
+ }
115
+ }
116
+
117
+ return out.join('\n');
118
+ }
119
+
120
+ module.exports = { normalizeMetadataBlock };