@redpanda-data/docs-extensions-and-macros 5.7.1 → 5.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/doc-tools.js CHANGED
@@ -1660,6 +1660,21 @@ automation
1660
1660
  return match
1661
1661
  }
1662
1662
  })
1663
+ // Antora resolves page metadata with a header-only parse that stops at
1664
+ // the first blank line. The chart README templates emit :description:
1665
+ // below the title's blank line, so the page shipped the generic site
1666
+ // meta description despite carrying one (found on k-connect-helm-spec,
1667
+ // DOC-2414 investigation). Relocate the first :description: into the
1668
+ // header, directly under the title.
1669
+ const descMatch = doc.match(/^:description:[^\n]*$/m)
1670
+ if (descMatch) {
1671
+ const headerEnd = doc.indexOf('\n\n')
1672
+ if (headerEnd !== -1 && doc.indexOf(descMatch[0]) > headerEnd) {
1673
+ doc = doc.replace(descMatch[0] + '\n', '')
1674
+ doc = doc.replace(/^(= .+)$/m, `$1\n${descMatch[0]}`)
1675
+ doc = doc.replace(/\n{3,}/g, '\n\n')
1676
+ }
1677
+ }
1663
1678
  fs.writeFileSync(outFile, doc, 'utf8')
1664
1679
 
