@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.
- package/extensions/generate-rp-connect-info.js +94 -0
- package/macros/rp-connect-components.js +11 -139
- package/package.json +1 -1
- package/tools/redpanda-connect/helpers/renderConnectMetadata.js +4 -2
- package/tools/redpanda-connect/metadata-utils.js +11 -3
- package/tools/redpanda-connect/migrate-metadata-to-partials.js +28 -1
- package/tools/redpanda-connect/normalize-metadata.js +120 -0
|
@@ -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
|
-
|
|
1096
|
-
|
|
1097
|
-
//
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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,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.
|
|
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
|
|
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 &&
|
|
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 };
|