@redpanda-data/docs-extensions-and-macros 5.13.0 → 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.
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ // One annotator for every scanner over connector prose, including the ones in
4
+ // metadata-utils that run before these. See annotateVerbatimLines there for
5
+ // why there is exactly one.
6
+ const { descriptionWithMetadataInclude, annotateVerbatimLines } = require('../metadata-utils.js');
7
+
8
+ // Length (characters) above which a heading-less description is reported as a
9
+ // candidate for upstream structure. Roughly a screen of prose.
10
+ const LONG_HEADINGLESS_THRESHOLD = 1200;
11
+
12
+ /**
13
+ * True when the description contains at least one structural heading that sits
14
+ * outside a `----` listing block or a markdown ```/~~~ fence.
15
+ *
16
+ * Used to report long heading-less descriptions during generation. Embedded
17
+ * headings are otherwise passed through unchanged: they render as top-level
18
+ * page sections, which matches every published connector page today. Demoting
19
+ * them is not an option because the description renders before the page's
20
+ * first `==` section, so demoted headings produce "section title out of
21
+ * sequence" errors. Collapsible wrapping was evaluated and rejected: it hides
22
+ * primary content behind unreliable cross-browser find and deep-link behavior,
23
+ * and bodies containing their own `====` delimiters cannot be wrapped at all.
24
+ */
25
+ function hasStructuralHeadings (body) {
26
+ // AsciiDoc headings only. Markdown-style ## headings deliberately do NOT
27
+ // count: the descriptions that render worst are exactly the ones whose
28
+ // only structure is markdown headings, and counting them here exempted
29
+ // those from the long-description report (they get their own report).
30
+ return annotateVerbatimLines(body)
31
+ .some(({ line, verbatim }) => !verbatim && /^={2,}\s+\S/.test(line));
32
+ }
33
+
34
+ /**
35
+ * True when the description contains a markdown-style heading (`##`) outside
36
+ * listing blocks and fences. These are reported for upstream conversion to
37
+ * `==` (a `#` line inside a fence is a comment in an example, not a heading).
38
+ */
39
+ function hasMarkdownHeadings (body) {
40
+ return annotateVerbatimLines(body)
41
+ .some(({ line, verbatim }) => !verbatim && /^#{2,}\s+\S/.test(line));
42
+ }
43
+
44
+ /**
45
+ * Handlebars helper: render a connector's description for the regenerated
46
+ * description partial.
47
+ *
48
+ * The `== Metadata` block is replaced by an include of the metadata partial
49
+ * (de-duplicating it, since metadata is emitted as its own partial).
50
+ * Everything else passes through unchanged: the description is the page's
51
+ * primary content, and structure belongs upstream in the connector source.
52
+ * The generator reports long heading-less descriptions so they get headings
53
+ * added there instead of being hidden by the docs build.
54
+ *
55
+ * @param {object} item connector data (needs `description`, `type`/`typeDir`, `name`)
56
+ * @returns {string}
57
+ */
58
+ module.exports = function renderConnectDescription (item) {
59
+ const body = descriptionWithMetadataInclude(item);
60
+ if (!body || !body.trim()) return '';
61
+ return escapePlaceholderBraces(ensureHeadingSeparation(body.trim()));
62
+ };
63
+
64
+ /**
65
+ * Insert the blank line Asciidoctor requires before a section title. A
66
+ * heading glued to the paragraph above it renders as literal text and
67
+ * orphans its subsections into "section title out of sequence" warnings
68
+ * (live case: the protobuf processor's "== Operators"). Listing blocks and
69
+ * fences are untouched — a glued `#` comment inside a fenced example must
70
+ * not have a blank line pushed into the example.
71
+ */
72
+ function ensureHeadingSeparation (body) {
73
+ const out = [];
74
+ for (const { line, verbatim } of annotateVerbatimLines(body)) {
75
+ if (
76
+ !verbatim &&
77
+ /^(?:={2,6}|#{2,6})\s+\S/.test(line) &&
78
+ out.length > 0 &&
79
+ out[out.length - 1].trim() !== ''
80
+ ) {
81
+ out.push('');
82
+ }
83
+ out.push(line);
84
+ }
85
+ return out.join('\n');
86
+ }
87
+
88
+ /**
89
+ * Escape template placeholders like {endpoint} so Asciidoctor keeps them
90
+ * instead of consuming them as attribute references (which substitutes to
91
+ * nothing and logs "skipping reference to missing attribute" on the
92
+ * published page — live today on salesforce_graphql and otlp_http).
93
+ * Single-backtick spans still apply attribute substitution, so spans are
94
+ * escaped too; `----` listing blocks and ```/~~~ fences are verbatim and stay
95
+ * untouched (a `\{` inside a fence would render as a literal backslash).
96
+ */
97
+ function escapePlaceholderBraces (body) {
98
+ return annotateVerbatimLines(body).map(({ line, verbatim }) => (
99
+ verbatim ? line : line.replace(/(?<![\\{])\{([a-z][\w.-]{1,30})\}/g, '\\{$1}')
100
+ )).join('\n');
101
+ }
102
+
103
+ /**
104
+ * Depth of the first structural heading (AsciiDoc `=` or markdown-compat
105
+ * `#`) outside listing blocks and fences, or null when the body has none.
106
+ * Descriptions whose first heading is deeper than level one (`===`, `###`)
107
+ * render "section title out of sequence" on the page, so the generator
108
+ * reports them as upstream fixes (seen on aws_dynamodb_cdc, iceberg,
109
+ * protobuf).
110
+ */
111
+ function firstHeadingDepth (body) {
112
+ for (const { line, verbatim } of annotateVerbatimLines(body)) {
113
+ if (verbatim) continue;
114
+ const m = line.match(/^(={2,6}|#{2,6})\s+\S/);
115
+ if (m) return m[1].length;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ module.exports.hasStructuralHeadings = hasStructuralHeadings;
121
+ module.exports.hasMarkdownHeadings = hasMarkdownHeadings;
122
+ module.exports.escapePlaceholderBraces = escapePlaceholderBraces;
123
+ module.exports.ensureHeadingSeparation = ensureHeadingSeparation;
124
+ module.exports.firstHeadingDepth = firstHeadingDepth;
125
+ module.exports.LONG_HEADINGLESS_THRESHOLD = LONG_HEADINGLESS_THRESHOLD;
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ const { flattenToAttributeValue } = require('../metadata-utils.js');
4
+
5
+ /**
6
+ * Handlebars helper: flatten a connector summary into a single-line, plain-text
7
+ * value suitable for the `:description:` page attribute (and therefore for the
8
+ * page's `<meta name="description">`).
9
+ *
10
+ * Delegates to the shared flattener so the description partial's attrs region
11
+ * and `backfillPageDescriptions` publish the same text for the same summary.
12
+ * Collapsing whitespace alone is not enough: summaries carry xrefs, link
13
+ * macros and inline code, and an unflattened value ships raw
14
+ * `xref:guides:bloblang/about.adoc[Bloblang mapping]` markup into search
15
+ * snippets and link previews (verified in a real Antora build).
16
+ *
17
+ * @param {string} summary
18
+ * @returns {string}
19
+ */
20
+ module.exports = function summaryAttribute (summary) {
21
+ return flattenToAttributeValue(summary);
22
+ };
@@ -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 lines = description.split('\n');
100
+ const annotated = annotateVerbatimLines(description);
101
+ const lines = annotated.map((a) => a.line);
43
102
  let headingLine = -1;
44
- let inBlock = false;
45
- for (let i = 0; i < lines.length; i++) {
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
- inBlock = false;
55
- for (let i = headingLine + 1; i < lines.length; i++) {
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` -> `inputs`).
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
- // Markdown-style fence delimiter (``` or ~~~, possibly with a language tag).
143
- // Metadata blocks can carry fenced examples alongside AsciiDoc ---- blocks.
144
- const FENCE_DELIMITER = /^(`{3,}|~{3,})/;
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
- let inBlock = false;
159
- let fence = null;
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
  };