1665
1680
  console.log(`Done: Wrote ${outFile}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.7.1",
3
+ "version": "5.8.0",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -55,7 +55,10 @@ function mergeOverrides(target, overrides) {
55
55
  throw new Error('Target must be a valid object');
56
56
  }
57
57
 
58
- const scalarKeys = ['description', 'type', 'annotated_field', 'version'];
58
+ // 'summary' earns its place here the hard way: overrides.json has carried
59
+ // summary overrides (zmq4, ffi) that were silently dropped because the key
60
+ // fell through every branch below. Summaries feed page meta descriptions.
61
+ const scalarKeys = ['description', 'summary', 'type', 'annotated_field', 'version'];
59
62
 
60
63
  for (const key in overrides) {
61
64
  // === Handle annotated_options ===
@@ -674,17 +677,120 @@ async function generateRpcnConnectorDocs(options) {
674
677
  }
675
678
  }
676
679
 
680
+ // Self-heal published page headers: pages are one-time drafts, so pages
681
+ // created before the template emitted :description: never got one.
682
+ let descriptionBackfill = { backfilled: [], skippedNoSummary: [] };
683
+ if (!writeFullDrafts) {
684
+ descriptionBackfill = backfillPageDescriptions(dataObj);
685
+ }
686
+
677
687
  return {
678
688
  partialsWritten,
679
689
  draftsWritten,
680
690
  partialFiles,
681
691
  draftFiles,
682
- lostSectionWarnings
692
+ lostSectionWarnings,
693
+ descriptionBackfill
683
694
  };
684
695
  }
685
696
 
697
+
698
+ // Connector page families that have per-component pages under
699
+ // modules/components/pages/. Other data keys (config, bloblang-functions,
700
+ // bloblang-methods) have no per-component page to backfill.
701
+ const PAGE_TYPE_DIRS = new Set([
702
+ 'inputs', 'outputs', 'processors', 'caches', 'buffers', 'metrics',
703
+ 'tracers', 'scanners', 'rate-limits', 'rate_limits'
704
+ ]);
705
+
706
+ /**
707
+ * Insert a tagged :description: into existing connector page headers that
708
+ * lack one, sourced from the component's summary in the connector data.
709
+ *
710
+ * Connector pages are one-time first drafts: pages drafted before the
711
+ * template emitted :description: never got one, and nothing rewrote them,
712
+ * so hundreds of published pages ship the generic site meta description
713
+ * even though ~97% of components carry a summary in the source data. This
714
+ * runs on every generation, so the page set self-heals and stays healed.
715
+ *
716
+ * The inserted block matches the connector template's shape: the meta tag
717
+ * region lets single-source stubs inherit the description as a header
718
+ * attribute (see the template comment in connector.hbs).
719
+ *
720
+ * @param {Object} connectorData - Parsed connector data (typed arrays)
721
+ * @param {Object} [options]
722
+ * @param {string} [options.pagesRoot] - Pages directory (default: modules/components/pages under cwd)
723
+ * @param {boolean} [options.dryRun] - Report without writing
724
+ * @returns {{backfilled: string[], skippedNoSummary: string[]}}
725
+ */
726
+ function backfillPageDescriptions (connectorData, { pagesRoot, dryRun = false } = {}) {
727
+ const root = pagesRoot || path.resolve(process.cwd(), 'modules/components/pages');
728
+ const results = { backfilled: [], skippedNoSummary: [] };
729
+ if (!fs.existsSync(root)) return results;
730
+
731
+ for (const [dataKey, items] of Object.entries(connectorData)) {
732
+ if (!PAGE_TYPE_DIRS.has(dataKey) || !Array.isArray(items)) continue;
733
+ // The data uses 'rate-limits' while the pages directory is
734
+ // 'rate_limits'. Resolve whichever spelling exists on disk.
735
+ const typeDir = fs.existsSync(path.join(root, dataKey))
736
+ ? dataKey
737
+ : dataKey.replace(/-/g, '_');
738
+ for (const item of items) {
739
+ if (!item || !item.name) continue;
740
+ const pagePath = path.join(root, typeDir, `${item.name}.adoc`);
741
+ if (!fs.existsSync(pagePath)) continue;
742
+
743
+ const content = fs.readFileSync(pagePath, 'utf8');
744
+ const lines = content.split('\n');
745
+ const headerEnd = lines.findIndex((l) => l.trim() === '');
746
+ const header = lines.slice(0, headerEnd === -1 ? lines.length : headerEnd);
747
+ if (header.some((l) => l.startsWith(':description:'))) continue;
748
+
749
+ // Meta descriptions are read as plain text by search results, link
750
+ // previews, and assistants, so flatten AsciiDoc markup mechanically:
751
+ // xrefs and links become their labels, inline code loses its
752
+ // backticks. No prose is edited.
753
+ const summary = (item.summary || '')
754
+ .replace(/(?:xref|link):[^\[\]]+\[([^\]]*)\]/g, '$1')
755
+ // Bare URL macros (no xref:/link: prefix) and internal xref shorthand
756
+ // also appear in source summaries and render literally if left in place.
757
+ .replace(/https?:\/\/[^\s\[\]]+\[([^\]]*)\]/g, '$1')
758
+ .replace(/<<[^,>]+,([^>]+)>>/g, '$1')
759
+ .replace(/<<([^>]+)>>/g, '$1')
760
+ // Strip the "open in new tab" caret that AsciiDoc URL macros carry
761
+ // inside the link text, e.g. [Open Telemetry collector^].
762
+ .replace(/\^/g, '')
763
+ .replace(/`([^`]*)`/g, '$1')
764
+ .replace(/\s+/g, ' ')
765
+ .trim();
766
+ if (!summary) {
767
+ results.skippedNoSummary.push(`${typeDir}/${item.name}`);
768
+ continue;
769
+ }
770
+
771
+ const insertAt = headerEnd === -1 ? lines.length : headerEnd;
772
+ lines.splice(insertAt, 0, '// tag::meta[]', `:description: ${summary}`, '// end::meta[]');
773
+ if (!dryRun) fs.writeFileSync(pagePath, lines.join('\n'), 'utf8');
774
+ results.backfilled.push(`${typeDir}/${item.name}`);
775
+ }
776
+ }
777
+
778
+ if (results.backfilled.length) {
779
+ console.log(`Backfilled :description: into ${results.backfilled.length} connector page header(s) from source summaries`);
780
+ }
781
+ if (results.skippedNoSummary.length) {
782
+ console.warn(
783
+ `\u26a0 ${results.skippedNoSummary.length} connector page(s) lack both a header description and a source summary: ` +
784
+ results.skippedNoSummary.join(', ') +
785
+ '. Author these in the overrides file or upstream.'
786
+ );
787
+ }
788
+ return results;
789
+ }
790
+
686
791
  module.exports = {
687
792
  generateRpcnConnectorDocs,
793
+ backfillPageDescriptions,
688
794
  mergeOverrides,
689
795
  resolveReferences
690
796
  };
@@ -1,6 +1,8 @@
1
1
  = Bloblang Functions
2
2
  // tag::single-source[]
3
+ // tag::meta[]
3
4
  :description: A list of Bloblang functions
5
+ // end::meta[]
4
6
 
5
7
  // © 2024 Redpanda Data Inc.
6
8
 
@@ -1,6 +1,8 @@
1
1
  = Bloblang Methods
2
2
  // tag::single-source[]
3
+ // tag::meta[]
3
4
  :description: A list of Bloblang methods
5
+ // end::meta[]
4
6
 
5
7
  // © 2024 Redpanda Data Inc.
6
8
 
@@ -3,7 +3,19 @@
3
3
  :type: {{type}}
