@redpanda-data/docs-extensions-and-macros 5.12.4 → 5.13.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.
- package/bin/doc-tools.js +39 -0
- package/cli-utils/big-int-json.js +85 -0
- package/extensions/render-property-descriptions.js +339 -0
- package/extensions/resolve-xrefs-in-attachments.js +24 -1
- package/package.json +7 -2
- package/tools/generate-cli-docs.js +15 -4
- package/tools/property-extractor/compare-properties.js +16 -5
- package/tools/property-extractor/generate-handlebars-docs.js +6 -1
- package/tools/redpanda-connect/generate-rpcn-connector-docs.js +275 -33
- package/tools/redpanda-connect/helpers/descriptionInclude.js +24 -0
- package/tools/redpanda-connect/helpers/index.js +3 -0
- package/tools/redpanda-connect/helpers/renderConnectDescription.js +125 -0
- package/tools/redpanda-connect/helpers/summaryAttribute.js +22 -0
- package/tools/redpanda-connect/metadata-utils.js +183 -35
- package/tools/redpanda-connect/migrate-descriptions-to-partials.js +275 -0
- package/tools/redpanda-connect/normalize-metadata.js +6 -4
- package/tools/redpanda-connect/pr-summary-formatter.js +51 -0
- package/tools/redpanda-connect/rpcn-connector-docs-handler.js +46 -7
- package/tools/redpanda-connect/templates/connector.hbs +17 -4
- package/tools/redpanda-connect/templates/descriptions-partials.hbs +21 -0
- package/tools/redpanda-connect/templates/intro.hbs +1 -1
|
@@ -18,8 +18,18 @@ const path = require('path');
|
|
|
18
18
|
|
|
19
19
|
const METADATA_HEADING = /^==\s+Metadata\s*$/;
|
|
20
20
|
// AsciiDoc listing/literal block delimiter (`----`, possibly longer). Lines
|
|
21
|
-
// inside such blocks must not be treated as headings.
|
|
21
|
+
// inside such blocks must not be treated as headings. Asciidoctor only reads a
|
|
22
|
+
// delimiter at column 0 -- an indented `----` is a literal paragraph -- so the
|
|
23
|
+
// test is against the raw line, never a trimmed one.
|
|
22
24
|
const BLOCK_DELIMITER = /^-{4,}$/;
|
|
25
|
+
// AsciiDoc literal block delimiter (`....`, possibly longer) -- as
|
|
26
|
+
// substitution-free as a ---- listing block, and just as liable to contain a
|
|
27
|
+
// brace or heading-shaped line that must not be touched.
|
|
28
|
+
const LITERAL_DELIMITER = /^\.{4,}$/;
|
|
29
|
+
// Markdown-style fence delimiter (``` or ~~~, possibly with a language tag).
|
|
30
|
+
// Descriptions and metadata blocks carry fenced examples alongside AsciiDoc
|
|
31
|
+
// ---- blocks.
|
|
32
|
+
const FENCE_DELIMITER = /^(`{3,}|~{3,})/;
|
|
23
33
|
// The metadata block ends at the next structural element. Besides the next
|
|
24
34
|
// level-2 heading, this also covers page-level constructs that follow the
|
|
25
35
|
// section when locateMetadata runs against a full reference page (not just a
|
|
@@ -29,6 +39,54 @@ const BLOCK_DELIMITER = /^-{4,}$/;
|
|
|
29
39
|
// swallow the trailing `include::...partial$fields[]` and `// end::single-source[]`.
|
|
30
40
|
const SECTION_END = /^(?:==\s+\S|include::|\/\/\s*(?:tag|end)::)/;
|
|
31
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Split `text` into lines annotated with whether each line sits inside (or
|
|
44
|
+
* delimits) a verbatim region: an AsciiDoc `----` listing block or a markdown
|
|
45
|
+
* ```/~~~ fence. Every scanner over connector prose walks the text through
|
|
46
|
+
* this, so fence interiors are treated exactly like listing-block interiors:
|
|
47
|
+
* content, never headings, never escapable prose, never section terminators.
|
|
48
|
+
*
|
|
49
|
+
* Layered state: while one delimiter kind is open, only its own closer
|
|
50
|
+
* matters. A `----` line inside a fence (or a fence line inside a `----`
|
|
51
|
+
* block) is content -- treating it as a delimiter leaks the state and
|
|
52
|
+
* swallows everything after it.
|
|
53
|
+
*
|
|
54
|
+
* This is the only such state machine in the tree on purpose. There used to
|
|
55
|
+
* be three, and they had already drifted: two tested the trimmed line and one
|
|
56
|
+
* the raw line, so an indented `----` opened a verbatim region for one
|
|
57
|
+
* scanner and was plain content for another, on the same description in the
|
|
58
|
+
* same pipeline. Asciidoctor itself only honours a delimiter at column 0, so
|
|
59
|
+
* the raw-line test is the correct one and the only one here.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} text
|
|
62
|
+
* @returns {Array<{line: string, verbatim: boolean}>}
|
|
63
|
+
*/
|
|
64
|
+
function annotateVerbatimLines (text) {
|
|
65
|
+
let inBlock = false;
|
|
66
|
+
let inLiteral = false;
|
|
67
|
+
let fence = null;
|
|
68
|
+
return String(text == null ? '' : text).split('\n').map((line) => {
|
|
69
|
+
if (inBlock) {
|
|
70
|
+
if (BLOCK_DELIMITER.test(line)) inBlock = false;
|
|
71
|
+
return { line, verbatim: true };
|
|
72
|
+
}
|
|
73
|
+
if (inLiteral) {
|
|
74
|
+
if (LITERAL_DELIMITER.test(line)) inLiteral = false;
|
|
75
|
+
return { line, verbatim: true };
|
|
76
|
+
}
|
|
77
|
+
if (fence) {
|
|
78
|
+
const closer = line.match(FENCE_DELIMITER);
|
|
79
|
+
if (closer && closer[1][0] === fence) fence = null;
|
|
80
|
+
return { line, verbatim: true };
|
|
81
|
+
}
|
|
82
|
+
const fenceMatch = line.match(FENCE_DELIMITER);
|
|
83
|
+
if (fenceMatch) { fence = fenceMatch[1][0]; return { line, verbatim: true }; }
|
|
84
|
+
if (BLOCK_DELIMITER.test(line)) { inBlock = true; return { line, verbatim: true }; }
|
|
85
|
+
if (LITERAL_DELIMITER.test(line)) { inLiteral = true; return { line, verbatim: true }; }
|
|
86
|
+
return { line, verbatim: false };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
32
90
|
/**
|
|
33
91
|
* Locate the `== Metadata` section within a description.
|
|
34
92
|
* @param {string} description
|
|
@@ -39,22 +97,19 @@ const SECTION_END = /^(?:==\s+\S|include::|\/\/\s*(?:tag|end)::)/;
|
|
|
39
97
|
function locateMetadata (description) {
|
|
40
98
|
if (!description || typeof description !== 'string') return null;
|
|
41
99
|
|
|
42
|
-
const
|
|
100
|
+
const annotated = annotateVerbatimLines(description);
|
|
101
|
+
const lines = annotated.map((a) => a.line);
|
|
43
102
|
let headingLine = -1;
|
|
44
|
-
let
|
|
45
|
-
|
|
46
|
-
if (BLOCK_DELIMITER.test(lines[i])) { inBlock = !inBlock; continue; }
|
|
47
|
-
if (!inBlock && METADATA_HEADING.test(lines[i])) { headingLine = i; break; }
|
|
103
|
+
for (let i = 0; i < annotated.length; i++) {
|
|
104
|
+
if (!annotated[i].verbatim && METADATA_HEADING.test(lines[i])) { headingLine = i; break; }
|
|
48
105
|
}
|
|
49
106
|
if (headingLine === -1) return null;
|
|
50
107
|
|
|
51
108
|
// Find the terminating element after the metadata heading: the next level-2
|
|
52
109
|
// heading, an Antora include directive, or a single-source tag comment.
|
|
53
110
|
let endLine = lines.length;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (BLOCK_DELIMITER.test(lines[i])) { inBlock = !inBlock; continue; }
|
|
57
|
-
if (!inBlock && SECTION_END.test(lines[i])) { endLine = i; break; }
|
|
111
|
+
for (let i = headingLine + 1; i < annotated.length; i++) {
|
|
112
|
+
if (!annotated[i].verbatim && SECTION_END.test(lines[i])) { endLine = i; break; }
|
|
58
113
|
}
|
|
59
114
|
|
|
60
115
|
// Trim trailing blank lines inside the section so the block ends cleanly.
|
|
@@ -78,16 +133,64 @@ function extractMetadata (description) {
|
|
|
78
133
|
return found ? found.block : '';
|
|
79
134
|
}
|
|
80
135
|
|
|
136
|
+
// Rate limits are the one component family the upstream data and the docs
|
|
137
|
+
// repo spell differently: the dataset key is `rate-limits`, `item.type` is
|
|
138
|
+
// `rate_limit`, and the pages directory is `rate_limits`. Every spelling has
|
|
139
|
+
// to collapse to one partial directory or the write path and the include path
|
|
140
|
+
// disagree and the include does not resolve.
|
|
141
|
+
const TYPE_DIR_ALIASES = new Map([
|
|
142
|
+
['rate-limit', 'rate_limits'],
|
|
143
|
+
['rate-limits', 'rate_limits'],
|
|
144
|
+
['rate_limit', 'rate_limits'],
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The component families that have their own reference pages under
|
|
149
|
+
* `modules/components/pages/<typeDir>/`, in canonical typeDir spelling.
|
|
150
|
+
*
|
|
151
|
+
* This list existed in eight places and the copies disagreed three ways:
|
|
152
|
+
* `config` was in two of them, `rate_limits` was missing from one, and two
|
|
153
|
+
* carried both rate-limit spellings. A component family added upstream that
|
|
154
|
+
* is missed by one copy silently gets no partial, no page backfill or no
|
|
155
|
+
* cloud-docs check, depending on which copy forgot it.
|
|
156
|
+
* @type {string[]}
|
|
157
|
+
*/
|
|
158
|
+
const CONNECTOR_TYPE_DIRS = [
|
|
159
|
+
'inputs', 'outputs', 'processors', 'caches', 'rate_limits',
|
|
160
|
+
'buffers', 'metrics', 'scanners', 'tracers',
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Every top-level key of the connector dataset that holds component objects:
|
|
165
|
+
* the page-backed families plus `config`, which has entries but no per-entry
|
|
166
|
+
* page. Callers that walk the dataset (augmenting, diffing, stripping) want
|
|
167
|
+
* this; callers that write per-page artifacts want CONNECTOR_TYPE_DIRS.
|
|
168
|
+
* @type {string[]}
|
|
169
|
+
*/
|
|
170
|
+
const CONNECTOR_DATA_KEYS = [...CONNECTOR_TYPE_DIRS, 'config'];
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Canonical partial directory for a plural type directory. Applied to every
|
|
174
|
+
* derivation of a type directory so `typeDirFor` is the single source of truth
|
|
175
|
+
* for both the file the generator writes and the include line a page carries.
|
|
176
|
+
* @param {string} typeDir
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
function normalizeTypeDir (typeDir) {
|
|
180
|
+
return TYPE_DIR_ALIASES.get(typeDir) || typeDir;
|
|
181
|
+
}
|
|
182
|
+
|
|
81
183
|
/**
|
|
82
|
-
* Derive the plural type directory (for example `input` ->
|
|
184
|
+
* Derive the plural, canonical type directory (for example `input` ->
|
|
185
|
+
* `inputs`, `rate-limits` -> `rate_limits`).
|
|
83
186
|
* @param {object} item connector data with `type` and/or `typeDir`
|
|
84
187
|
* @returns {string}
|
|
85
188
|
*/
|
|
86
189
|
function typeDirFor (item) {
|
|
87
|
-
if (item && item.typeDir) return item.typeDir;
|
|
190
|
+
if (item && item.typeDir) return normalizeTypeDir(item.typeDir);
|
|
88
191
|
const type = item && item.type;
|
|
89
192
|
if (!type) return '';
|
|
90
|
-
return type.endsWith('s') ? type : `${type}s
|
|
193
|
+
return normalizeTypeDir(type.endsWith('s') ? type : `${type}s`);
|
|
91
194
|
}
|
|
92
195
|
|
|
93
196
|
/**
|
|
@@ -139,9 +242,22 @@ function descriptionWithMetadataInclude (item) {
|
|
|
139
242
|
return description.slice(0, found.start) + metadataIncludeLine(item) + description.slice(found.end);
|
|
140
243
|
}
|
|
141
244
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
245
|
+
/**
|
|
246
|
+
* Build the Antora include directive for a connector's description partial.
|
|
247
|
+
* Mirrors {@link metadataIncludeLine} so a drafted page can pull its
|
|
248
|
+
* auto-generated summary and description from a regenerated partial instead of
|
|
249
|
+
* freezing them into the page body at first draft.
|
|
250
|
+
*
|
|
251
|
+
* The partial defines two tag regions, so pages address it twice: `attrs` in
|
|
252
|
+
* the page header (sets `:description:`) and `body` where the prose renders.
|
|
253
|
+
* @param {object} item connector data with `type`/`typeDir` and `name`
|
|
254
|
+
* @param {string} [tag] tag region to include (`attrs` or `body`); omit for the whole file
|
|
255
|
+
* @returns {string}
|
|
256
|
+
*/
|
|
257
|
+
function descriptionIncludeLine (item, tag) {
|
|
258
|
+
const attrs = tag ? `tag=${tag}` : '';
|
|
259
|
+
return `include::connect:components:partial$descriptions/${typeDirFor(item)}/${item.name}.adoc[${attrs}]`;
|
|
260
|
+
}
|
|
145
261
|
|
|
146
262
|
/**
|
|
147
263
|
* Collect the section heading titles in an AsciiDoc block, ignoring lines
|
|
@@ -155,25 +271,8 @@ const FENCE_DELIMITER = /^(`{3,}|~{3,})/;
|
|
|
155
271
|
function sectionHeadings (text) {
|
|
156
272
|
if (!text || typeof text !== 'string') return [];
|
|
157
273
|
const headings = [];
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
for (const line of text.split('\n')) {
|
|
161
|
-
// Layered state: while inside one delimiter kind, the only thing that
|
|
162
|
-
// matters is its own closer. A fence-like line inside a ---- literal
|
|
163
|
-
// block (or a ---- line inside a fence) is content, not a delimiter —
|
|
164
|
-
// treating it as one leaks the state and swallows every later heading.
|
|
165
|
-
if (inBlock) {
|
|
166
|
-
if (BLOCK_DELIMITER.test(line)) inBlock = false;
|
|
167
|
-
continue;
|
|
168
|
-
}
|
|
169
|
-
if (fence) {
|
|
170
|
-
const closer = line.match(FENCE_DELIMITER);
|
|
171
|
-
if (closer && closer[1][0] === fence) fence = null;
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
const fenceMatch = line.match(FENCE_DELIMITER);
|
|
175
|
-
if (fenceMatch) { fence = fenceMatch[1][0]; continue; }
|
|
176
|
-
if (BLOCK_DELIMITER.test(line)) { inBlock = true; continue; }
|
|
274
|
+
for (const { line, verbatim } of annotateVerbatimLines(text)) {
|
|
275
|
+
if (verbatim) continue;
|
|
177
276
|
const m = line.match(/^=+\s+(\S.*)$/);
|
|
178
277
|
if (m) headings.push(m[1].trim());
|
|
179
278
|
}
|
|
@@ -196,13 +295,62 @@ function lostMetadataSections (oldContent, newContent) {
|
|
|
196
295
|
return sectionHeadings(oldContent).filter((h) => !newHeadings.has(h));
|
|
197
296
|
}
|
|
198
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Flatten AsciiDoc prose into a value usable as a `:description:` attribute
|
|
300
|
+
* and, downstream, as the page's `<meta name="description">`.
|
|
301
|
+
*
|
|
302
|
+
* Meta descriptions are read as plain text by search results, link previews
|
|
303
|
+
* and assistants, so markup has to come out mechanically: xref and link
|
|
304
|
+
* macros become their labels, bare URL macros become their labels, `<<>>`
|
|
305
|
+
* shorthand becomes its text, the "open in new tab" caret goes, inline code
|
|
306
|
+
* loses its backticks, and whitespace (including hard line breaks, which an
|
|
307
|
+
* attribute value cannot carry) collapses to single spaces. No prose is
|
|
308
|
+
* edited.
|
|
309
|
+
*
|
|
310
|
+
* This is the one implementation: `summaryAttribute` (the description
|
|
311
|
+
* partial's attrs region) and `backfillPageDescriptions` (the page-header
|
|
312
|
+
* self-heal) both call it, so the two paths cannot drift into publishing
|
|
313
|
+
* different meta descriptions for the same summary.
|
|
314
|
+
*
|
|
315
|
+
* @param {string} text
|
|
316
|
+
* @returns {string}
|
|
317
|
+
*/
|
|
318
|
+
function flattenToAttributeValue (text) {
|
|
319
|
+
if (!text) return '';
|
|
320
|
+
return String(text)
|
|
321
|
+
.replace(/(?:xref|link):[^\[\]]+\[([^\]]*)\]/g, '$1')
|
|
322
|
+
// Bare URL macros (no xref:/link: prefix) and internal xref shorthand
|
|
323
|
+
// also appear in source summaries and render literally if left in place.
|
|
324
|
+
.replace(/https?:\/\/[^\s\[\]]+\[([^\]]*)\]/g, '$1')
|
|
325
|
+
// image:: and inline image: macros have no textual value in a meta
|
|
326
|
+
// description; drop them rather than leaking the target path.
|
|
327
|
+
.replace(/image:{1,2}[^\[\]\s]*\[[^\]]*\]/g, '')
|
|
328
|
+
.replace(/<<[^,>]+,([^>]+)>>/g, '$1')
|
|
329
|
+
.replace(/<<([^>]+)>>/g, '$1')
|
|
330
|
+
// Strip the "open in new tab" caret that AsciiDoc URL macros carry
|
|
331
|
+
// inside the link text, e.g. [Open Telemetry collector^].
|
|
332
|
+
.replace(/\^/g, '')
|
|
333
|
+
.replace(/`([^`]*)`/g, '$1')
|
|
334
|
+
.replace(/\s+/g, ' ')
|
|
335
|
+
.trim();
|
|
336
|
+
}
|
|
337
|
+
|
|
199
338
|
module.exports = {
|
|
200
339
|
locateMetadata,
|
|
340
|
+
flattenToAttributeValue,
|
|
341
|
+
CONNECTOR_TYPE_DIRS,
|
|
342
|
+
CONNECTOR_DATA_KEYS,
|
|
343
|
+
normalizeTypeDir,
|
|
201
344
|
resolvePageTypeDir,
|
|
202
345
|
extractMetadata,
|
|
203
346
|
typeDirFor,
|
|
204
347
|
metadataIncludeLine,
|
|
205
348
|
descriptionWithMetadataInclude,
|
|
349
|
+
descriptionIncludeLine,
|
|
206
350
|
sectionHeadings,
|
|
207
351
|
lostMetadataSections,
|
|
352
|
+
annotateVerbatimLines,
|
|
353
|
+
BLOCK_DELIMITER,
|
|
354
|
+
LITERAL_DELIMITER,
|
|
355
|
+
FENCE_DELIMITER,
|
|
208
356
|
};
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One-time migration that makes published connector pages consume the
|
|
5
|
+
* regenerated description partial.
|
|
6
|
+
*
|
|
7
|
+
* Connector reference pages are one-time first drafts: the summary, the
|
|
8
|
+
* "Introduced in version" line and the description prose were frozen into
|
|
9
|
+
* `modules/components/pages/<type>/<name>.adoc` when the page was drafted, and
|
|
10
|
+
* nothing has refreshed them since. The generator now writes the same content
|
|
11
|
+
* to `modules/components/partials/descriptions/<type>/<name>.adoc` on every
|
|
12
|
+
* run, with two tag regions. This script rewires existing pages to it:
|
|
13
|
+
*
|
|
14
|
+
* header: include::...[tag=attrs] inside the page's `// tag::meta[]` region
|
|
15
|
+
* body: include::...[tag=body] where the frozen prose is today
|
|
16
|
+
*
|
|
17
|
+
* The header rewire is mechanical and safe: it swaps one `:description:` line
|
|
18
|
+
* for an include that sets the same attribute from the partial.
|
|
19
|
+
*
|
|
20
|
+
* The body rewire is a rewrite of published prose, so it is guarded. The page's
|
|
21
|
+
* intro region is only replaced when every non-blank line of it also appears in
|
|
22
|
+
* the partial's body region. Pages whose intro carries anything the partial does
|
|
23
|
+
* not reproduce (hand-added prose, a hand-written config listing, an extra
|
|
24
|
+
* admonition) are reported and left alone for a human, because migrating them
|
|
25
|
+
* would silently delete published content.
|
|
26
|
+
*
|
|
27
|
+
* Usage (run from the docs repo root, after a generator run so the partials
|
|
28
|
+
* exist):
|
|
29
|
+
* npx doc-tools generate migrate-rpcn-descriptions # dry run
|
|
30
|
+
* npx doc-tools generate migrate-rpcn-descriptions --write # apply
|
|
31
|
+
* npx doc-tools generate migrate-rpcn-descriptions --skip-body # headers only
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
const path = require('path');
|
|
36
|
+
const { descriptionIncludeLine, normalizeTypeDir, flattenToAttributeValue } = require('./metadata-utils.js');
|
|
37
|
+
|
|
38
|
+
// The page's meta tag region: the block single-source stubs inherit
|
|
39
|
+
// :description: from, so the attrs include belongs inside it.
|
|
40
|
+
const META_TAG_OPEN = /^\/\/\s*tag::meta\[\]\s*$/;
|
|
41
|
+
const META_TAG_CLOSE = /^\/\/\s*end::meta\[\]\s*$/;
|
|
42
|
+
|
|
43
|
+
// The intro region starts after the component-type dropdown macro, which every
|
|
44
|
+
// connector page carries directly below its header.
|
|
45
|
+
const INTRO_START = /^component_type_dropdown::\[\]\s*$/;
|
|
46
|
+
|
|
47
|
+
// ...and ends at the first structural element after it. `include::` covers the
|
|
48
|
+
// metadata, fields and examples partials; `[tabs]` covers the generated config
|
|
49
|
+
// listing; `== ` covers the first real section. Fenced code blocks are
|
|
50
|
+
// deliberately NOT terminators: a hand-written config listing inside the intro
|
|
51
|
+
// has to stay inside the region so the content check below sees it and refuses.
|
|
52
|
+
const INTRO_END = /^(?:==\s+\S|include::|\[tabs\]|\/\/\s*end::single-source)/;
|
|
53
|
+
|
|
54
|
+
/** Index of the intro anchor, or the end of the file when there is none. */
|
|
55
|
+
function introOf (lines) {
|
|
56
|
+
const at = lines.findIndex((l) => INTRO_START.test(l));
|
|
57
|
+
return at === -1 ? lines.length : at;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Extract the lines of one tag region from a partial. */
|
|
61
|
+
function tagRegion (partial, tag) {
|
|
62
|
+
const lines = partial.split('\n');
|
|
63
|
+
const open = lines.findIndex((l) => new RegExp(`^//\\s*tag::${tag}\\[\\]\\s*$`).test(l));
|
|
64
|
+
if (open === -1) return null;
|
|
65
|
+
const close = lines.findIndex((l, i) => i > open && new RegExp(`^//\\s*end::${tag}\\[\\]\\s*$`).test(l));
|
|
66
|
+
if (close === -1) return null;
|
|
67
|
+
return lines.slice(open + 1, close);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Lines that carry content: blanks and AsciiDoc comments do not count. */
|
|
71
|
+
function contentLines (lines) {
|
|
72
|
+
return lines.map((l) => l.trim()).filter((l) => l !== '' && !l.startsWith('//'));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* True when the partial's body region reproduces every content line of the
|
|
77
|
+
* page's intro region. This is the guard that keeps the migration from
|
|
78
|
+
* deleting published prose the generator does not know about.
|
|
79
|
+
*/
|
|
80
|
+
function bodyCoversIntro (introLines, bodyLines) {
|
|
81
|
+
const body = new Set(contentLines(bodyLines));
|
|
82
|
+
return contentLines(introLines).every((l) => body.has(l));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Migrate published connector pages onto the regenerated description partial.
|
|
87
|
+
* @param {object} [options]
|
|
88
|
+
* @param {boolean} [options.write=false] Apply changes (otherwise dry run).
|
|
89
|
+
* @param {boolean} [options.skipBody=false] Only rewire page headers.
|
|
90
|
+
* @param {string} [options.pagesRoot]
|
|
91
|
+
* @param {string} [options.partialsRoot]
|
|
92
|
+
* @returns {{headers:string[], bodies:string[], skipped:Array<{page:string, reason:string}>}}
|
|
93
|
+
*/
|
|
94
|
+
function migrateDescriptionsToPartials ({
|
|
95
|
+
write = false,
|
|
96
|
+
skipBody = false,
|
|
97
|
+
pagesRoot,
|
|
98
|
+
partialsRoot,
|
|
99
|
+
} = {}) {
|
|
100
|
+
const pages = pagesRoot || path.resolve(process.cwd(), 'modules/components/pages');
|
|
101
|
+
const partials = partialsRoot || path.resolve(process.cwd(), 'modules/components/partials/descriptions');
|
|
102
|
+
const results = { headers: [], bodies: [], skipped: [] };
|
|
103
|
+
if (!fs.existsSync(pages)) return results;
|
|
104
|
+
|
|
105
|
+
for (const typeDirName of fs.readdirSync(pages).sort()) {
|
|
106
|
+
const typeDirPath = path.join(pages, typeDirName);
|
|
107
|
+
if (!fs.statSync(typeDirPath).isDirectory()) continue;
|
|
108
|
+
// The pages directory and the partials directory can spell a component
|
|
109
|
+
// family differently (pages/rate_limits vs the rate-limits data key), so
|
|
110
|
+
// resolve the partial through the same normalizer the generator writes
|
|
111
|
+
// with rather than by string equality.
|
|
112
|
+
const partialTypeDir = normalizeTypeDir(typeDirName);
|
|
113
|
+
|
|
114
|
+
for (const file of fs.readdirSync(typeDirPath).sort()) {
|
|
115
|
+
if (!file.endsWith('.adoc')) continue;
|
|
116
|
+
const name = file.slice(0, -'.adoc'.length);
|
|
117
|
+
const key = `${typeDirName}/${name}`;
|
|
118
|
+
const pagePath = path.join(typeDirPath, file);
|
|
119
|
+
const partialPath = path.join(partials, partialTypeDir, file);
|
|
120
|
+
|
|
121
|
+
if (!fs.existsSync(partialPath)) {
|
|
122
|
+
results.skipped.push({ page: key, reason: 'no generated description partial' });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const partial = fs.readFileSync(partialPath, 'utf8');
|
|
126
|
+
const attrs = tagRegion(partial, 'attrs');
|
|
127
|
+
const body = tagRegion(partial, 'body');
|
|
128
|
+
if (!attrs || !body || contentLines(body).length === 0) {
|
|
129
|
+
// A blanked partial (description removed upstream) has both regions
|
|
130
|
+
// but no content. Wiring a page to it would publish an empty page.
|
|
131
|
+
results.skipped.push({ page: key, reason: 'description partial is empty' });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const includeAttrs = descriptionIncludeLine({ typeDir: partialTypeDir, name }, 'attrs');
|
|
136
|
+
const includeBody = descriptionIncludeLine({ typeDir: partialTypeDir, name }, 'body');
|
|
137
|
+
|
|
138
|
+
let lines = fs.readFileSync(pagePath, 'utf8').split('\n');
|
|
139
|
+
let changed = false;
|
|
140
|
+
|
|
141
|
+
// --- header ---------------------------------------------------------
|
|
142
|
+
if (!lines.some((l) => l.trim() === includeAttrs)) {
|
|
143
|
+
const open = lines.findIndex((l) => META_TAG_OPEN.test(l));
|
|
144
|
+
const close = lines.findIndex((l, i) => i > open && META_TAG_CLOSE.test(l));
|
|
145
|
+
const attrValue = contentLines(attrs)
|
|
146
|
+
.find((l) => l.startsWith(':description:'));
|
|
147
|
+
const pageAttr = lines.slice(0, introOf(lines)).find((l) => l.startsWith(':description:'));
|
|
148
|
+
if (!attrValue) {
|
|
149
|
+
// The connector has no summary, so the attrs region sets nothing.
|
|
150
|
+
// Replacing a hand-written :description: with it would drop the
|
|
151
|
+
// page's meta description.
|
|
152
|
+
results.skipped.push({ page: key, reason: 'attrs region sets no :description:' });
|
|
153
|
+
} else if (pageAttr && flattenToAttributeValue(pageAttr) !== flattenToAttributeValue(attrValue)) {
|
|
154
|
+
// The page's meta description is not the connector summary, so it
|
|
155
|
+
// was authored by hand (often tuned for search) and swapping in the
|
|
156
|
+
// include would silently discard that editorial work. 13 pages in
|
|
157
|
+
// rp-connect-docs are in this state. A human decides these: either
|
|
158
|
+
// move the wording upstream into the summary, or leave the page
|
|
159
|
+
// header alone.
|
|
160
|
+
results.skipped.push({ page: key, reason: 'hand-authored :description: differs from the summary (needs a human)' });
|
|
161
|
+
} else if (open !== -1 && close > open) {
|
|
162
|
+
lines.splice(open + 1, close - open - 1, includeAttrs);
|
|
163
|
+
results.headers.push(key);
|
|
164
|
+
changed = true;
|
|
165
|
+
} else {
|
|
166
|
+
// Header = everything above the dropdown macro, not "up to the
|
|
167
|
+
// first blank line": a handful of pages carry a blank line inside
|
|
168
|
+
// their attribute list, which is how they ended up with two
|
|
169
|
+
// :description: lines in the first place.
|
|
170
|
+
const attrLine = lines.findIndex((l) => l.startsWith(':description:'));
|
|
171
|
+
if (attrLine !== -1 && attrLine < introOf(lines)) {
|
|
172
|
+
lines.splice(attrLine, 1, '// tag::meta[]', includeAttrs, '// end::meta[]');
|
|
173
|
+
results.headers.push(key);
|
|
174
|
+
changed = true;
|
|
175
|
+
} else {
|
|
176
|
+
results.skipped.push({ page: key, reason: 'no header :description: to replace' });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// A handful of pages carry a blank line inside their header, so the
|
|
182
|
+
// page-header backfill inserted its meta block above a `:description:`
|
|
183
|
+
// it could not see and the page ended up with two. AsciiDoc takes the
|
|
184
|
+
// last assignment, so leaving the literal one there would make the
|
|
185
|
+
// include a no-op and the page would keep publishing frozen text.
|
|
186
|
+
if (lines.some((l) => l.trim() === includeAttrs)) {
|
|
187
|
+
for (let i = introOf(lines) - 1; i >= 0; i--) {
|
|
188
|
+
if (lines[i].startsWith(':description:')) {
|
|
189
|
+
lines.splice(i, 1);
|
|
190
|
+
changed = true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- body -----------------------------------------------------------
|
|
196
|
+
if (!skipBody && !lines.some((l) => l.trim() === includeBody)) {
|
|
197
|
+
const start = lines.findIndex((l) => INTRO_START.test(l));
|
|
198
|
+
if (start === -1) {
|
|
199
|
+
results.skipped.push({ page: key, reason: 'no component_type_dropdown to anchor the intro' });
|
|
200
|
+
} else {
|
|
201
|
+
let end = lines.length;
|
|
202
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
203
|
+
if (INTRO_END.test(lines[i])) { end = i; break; }
|
|
204
|
+
}
|
|
205
|
+
const intro = lines.slice(start + 1, end);
|
|
206
|
+
if (contentLines(intro).length === 0) {
|
|
207
|
+
results.skipped.push({ page: key, reason: 'intro region is empty' });
|
|
208
|
+
} else if (!bodyCoversIntro(intro, body)) {
|
|
209
|
+
results.skipped.push({
|
|
210
|
+
page: key,
|
|
211
|
+
reason: 'intro prose is not reproduced by the partial (needs a human)',
|
|
212
|
+
});
|
|
213
|
+
} else {
|
|
214
|
+
// The page's own metadata include is emitted by the partial body
|
|
215
|
+
// too, so consume it rather than leaving a duplicate behind.
|
|
216
|
+
let tail = end;
|
|
217
|
+
const metaInclude = `include::connect:components:partial$metadata/${partialTypeDir}/${name}.adoc[]`;
|
|
218
|
+
if (lines[tail] && lines[tail].trim() === metaInclude &&
|
|
219
|
+
contentLines(body).includes(metaInclude)) {
|
|
220
|
+
tail++;
|
|
221
|
+
}
|
|
222
|
+
// Some pages carry the rest of the description BELOW the generated
|
|
223
|
+
// config listing, so replacing the intro alone would leave the
|
|
224
|
+
// include and the surviving prose both rendering it. Measured on
|
|
225
|
+
// caches/redpanda, where an Antora build reported the same xref
|
|
226
|
+
// twice after the naive rewrite. Anything outside the region that
|
|
227
|
+
// the partial body also renders means the description is split
|
|
228
|
+
// across the page, which a human has to untangle.
|
|
229
|
+
const outside = contentLines(lines.slice(0, start + 1).concat(lines.slice(tail)));
|
|
230
|
+
const inBody = new Set(contentLines(body));
|
|
231
|
+
if (outside.some((l) => inBody.has(l))) {
|
|
232
|
+
results.skipped.push({
|
|
233
|
+
page: key,
|
|
234
|
+
reason: 'description prose continues past the config listing (needs a human)',
|
|
235
|
+
});
|
|
236
|
+
} else {
|
|
237
|
+
lines.splice(start + 1, tail - start - 1, '', includeBody, '');
|
|
238
|
+
results.bodies.push(key);
|
|
239
|
+
changed = true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (changed) {
|
|
246
|
+
console.log(`${write ? 'MIGRATE' : 'WOULD MIGRATE'}: ${key}`);
|
|
247
|
+
if (write) fs.writeFileSync(pagePath, lines.join('\n'), 'utf8');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
console.log(
|
|
253
|
+
`\n${write ? 'Migrated' : 'Would migrate'} ${results.headers.length} page header(s) and ` +
|
|
254
|
+
`${results.bodies.length} page body/bodies; ${results.skipped.length} left alone.`
|
|
255
|
+
);
|
|
256
|
+
const byReason = new Map();
|
|
257
|
+
for (const s of results.skipped) byReason.set(s.reason, (byReason.get(s.reason) || 0) + 1);
|
|
258
|
+
for (const [reason, count] of [...byReason].sort((a, b) => b[1] - a[1])) {
|
|
259
|
+
console.log(` ${count} ${reason}`);
|
|
260
|
+
}
|
|
261
|
+
if (!write) console.log('Dry run only. Re-run with --write to apply.');
|
|
262
|
+
|
|
263
|
+
return results;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = { migrateDescriptionsToPartials, tagRegion, bodyCoversIntro };
|
|
267
|
+
|
|
268
|
+
// Allow direct execution for local development; the supported entry point is
|
|
269
|
+
// `npx doc-tools generate migrate-rpcn-descriptions`.
|
|
270
|
+
if (require.main === module) {
|
|
271
|
+
migrateDescriptionsToPartials({
|
|
272
|
+
write: process.argv.includes('--write'),
|
|
273
|
+
skipBody: process.argv.includes('--skip-body'),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
@@ -24,8 +24,10 @@ const FENCE_OPEN = /^(\s*)(```|~~~)(.*)$/;
|
|
|
24
24
|
const BULLET = /^(\s*[-*]\s+)(.*)$/;
|
|
25
25
|
// AsciiDoc literal/listing block delimiter. Content inside `----` blocks is
|
|
26
26
|
// rendered verbatim, so backticks would show up literally — such regions must
|
|
27
|
-
// pass through untouched (no field-name inline-coding).
|
|
28
|
-
|
|
27
|
+
// pass through untouched (no field-name inline-coding). Shared with the
|
|
28
|
+
// scanners in metadata-utils rather than redeclared: three copies of this had
|
|
29
|
+
// already drifted into disagreeing about indented delimiters.
|
|
30
|
+
const { BLOCK_DELIMITER } = require('./metadata-utils.js');
|
|
29
31
|
// A metadata field bullet: the leading token is a lowercase snake_case
|
|
30
32
|
// identifier (gcs_key, http_server_verb, header), optionally followed by a
|
|
31
33
|
// parenthetical annotation (for example "(RFC3339)"), and optionally a
|
|
@@ -71,12 +73,12 @@ function normalizeMetadataBlock (block) {
|
|
|
71
73
|
// AsciiDoc literal/listing block: pass the whole `----` … `----` region
|
|
72
74
|
// through verbatim. Field-name lines inside are rendered literally, so they
|
|
73
75
|
// must not be inline-coded (backticks would appear as visible characters).
|
|
74
|
-
if (BLOCK_DELIMITER.test(lines[i]
|
|
76
|
+
if (BLOCK_DELIMITER.test(lines[i])) {
|
|
75
77
|
out.push(lines[i]);
|
|
76
78
|
let j = i + 1;
|
|
77
79
|
for (; j < lines.length; j++) {
|
|
78
80
|
out.push(lines[j]);
|
|
79
|
-
if (BLOCK_DELIMITER.test(lines[j]
|
|
81
|
+
if (BLOCK_DELIMITER.test(lines[j])) break;
|
|
80
82
|
}
|
|
81
83
|
i = j;
|
|
82
84
|
continue;
|
|
@@ -115,6 +115,29 @@ function renderBloblangChanges(data, includeVersion = false) {
|
|
|
115
115
|
return lines;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Render upstream description-structure findings as a collapsed section of
|
|
120
|
+
* the PR summary. These are advisory (the fix belongs in the Connect
|
|
121
|
+
* source), but console.warn output dies in the collapsed workflow log, so
|
|
122
|
+
* they surface here where the PR reviewer actually looks.
|
|
123
|
+
* @param {Array<{connector: string, message: string}>} reports
|
|
124
|
+
* @returns {string[]} lines (empty when there is nothing to report)
|
|
125
|
+
*/
|
|
126
|
+
function renderDescriptionReports(reports) {
|
|
127
|
+
if (!Array.isArray(reports) || reports.length === 0) return [];
|
|
128
|
+
const lines = [];
|
|
129
|
+
lines.push('<details>');
|
|
130
|
+
lines.push(`<summary>:pencil2: ${reports.length} description(s) need structure fixes upstream in the Connect source</summary>`);
|
|
131
|
+
lines.push('');
|
|
132
|
+
for (const r of reports) {
|
|
133
|
+
lines.push(`- ${r.message}`);
|
|
134
|
+
}
|
|
135
|
+
lines.push('');
|
|
136
|
+
lines.push('</details>');
|
|
137
|
+
lines.push('');
|
|
138
|
+
return lines;
|
|
139
|
+
}
|
|
140
|
+
|
|
118
141
|
/**
|
|
119
142
|
* Generate PR summary for multiple releases
|
|
120
143
|
* @param {object} masterDiff - Master diff with releases array
|
|
@@ -153,6 +176,7 @@ function generateMultiVersionPRSummary(masterDiff, binaryAnalysis = null, drafte
|
|
|
153
176
|
lines.push('<!-- PR_SUMMARY_START -->');
|
|
154
177
|
lines.push('');
|
|
155
178
|
lines.push(...renderLostSectionWarnings(masterDiff?.lostSectionWarnings));
|
|
179
|
+
lines.push(...renderDescriptionReports(masterDiff?.descriptionReports));
|
|
156
180
|
lines.push('## Redpanda Connect Documentation Update');
|
|
157
181
|
lines.push('');
|
|
158
182
|
lines.push(`**Multi-Release Update:** ${startVersion} → ${endVersion}`);
|
|
@@ -559,6 +583,7 @@ function generatePRSummary(diffData, binaryAnalysis = null, draftedConnectors =
|
|
|
559
583
|
lines.push('<!-- PR_SUMMARY_START -->');
|
|
560
584
|
lines.push('');
|
|
561
585
|
lines.push(...renderLostSectionWarnings(diffData.lostSectionWarnings));
|
|
586
|
+
lines.push(...renderDescriptionReports(diffData.descriptionReports));
|
|
562
587
|
|
|
563
588
|
// Detect if this is a master diff
|
|
564
589
|
if (!isMultiVersion && diffData.releases && diffData.totalSummary) {
|
|
@@ -1326,9 +1351,35 @@ function printPRSummary(diffData, binaryAnalysis = null, draftedConnectors = nul
|
|
|
1326
1351
|
console.log('\n' + summary + '\n');
|
|
1327
1352
|
}
|
|
1328
1353
|
|
|
1354
|
+
/**
|
|
1355
|
+
* Merge one generateRpcnConnectorDocs result into the accumulators that feed
|
|
1356
|
+
* this PR summary.
|
|
1357
|
+
*
|
|
1358
|
+
* Every report key the generator returns has to be collected at every call
|
|
1359
|
+
* site, or the summary is quietly incomplete for whichever path forgot one.
|
|
1360
|
+
* The draft call site pushed lostSectionWarnings inline and forgot
|
|
1361
|
+
* descriptionReports, so structure reports for newly drafted connectors --
|
|
1362
|
+
* the ones most likely to need an upstream fix -- never reached the summary.
|
|
1363
|
+
* Doing the merge in one place is what makes that structural rather than
|
|
1364
|
+
* something each call site has to remember.
|
|
1365
|
+
*
|
|
1366
|
+
* @param {object} result generator result
|
|
1367
|
+
* @param {{descriptionReports: Array, lostSectionWarnings: Array}} into accumulators, mutated in place
|
|
1368
|
+
* @returns {{descriptionReports: Array, lostSectionWarnings: Array}} the same accumulators
|
|
1369
|
+
*/
|
|
1370
|
+
function collectGeneratorReports (result, into) {
|
|
1371
|
+
if (!result) return into;
|
|
1372
|
+
into.descriptionReports.push(...(result.descriptionReports || []));
|
|
1373
|
+
into.lostSectionWarnings.push(...(result.lostSectionWarnings || []));
|
|
1374
|
+
return into;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1329
1377
|
module.exports = {
|
|
1378
|
+
collectGeneratorReports,
|
|
1330
1379
|
generatePRSummary,
|
|
1331
1380
|
generateMultiVersionPRSummary,
|
|
1332
1381
|
printPRSummary,
|
|
1382
|
+
renderDescriptionReports,
|
|
1383
|
+
renderLostSectionWarnings,
|
|
1333
1384
|
truncateToSentence
|
|
1334
1385
|
};
|