4
4
  {{#if support}}:support: {{support}}{{/if}}
5
5
  :categories: [{{join categories ", "}}]
6
- :description: {{#if summary}}{{{summary}}}{{else if description}}{{description}}{{else}}// TODO: Missing description. Add a description in the overrides file or source data.{{/if}}
6
+ {{#if summary}}
7
+ // tag::meta[]
8
+ :description: {{{summary}}}
9
+ // end::meta[]
10
+ {{else if description}}
11
+ // tag::meta[]
12
+ :description: {{description}}
13
+ // end::meta[]
14
+ {{/if}}
15
+ {{!-- No fallback line when neither exists: an emitted TODO would become
16
+ the attribute VALUE and stubs would inherit it into their meta tags.
17
+ Missing descriptions are the tracked editorial backlog
18
+ (cloud-docs .github/description-allowlist.txt). --}}
7
19
 
8
20
  component_type_dropdown::[]
9
21
 
@@ -23,7 +23,7 @@ const { spawnSync } = require('child_process')
23
23
  * authoritative: dashified filenames cannot be reversed unambiguously
24
24
  * (rpk-ai-llm-provider could be `llm provider` or `llm-provider`).
25
25
  * @param {string} partialsDir - Directory of generated .adoc partials
26
- * @returns {Array<{file: string, title: string, description: string|undefined}>} Sorted by command path
26
+ * @returns {Array<{file: string, title: string, description: string|undefined, hasMetaTag: boolean}>} Sorted by command path
27
27
  */
28
28
  function readPartialTitles(partialsDir) {
29
29
  const partials = []
@@ -35,11 +35,18 @@ function readPartialTitles(partialsDir) {
35
35
  console.warn(`Warning: no title line in ${file}; skipping`)
36
36
  continue
37
37
  }
38
- // The partial repeats :description: inside its single-source tag, but
39
- // Antora resolves page metadata with a header-only parse that never sees
40
- // the include — the stub must carry the description in its own header.
38
+ // Antora resolves page metadata with a header-only parse that never
39
+ // sees the body include, so the stub must carry the description in its
40
+ // own header. Partials generated with doc-tools >= 5.4 carry a nested
41
+ // meta tag region around :description: that stubs include dynamically;
42
+ // older partials fall back to a literal copy of the header description.
41
43
  const description = (content.match(/^:description:[ \t]*(.+)$/m) || [])[1]
42
- partials.push({ file, title: match[1].trim(), description: description && description.trim() })
44
+ partials.push({
45
+ file,
46
+ title: match[1].trim(),
47
+ description: description && description.trim(),
48
+ hasMetaTag: content.includes('tag::meta['),
49
+ })
43
50
  }
44
51
  // Hierarchical order: sort by command words so parents precede children
45
52
  partials.sort((a, b) => {
@@ -121,11 +128,23 @@ function inferIncludePrefix(stubDir, plugin) {
121
128
  * @param {string} [params.description] - Meta description from the partial header
122
129
  * @param {string} params.includePrefix - Antora resource prefix
123
130
  * @param {Array<string>} params.attributes - Page attribute lines
131
+ * @param {boolean} [params.hasMetaTag] - Partial carries a tag::meta[] region (doc-tools >= 5.4)
124
132
  * @returns {string}
125
133
  */
126
- function renderStub({ title, file, description, includePrefix, attributes }) {
134
+ function renderStub({ title, file, description, includePrefix, attributes, hasMetaTag }) {
127
135
  const lines = [`= ${title}`]
128
- if (description) lines.push(`:description: ${description}`)
136
+ // The description mechanism is always the first header line after the
137
+ // title: a header include for partials with a meta tag region, or a
138
+ // literal copy as the fallback for partials generated before 5.4. Antora
139
+ // resolves page metadata with a header-only parse that stops at the first
140
+ // blank line, so either form must sit above it — an include below the
141
+ // blank line is never evaluated for attributes and the page ships the
142
+ // generic site description.
143
+ if (hasMetaTag) {
144
+ lines.push(`include::${includePrefix}${file}[tag=meta]`)
145
+ } else if (description) {
146
+ lines.push(`:description: ${description}`)
147
+ }
129
148
  for (const attr of attributes) lines.push(attr)
130
149
  lines.push('')
131
150
  lines.push(`include::${includePrefix}${file}[tag=single-source]`)
@@ -162,6 +181,7 @@ function reconcileStubs({
162
181
  const existingStubs = fs.readdirSync(stubDir).filter(f => f.endsWith('.adoc'))
163
182
 
164
183
  const created = []
184
+ const upgraded_ = []
165
185
  const deleted = []
166
186
  const keptNonStub = []
167
187
  const skippedAliasTargets = []
@@ -206,7 +226,34 @@ function reconcileStubs({
206
226
  fs.existsSync(stubDir) ? fs.readdirSync(stubDir).filter(f => f.endsWith('.adoc')) : []
207
227
  )
208
228
  for (const partial of partials) {
209
- if (remainingStubs.has(partial.file)) continue
229
+ if (remainingStubs.has(partial.file)) {
230
+ // Existing stub: upgrade it in place when the partial has gained the
231
+ // meta tag region and the stub does not yet include it. The header
232
+ // include is what lets the stub inherit :description: as page
233
+ // metadata, so upgrading here makes the consumer repo self-heal on
234
+ // the next sync instead of needing a hand-written migration PR.
235
+ if (partial.hasMetaTag && !dryRun) {
236
+ const stubPath = path.join(stubDir, partial.file)
237
+ const stubContent = fs.readFileSync(stubPath, 'utf8')
238
+ if (!stubContent.includes('[tag=meta]')) {
239
+ const metaInclude = `include::${includePrefix}${partial.file}[tag=meta]`
240
+ // A later attribute entry overrides an earlier one (verified
241
+ // empirically against an Antora build), so a literal
242
+ // :description: below the include would win and leave the
243
+ // include dead weight. These are generated reference stubs, so
244
+ // any literal is a static backfill the include supersedes:
245
+ // strip it. Hand-maintained prose stubs never pass through this
246
+ // reconciler, so intentionally curated descriptions are safe.
247
+ let upgraded = stubContent.replace(/^:description: .*\n/m, '')
248
+ upgraded = upgraded.replace(/^(= .+)$/m, `$1\n${metaInclude}`)
249
+ if (upgraded !== stubContent) {
250
+ fs.writeFileSync(stubPath, upgraded, 'utf8')
251
+ upgraded_.push(partial.file)
252
+ }
253
+ }
254
+ }
255
+ continue
256
+ }
210
257
  if (aliasClaims.has(partial.file)) {
211
258
  skippedAliasTargets.push({ file: partial.file, claimedBy: aliasClaims.get(partial.file) })
212
259
  continue
@@ -285,7 +332,7 @@ function reconcileStubs({
285
332
  }
286
333
  }
287
334
 
288
- return { created, deleted, keptNonStub, navUpdated, renameCandidates, skippedAliasTargets }
335
+ return { created, upgraded: upgraded_, deleted, keptNonStub, navUpdated, renameCandidates, skippedAliasTargets }
289
336
  }
290
337
 
291
338
  module.exports = {
@@ -6,7 +6,12 @@
6
6
  page at worst. Availability pages are excluded from cloud by not
7
7
  generating a stub for them in cloud-docs. --}}
8
8
  = {{commandPath}}
9
+ {{!-- Comment lines are legal inside an AsciiDoc header, so the meta tag
10
+ region can wrap the page's single description. Stub pages inherit it
11
+ with a header include (see the note at the single-source tag). --}}
12
+ // tag::meta[]
9
13
  :description: {{{wrapDescriptionPassthrough shortDesc}}}
14
+ // end::meta[]
10
15
  {{#if pageAliases}}
11
16
  :page-aliases: {{{pageAliases}}}
12
17
  {{/if}}
@@ -23,11 +28,24 @@
23
28
  {{/each}}
24
29
 
25
30
  // tag::single-source[]
26
- :description: {{{wrapDescriptionPassthrough shortDesc}}}
27
- {{!-- The description attribute is repeated inside the tagged region so
28
- single-sourcing consumers (stub pages that include this tag) inherit
29
- the meta description. page-aliases and page-platforms deliberately stay
30
- outside the tag: consumers must not inherit alias registrations. --}}
31
+ {{!-- Stub pages inherit this page's description by including the header's
32
+ meta tag region as a HEADER attribute. A stub that only includes
33
+ tag=single-source after a blank line never gets it: Antora resolves
34
+ page metadata with a header-only parse that stops at the stub's first
35
+ blank line, so the include is never evaluated for attributes and the
36
+ page ships the generic site description (verified empirically,
37
+ 2026-08-05, including that a headerless include destroys the body).
38
+ The working stub shape is two includes:
39
+
40
+ = Page title
41
+ include::...page.adoc[tag=meta] <- header: no blank line above
42
+ <- blank line ends the header
43
+ include::...page.adoc[tag=single-source]
44
+
45
+ Earlier revisions repeated :description: inside this region on the
46
+ wrong belief that it reached stub metadata by itself; it never did.
47
+ page-aliases and page-platforms deliberately stay outside both tag
48
+ regions: consumers must not inherit alias registrations. --}}
31
49
  {{!-- Deprecation notice --}}
32
50
  {{#if deprecated}}
33
51
  [CAUTION]