@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 CHANGED
@@ -772,9 +772,11 @@ automation
772
772
  .option('--template-fields <path>', 'Fields section partial template', path.resolve(__dirname, '../tools/redpanda-connect/templates/fields-partials.hbs'))
773
773
  .option('--template-examples <path>', 'Examples section partial template', path.resolve(__dirname, '../tools/redpanda-connect/templates/examples-partials.hbs'))
774
774
  .option('--template-metadata <path>', 'Metadata section partial template', path.resolve(__dirname, '../tools/redpanda-connect/templates/metadata-partials.hbs'))
775
+ .option('--template-description <path>', 'Description section partial template', path.resolve(__dirname, '../tools/redpanda-connect/templates/descriptions-partials.hbs'))
775
776
  .option('--template-bloblang <path>', 'Custom Handlebars template for bloblang function/method partials')
776
777
  .option('--overrides <path>', 'Optional JSON file with overrides', 'docs-data/overrides.json')
777
778
  .option('--include-bloblang', 'Include Bloblang functions and methods in generation')
779
+ .option('--prune-orphaned-descriptions', 'Allow the description-partial orphan sweep to blank more than 10% of the tree. Only use this when the connector dataset is confirmed complete: an incomplete dataset looks identical to a mass upstream deletion and the sweep blanks published content')
778
780
  .option('--cloud-version <version>', 'Cloud binary version (default: auto-detect latest)')
779
781
  .option('--cgo-version <version>', 'cgo binary version (default: same as cloud-version)')
780
782
  .option('--skip-intermediate', 'Skip intermediate release processing (legacy mode - only compare latest vs last documented)')
@@ -794,6 +796,43 @@ automation
794
796
  await handleRpcnConnectorDocs(options)
795
797
  })
796
798
 
799
+ /**
800
+ * generate migrate-rpcn-descriptions
801
+ *
802
+ * @description
803
+ * One-time migration that rewires published connector reference pages onto the
804
+ * regenerated description partial: the header `:description:` becomes
805
+ * `include::...[tag=attrs]` and the frozen intro prose becomes
806
+ * `include::...[tag=body]`. Afterwards `generate rpcn-connector-docs` keeps
807
+ * both fresh on every run.
808
+ *
809
+ * @why
810
+ * Connector pages are one-time first drafts, so their summary, "Introduced in
811
+ * version" line and description prose never change again even when the
812
+ * connector does. The body rewire is guarded: a page whose intro carries
813
+ * anything the partial does not reproduce is reported and left for a human,
814
+ * because rewriting it would delete published content.
815
+ *
816
+ * @example
817
+ * # Dry run (default): report the pages that would change
818
+ * npx doc-tools generate migrate-rpcn-descriptions
819
+ *
820
+ * # Apply the migration
821
+ * npx doc-tools generate migrate-rpcn-descriptions --write
822
+ *
823
+ * # Rewire page headers only, leaving the body prose alone
824
+ * npx doc-tools generate migrate-rpcn-descriptions --write --skip-body
825
+ */
826
+ automation
827
+ .command('migrate-rpcn-descriptions')
828
+ .description('One-time migration of frozen connector summaries and description prose onto the regenerated description partial. Dry run unless --write is given.')
829
+ .option('--write', 'Apply changes (default is a dry run that only reports)')
830
+ .option('--skip-body', 'Rewire page headers only, leaving the frozen intro prose in the page')
831
+ .action((options) => {
832
+ const { migrateDescriptionsToPartials } = require('../tools/redpanda-connect/migrate-descriptions-to-partials.js')
833
+ migrateDescriptionsToPartials({ write: Boolean(options.write), skipBody: Boolean(options.skipBody) })
834
+ })
835
+
797
836
  /**
798
837
  * generate migrate-rpcn-metadata
799
838
  *
@@ -0,0 +1,85 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * JSON parse and stringify that do not destroy integers a JS number cannot hold.
5
+ *
6
+ * Redpanda's property data carries the uint64 and int64 limits as the maximum and
7
+ * minimum of numeric properties: 18446744073709551615, 9223372036854775807 and
8
+ * -9223372036854775808. The Python extractor writes them exactly, because Python
9
+ * integers are arbitrary precision. Every stage after that is JavaScript, where
10
+ * the largest exactly-representable integer is 2^53 - 1, so a plain
11
+ * `JSON.parse` silently rounds all three -- 18446744073709551615 becomes
12
+ * 18446744073709552000, which is not the uint64 limit and is not a value the
13
+ * server accepts.
14
+ *
15
+ * That had been happening on every published dataset since April: the reference
16
+ * pages, the tooltip attachment and the Connect catalog all showed rounded
17
+ * values, and none of them agreed with the source data.
18
+ *
19
+ * `parse` returns those integers as BigInt, so they print exactly and can be
20
+ * written back unchanged by `stringify`. BigInt rather than a string because it
21
+ * is unambiguous: a value that was a number in the source can be told apart from
22
+ * a string that was authored as one, which is what makes the round trip safe.
23
+ *
24
+ * Numbers inside the safe range are untouched and stay plain numbers.
25
+ *
26
+ * KNOWN LIMITATION: this is a regex over the raw text, not a real JSON scanner,
27
+ * so it does not track string/escape state. A value that is legitimate JSON
28
+ * text APPEARING INSIDE an escaped string -- for example a Bloblang example
29
+ * embedded as an example value, `"{\"delay_for_ns\":110839937000000000}"` --
30
+ * is indistinguishable from real JSON structure to this regex, which inserts a
31
+ * quoted sentinel at that position and corrupts the document. This is confirmed
32
+ * against the real, published Connect component attachment, not a theoretical
33
+ * risk: parsing it throws "Expected ',' or ']' after array element". Do not
34
+ * apply this module to Connect/Bloblang-shaped JSON, or to any JSON whose string
35
+ * values may themselves contain escaped JSON-looking text. It has been checked
36
+ * against every redpanda-properties-*.json and property-overrides.json this
37
+ * repo could find (~50 files, every published version) with no false match, and
38
+ * is applied only to property-shaped data for that reason -- not because the
39
+ * risk is theoretical elsewhere.
40
+ */
41
+
42
+ // An integer literal too long to be safe, in JSON value position: after a colon,
43
+ // an opening bracket or a comma, and followed by a delimiter. A digit run inside
44
+ // a string cannot match, because a string value starts with a quote.
45
+ const UNSAFE_INT_RX = /([:[,]\s*)(-?\d{16,})(?=\s*[,}\]])/g
46
+ // Printable ASCII: a control character is not legal inside a JSON string, so a
47
+ // sentinel containing one produces JSON that will not parse.
48
+ const SENTINEL = '@@bigint:'
49
+ const SENTINEL_RX = new RegExp(`"${SENTINEL}(-?\\d+)"`, 'g')
50
+
51
+ /** True if the literal needs BigInt to survive a round trip. */
52
+ function isUnsafeInteger (digits) {
53
+ return !Number.isSafeInteger(Number(digits))
54
+ }
55
+
56
+ /**
57
+ * Parse JSON, returning integers outside the safe range as BigInt.
58
+ *
59
+ * @param {string} text - JSON text.
60
+ * @returns {*} The parsed value.
61
+ */
62
+ function parse (text) {
63
+ const shielded = String(text).replace(UNSAFE_INT_RX, (match, lead, digits) =>
64
+ isUnsafeInteger(digits) ? `${lead}"${SENTINEL}${digits}"` : match
65
+ )
66
+ return JSON.parse(shielded, (key, value) =>
67
+ typeof value === 'string' && value.startsWith(SENTINEL) ? BigInt(value.slice(SENTINEL.length)) : value
68
+ )
69
+ }
70
+
71
+ /**
72
+ * Stringify JSON, writing BigInt values back as bare integer literals.
73
+ *
74
+ * @param {*} value - Value to serialize.
75
+ * @param {number|string} [space] - Indentation, as JSON.stringify takes it.
76
+ * @returns {string} JSON text.
77
+ */
78
+ function stringify (value, space) {
79
+ const json = JSON.stringify(value, (key, entry) =>
80
+ typeof entry === 'bigint' ? `${SENTINEL}${entry.toString()}` : entry, space
81
+ )
82
+ return json === undefined ? json : json.replace(SENTINEL_RX, '$1')
83
+ }
84
+
85
+ module.exports = { parse, stringify, isUnsafeInteger }
@@ -0,0 +1,339 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Renders the `description` of every property in a
5
+ * redpanda-properties-<tag>.json attachment to HTML at build time, with
6
+ * Antora's own AsciiDoc loader and the site's own AsciiDoc config, and writes
7
+ * the result to `description_html`.
8
+ *
9
+ * Why this exists
10
+ * ---------------
11
+ * Property descriptions are authored in AsciiDoc and shipped as JSON, so
12
+ * something has to render them. The docs UI was doing it in the browser, with a
13
+ * hand-written parser covering seven constructs: backticks, prop:, config_ref:,
14
+ * glossterm:, link:, <<anchor>>, and xrefs pre-resolved by a regex extension.
15
+ *
16
+ * A whitelist of seven fails open. Everything else -- bold, italics, attribute
17
+ * references, character replacements, bare URLs, any macro this repo adds next
18
+ * -- reaches the reader as raw source, and nothing warns. In v26.2.1 that was 31
19
+ * of 689 properties, including seven that displayed a literal
20
+ * `include::reference:partial$internal-use-property.adoc[]` where an
21
+ * internal-use warning or a breaking-change notice should have been.
22
+ *
23
+ * Converting here removes the whole class of defect rather than the instances:
24
+ * Asciidoctor handles all of AsciiDoc, our own macros run as themselves (so
25
+ * glossterm's positional attributes cannot drift from macros/glossary.js), and
26
+ * a macro added next year works with no docs-ui change at all.
27
+ *
28
+ * Both fields ship. `description` stays because consumers read it -- including
29
+ * docs-ui versions older than the one that prefers `description_html`, which
30
+ * deploys separately. The output is minified to pay for the second field.
31
+ */
32
+
33
+ const loadAsciiDoc = require('@antora/asciidoc-loader')
34
+ const bigIntJson = require('../cli-utils/big-int-json')
35
+ const { buildPageIndex, propertyAnchor } = require('../macros/prop')
36
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
37
+
38
+ const PROPERTIES_JSON_RX = /^redpanda-properties-(v\d+\.\d+\.\d+(?:-[\w.]+)?)\.json$/
39
+ // xref:target[label] as authored in a description. The .adoc extension is
40
+ // optional: one real description writes xref:develop:transactions#tips[...].
41
+ const XREF_RX = /xref:([^[\]\s#]+?)(?:#([^[\]\s]*))?\[([^\]]*)\]/g
42
+ // <<anchor>> and <<anchor,display text>>, as authored in the description. The
43
+ // anchor must start with a letter or underscore: property anchors always do,
44
+ // and without that a C++ shift expression in a code span (`1<<20>>`) was read as
45
+ // a reference, rewritten, and then reported as a broken anchor.
46
+ const INTERNAL_REF_RX = /<<([A-Za-z_][\w.-]*)(?:,\s*([^<>]*?))?>>/g
47
+
48
+ module.exports.register = function () {
49
+ raiseListenerLimit(this)
50
+ const logger = this.getLogger('render-property-descriptions')
51
+
52
+ // The anchor index has to be built here, while page sources still exist:
53
+ // convert-documents.js deletes page.src.contents once conversion finishes
54
+ // (unless keepSource), and buildPageIndex reads that source to find which page
55
+ // documents which property. Building it at documentsConverted worked only
56
+ // because property-page-index happened to warm the same cache earlier, and
57
+ // without that every <<anchor>> silently degraded to plain text.
58
+ const anchorIndexes = new Map()
59
+ this.once('contentClassified', ({ contentCatalog }) => {
60
+ for (const attachment of propertyAttachments(contentCatalog)) {
61
+ const properties = readProperties(attachment, logger)
62
+ if (!properties) continue
63
+ const key = `${attachment.src.component}@${attachment.src.version || ''}`
64
+ if (anchorIndexes.has(key)) continue
65
+ anchorIndexes.set(key, anchorIndex(contentCatalog, attachment, properties, logger))
66
+ }
67
+ })
68
+
69
+ // Conversion itself waits for documentsConverted: page aliases and the rest of
70
+ // what page conversion relies on are only fully registered by then. Hooking
71
+ // earlier left an xref to an aliased page rendering as
72
+ // class="xref unresolved" while the same xref resolved fine on a real page.
73
+ // Attachments are still published after this event.
74
+ this.once('documentsConverted', ({ contentCatalog, siteAsciiDocConfig }) => {
75
+ const attachments = propertyAttachments(contentCatalog)
76
+ if (!attachments.length) return
77
+
78
+ for (const attachment of attachments) {
79
+ const where = `${attachment.src.component}@${attachment.src.version || 'unversioned'}`
80
+ const data = readDataset(attachment, logger)
81
+ if (!data) continue
82
+ const properties = data.properties
83
+
84
+ // Convert "as" a real page rather than a fabricated one. A hand-built
85
+ // file.pub is missing fields Antora's resolver needs, and resolving an
86
+ // xref to another module then throws instead of resolving.
87
+ const hostPage = findHostPage(contentCatalog, attachment)
88
+ if (!hostPage) {
89
+ logger.debug(`${where}: no reference page to convert descriptions as, skipped`)
90
+ continue
91
+ }
92
+ const anchors = anchorIndexes.get(`${attachment.src.component}@${attachment.src.version || ''}`) || new Map()
93
+
94
+ // Antora converts each page with its component version's own config
95
+ // (convert-documents.js builds one per component version and prefers it
96
+ // over the site config), so attributes declared in antora.yml -- env-cloud
97
+ // among them, which five descriptions branch on -- only exist there.
98
+ const componentVersion = contentCatalog.getComponentVersion
99
+ ? contentCatalog.getComponentVersion(attachment.src.component, attachment.src.version)
100
+ : undefined
101
+ const asciidocConfig = (componentVersion && componentVersion.asciidoc) || siteAsciiDocConfig
102
+
103
+ let rendered = 0
104
+ let failed = 0
105
+ const brokenAnchors = new Set()
106
+ const unresolved = new Set()
107
+ const xrefCache = new Map()
108
+ for (const [name, entry] of Object.entries(properties)) {
109
+ const description = entry && entry.description
110
+ if (typeof description !== 'string' || !description.trim()) continue
111
+ try {
112
+ const source = resolveInternalRefs(description, anchors, (anchor) => brokenAnchors.add(anchor))
113
+ const html = render(source, hostPage, contentCatalog, asciidocConfig)
114
+ if (html == null) continue
115
+ entry.description_html = html
116
+ // The attachment is a published download, and `xref:...[]` means
117
+ // nothing to someone reading the JSON on its own -- resolving it is
118
+ // why resolve-xrefs-in-attachments existed for these files. This
119
+ // extension owns that job for property datasets now, so it has to keep
120
+ // doing it, using Antora's resolver rather than rewriting paths by
121
+ // hand. Descriptions stay AsciiDoc apart from the resolved anchors,
122
+ // which is the shape consumers already read.
123
+ entry.description = resolveXrefs(
124
+ description, hostPage, contentCatalog, asciidocConfig, xrefCache, (macro) => unresolved.add(macro)
125
+ )
126
+ rendered++
127
+ } catch (error) {
128
+ // One description that will not convert must not cost the other 680.
129
+ failed++
130
+ logger.warn(`${where} ${name}: description could not be converted, so its tooltip falls back to the raw source (${error.message})`)
131
+ }
132
+ }
133
+
134
+ if (rendered) attachment.contents = Buffer.from(bigIntJson.stringify(data))
135
+ logger.info(`${where}: rendered ${rendered} property descriptions to HTML${failed ? `, ${failed} failed` : ''}`)
136
+ if (unresolved.size) {
137
+ logger.warn(
138
+ `${where}: ${unresolved.size} xref target(s) in property descriptions could not be resolved, so they stay as raw macros in the published attachment: ` +
139
+ `${[...unresolved].sort().join(', ')}.`
140
+ )
141
+ }
142
+ if (brokenAnchors.size) {
143
+ logger.warn(
144
+ `${where}: ${brokenAnchors.size} <<anchor>> reference(s) in property descriptions name no documented property, so they render as plain text: ` +
145
+ `${[...brokenAnchors].sort().join(', ')}. Property anchors replace dots with hyphens, so redpanda.storage.mode is <<redpanda-storage-mode>>. ` +
146
+ 'Fix them in the description or in docs-data/property-overrides.json.'
147
+ )
148
+ }
149
+ }
150
+ })
151
+ }
152
+
153
+ function basename (file) {
154
+ return file.src.relative.split('/').pop()
155
+ }
156
+
157
+ function propertyAttachments (contentCatalog) {
158
+ return (contentCatalog.findBy({ family: 'attachment' }) || []).filter(
159
+ (file) => file.src.module === 'reference' && PROPERTIES_JSON_RX.test(basename(file))
160
+ )
161
+ }
162
+
163
+ /**
164
+ * Parse one dataset, or report why it is unusable and return undefined. Left
165
+ * as-is on failure: the prop macro reports an unusable dataset with the detail,
166
+ * and failing the whole build over one bad attachment helps nobody.
167
+ */
168
+ function readDataset (attachment, logger) {
169
+ const where = `${attachment.src.component}@${attachment.src.version || 'unversioned'}`
170
+ let data
171
+ try {
172
+ data = bigIntJson.parse(attachment.contents.toString())
173
+ } catch (error) {
174
+ logger.warn(`${where}: ${basename(attachment)} is not valid JSON, descriptions not rendered (${error.message})`)
175
+ return undefined
176
+ }
177
+ const properties = data && data.properties
178
+ if (!properties || typeof properties !== 'object' || Array.isArray(properties)) {
179
+ logger.warn(`${where}: ${basename(attachment)} has no usable 'properties' object, descriptions not rendered`)
180
+ return undefined
181
+ }
182
+ return data
183
+ }
184
+
185
+ function readProperties (attachment, logger) {
186
+ const data = readDataset(attachment, logger)
187
+ return data ? data.properties : undefined
188
+ }
189
+
190
+ /**
191
+ * A real page in the attachment's own component and reference module, preferring
192
+ * one under properties/ so includes and relative xrefs resolve from where the
193
+ * property docs actually live.
194
+ */
195
+ function findHostPage (contentCatalog, attachment) {
196
+ const pages = contentCatalog.findBy({
197
+ component: attachment.src.component,
198
+ version: attachment.src.version,
199
+ family: 'page',
200
+ }) || []
201
+ return (
202
+ pages.find((f) => f.src.module === 'reference' && /^properties\//.test(f.src.relative)) ||
203
+ pages.find((f) => f.src.module === 'reference') ||
204
+ undefined
205
+ )
206
+ }
207
+
208
+ /**
209
+ * anchor -> xref target, for the <<anchor>> references that point at another
210
+ * documented property. Built from the same page index the prop macro links
211
+ * with, so a description and a prop: call agree about which page documents what.
212
+ */
213
+ function anchorIndex (contentCatalog, attachment, properties, logger) {
214
+ const where = `${attachment.src.component}@${attachment.src.version || 'unversioned'}`
215
+ const index = new Map()
216
+ let pages
217
+ try {
218
+ pages = buildPageIndex(contentCatalog, attachment.src.component, properties, attachment.src.version)
219
+ } catch (error) {
220
+ // Returning an empty index silently made a thrown index and an empty one
221
+ // indistinguishable, and every <<anchor>> then degraded to plain text with
222
+ // no explanation.
223
+ logger.warn(`${where}: could not index which page documents each property, so <<anchor>> references in descriptions render as plain text (${error.message})`)
224
+ return index
225
+ }
226
+ if (!pages.size) {
227
+ logger.warn(`${where}: no reference page was found to document any property, so <<anchor>> references in descriptions render as plain text.`)
228
+ return index
229
+ }
230
+ for (const [name, entry] of pages) {
231
+ if (!entry || !entry.page) continue
232
+ index.set(propertyAnchor(name), { page: entry.page, name })
233
+ }
234
+ return index
235
+ }
236
+
237
+ /**
238
+ * Rewrite <<anchor>> to an xref before conversion.
239
+ *
240
+ * Asciidoctor renders <<anchor>> as a same-page fragment link, which is right
241
+ * on the reference page and wrong in a tooltip: a tooltip is shown on an
242
+ * arbitrary page, where that fragment does not exist. Pointing it at the page
243
+ * that documents the property keeps the link working wherever the tooltip
244
+ * appears, and lets Antora resolve the URL rather than us building one.
245
+ */
246
+ function resolveInternalRefs (description, anchors, report) {
247
+ return description.replace(INTERNAL_REF_RX, (match, anchor, display) => {
248
+ const text = (display || '').trim()
249
+ const target = anchors.get(anchor)
250
+ if (target) return `xref:reference:${target.page}.adoc#${anchor}[${attrlistValue(text || `\`${target.name}\``)}]`
251
+ // The anchor names no documented property, so there is nothing to link to.
252
+ // Asciidoctor would render a same-page fragment, and a tooltip is shown on
253
+ // arbitrary pages, so that link would go nowhere and still invite a click --
254
+ // the same reasoning the prop macro applies to a page it cannot verify.
255
+ // Render the text and report the anchor so it gets fixed at the source.
256
+ report(anchor)
257
+ return text || `\`${anchor}\``
258
+ })
259
+ }
260
+
261
+ /**
262
+ * Display text as a single positional attribute. An unescaped `]` ends the
263
+ * attribute list, so `<<prop,the flag [beta] option>>` published a truncated
264
+ * link with ` option]` outside it; and a bare `=` makes Asciidoctor read the
265
+ * value as a named attribute, which dropped the label entirely and applied part
266
+ * of it as a CSS role. Escaping the bracket and quoting when there is an equals
267
+ * sign round-trips every combination of brackets, equals, quotes and commas.
268
+ */
269
+ function attrlistValue (text) {
270
+ const escaped = text.replace(/]/g, '\\]')
271
+ return /=/.test(escaped) ? `"${escaped.replace(/"/g, '\\"')}"` : escaped
272
+ }
273
+
274
+ /**
275
+ * Rewrite xref macros in a description to resolved links, so the published
276
+ * attachment is useful on its own.
277
+ *
278
+ * The conversion is done by Antora, one macro at a time, rather than by building
279
+ * a URL here: that way the page resolution, the fragment, and any id rules from
280
+ * the site's AsciiDoc config are whatever the site itself would produce, and
281
+ * cannot drift from the pages the links point at. The previous implementation
282
+ * rewrote paths with regexes and converted underscores in the fragment to
283
+ * hyphens -- but property page anchors keep their underscores
284
+ * (`id="log_cleanup_policy"`), so all 25 of its fragment links in the published
285
+ * v26.2.1 data were dead.
286
+ *
287
+ * Conversions are cached per macro, because the same xref appears in many
288
+ * descriptions.
289
+ */
290
+ function resolveXrefs (description, hostPage, contentCatalog, asciidocConfig, cache, report) {
291
+ return description.replace(XREF_RX, (match) => {
292
+ if (!cache.has(match)) cache.set(match, convertXref(match, hostPage, contentCatalog, asciidocConfig))
293
+ const anchor = cache.get(match)
294
+ if (!anchor) {
295
+ report(match)
296
+ // Leave the macro alone rather than emitting a link to nowhere: a reader
297
+ // of the raw JSON can still see what was meant.
298
+ return match
299
+ }
300
+ return anchor
301
+ })
302
+ }
303
+
304
+ /** One xref macro, converted by Antora. Returns undefined if it does not resolve. */
305
+ function convertXref (macro, hostPage, contentCatalog, asciidocConfig) {
306
+ let html
307
+ try {
308
+ html = render(macro, hostPage, contentCatalog, asciidocConfig)
309
+ } catch (error) {
310
+ return undefined
311
+ }
312
+ if (!html) return undefined
313
+ const anchor = /<a\b[^>]*>[\s\S]*?<\/a>/.exec(html)
314
+ // Antora marks a target it could not resolve, and that is not a link worth
315
+ // publishing into a file people read on its own.
316
+ if (!anchor || /class="[^"]*\bunresolved\b/.test(anchor[0])) return undefined
317
+ return anchor[0]
318
+ }
319
+
320
+ /** Convert one description with the site's converter, as the host page. */
321
+ function render (source, hostPage, contentCatalog, siteAsciiDocConfig) {
322
+ const file = {
323
+ contents: Buffer.from(source),
324
+ mediaType: 'text/asciidoc',
325
+ src: { ...hostPage.src },
326
+ pub: { ...hostPage.pub },
327
+ }
328
+ // relativizeResourceRefs: false gives site-root-relative URLs. The default
329
+ // computes them relative to the converting file, which is wrong for a tooltip
330
+ // shown on some other page.
331
+ const doc = loadAsciiDoc(file, contentCatalog, { ...siteAsciiDocConfig, relativizeResourceRefs: false })
332
+ const blocks = doc.getBlocks()
333
+ if (!blocks.length) return doc.convert() || null
334
+ // A single paragraph gives inline HTML with no <p> wrapper, which is what a
335
+ // tooltip wants. Anything richer -- an included admonition, a list, several
336
+ // paragraphs -- keeps its block markup, and the UI styles it.
337
+ if (blocks.length === 1 && blocks[0].getContext() === 'paragraph') return blocks[0].getContent()
338
+ return doc.convert()
339
+ }
@@ -20,6 +20,8 @@ const { raiseListenerLimit } = require('./util/raise-listener-limit')
20
20
  * - xref:path.adoc[] (empty display uses page title or path)
21
21
  */
22
22
 
23
+ const PROPERTIES_JSON_RX = /^redpanda-properties-(v\d+\.\d+\.\d+(?:-[\w.]+)?)\.json$/
24
+
23
25
  module.exports.register = function () {
24
26
  raiseListenerLimit(this)
25
27
  const logger = this.getLogger('resolve-xrefs-in-attachments')
@@ -29,7 +31,15 @@ module.exports.register = function () {
29
31
  const allAttachments = contentCatalog.findBy({ family: 'attachment' })
30
32
  const jsonAttachments = allAttachments.filter((att) => {
31
33
  const path = att.src?.path || att.out?.path || ''
32
- return path.endsWith('.json')
34
+ if (!path.endsWith('.json')) return false
35
+ // Property datasets are handled by render-property-descriptions, which
36
+ // converts their descriptions with Asciidoctor instead of rewriting xrefs
37
+ // by hand. Both running double-processed 53 descriptions in v26.2.1: the
38
+ // anchor inserted here was escaped to &lt;a href= by the converter. This
39
+ // extension still serves every other JSON attachment, including the
40
+ // Connect component catalog, which carries far more xrefs than the
41
+ // property files do.
42
+ return !PROPERTIES_JSON_RX.test(path.split('/').pop())
33
43
  })
34
44
 
35
45
  if (!jsonAttachments.length) {
@@ -72,6 +82,19 @@ function processJsonAttachment (attachment, contentCatalog, logger) {
72
82
 
73
83
  let data
74
84
  try {
85
+ // Deliberately plain JSON.parse, not bigIntJson: this function rewrites
86
+ // whatever JSON attachment it is handed, including the Connect component
87
+ // catalog, which contains example config text as an ESCAPED JSON-shaped
88
+ // string -- "{\"delay_for_ns\":110839937000000000}" -- indistinguishable
89
+ // by regex from real JSON value position. bigIntJson's value-position regex
90
+ // matched inside that escaped string and inserted stray quotes, which is not
91
+ // a rounding error, it is invalid JSON: confirmed to throw
92
+ // "Expected ',' or ']' after array element" on the real, currently-published
93
+ // connect-4.103.1.json attachment. A plain round trip still silently rounds
94
+ // large integers (the pre-existing, understood defect this file has always
95
+ // had), which is the safer failure mode until a real JSON scanner -- one
96
+ // that tracks string/escape state rather than pattern-matching text --
97
+ // replaces the regex here.
75
98
  data = JSON.parse(contentStr)
76
99
  } catch (err) {
77
100
  logger.debug(`Skipping invalid JSON: ${attachment.src?.path}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.12.4",
3
+ "version": "5.13.1",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -77,6 +77,7 @@
77
77
  "./extensions/convert-llms-to-txt": "./extensions/convert-llms-to-txt.js",
78
78
  "./extensions/convert-sitemap-to-markdown": "./extensions/convert-sitemap-to-markdown.js",
79
79
  "./extensions/modify-redirects": "./extensions/produce-redirects.js",
80
+ "./extensions/produce-redirects": "./extensions/produce-redirects.js",
80
81
  "./extensions/algolia-indexer/index": "./extensions/algolia-indexer/index.js",
81
82
  "./extensions/aggregate-terms": "./extensions/aggregate-terms.js",
82
83
  "./extensions/resolve-xrefs-in-attachments": "./extensions/resolve-xrefs-in-attachments.js",
@@ -91,7 +92,8 @@
91
92
  "./macros/badge": "./macros/badge.js",
92
93
  "./macros/enterprise": "./macros/enterprise.js",
93
94
  "./macros/prop": "./macros/prop.js",
94
- "./extensions/property-page-index": "./extensions/property-page-index.js"
95
+ "./extensions/property-page-index": "./extensions/property-page-index.js",
96
+ "./extensions/render-property-descriptions": "./extensions/render-property-descriptions.js"
95
97
  },
96
98
  "files": [
97
99
  "extensions",
@@ -156,5 +158,8 @@
156
158
  "@web/dev-server": "^0.2.5",
157
159
  "jest": "^29.7.0",
158
160
  "jest-junit": "^16.0.0"
161
+ },
162
+ "peerDependencies": {
163
+ "@antora/asciidoc-loader": "^3.1.0"
159
164
  }
160
165
  }
@@ -126,9 +126,19 @@ function parseJSDocComments(sourceFile) {
126
126
  const content = fs.readFileSync(sourceFile, 'utf8');
127
127
  const comments = {};
128
128
 
129
- // Regex to match JSDoc comments followed by command definitions
130
- // Matches both top-level commands and automation.command()
131
- const pattern = /\/\*\*\s*([\s\S]*?)\s*\*\/\s*(?:programCli|automation|validation)\s*\.command\(['"]([^'"]+)['"]\)/g;
129
+ // Regex to match JSDoc comments followed by command definitions.
130
+ // Matches both top-level commands and automation.command().
131
+ //
132
+ // The comment body is `(?:(?!\*\/)[\s\S])*?`, a tempered match that cannot
133
+ // cross a closing `*\/`, rather than a plain `[\s\S]*?`. With the plain
134
+ // version two ADJACENT JSDoc blocks merged into one match: the lazy body ran
135
+ // past the first block's terminator, because that terminator is followed by
136
+ // `/**` rather than by `.command(`, and kept going to the second. The
137
+ // extractor then took the FIRST @description it found, so the second command
138
+ // was documented with the first command's prose. That is exactly what
139
+ // happened to `generate migrate-rpcn-descriptions`, which shipped a
140
+ // regenerated section describing migrate-rpcn-metadata.
141
+ const pattern = /\/\*\*\s*((?:(?!\*\/)[\s\S])*?)\s*\*\/\s*(?:programCli|automation|validation)\s*\.command\(['"]([^'"]+)['"]\)/g;
132
142
 
133
143
  let match;
134
144
  while ((match = pattern.exec(content)) !== null) {
@@ -306,7 +316,8 @@ IMPORTANT: This documentation is auto-generated. Do not edit manually. Run \`npm
306
316
  'crd-spec',
307
317
  'bundle-openapi',
308
318
  'update-connect-version',
309
- 'migrate-rpcn-metadata'
319
+ 'migrate-rpcn-metadata',
320
+ 'migrate-rpcn-descriptions'
310
321
  ];
311
322
 
312
323
  generateSubcommands.forEach(subcmd => {
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  const fs = require('fs');
18
+ const bigIntJson = require('../../cli-utils/big-int-json');
18
19
  const path = require('path');
19
20
  const semver = require('semver');
20
21
 
@@ -109,7 +110,10 @@ function formatValue(value) {
109
110
  return `[${value.length} items]`;
110
111
  }
111
112
  if (typeof value === 'object') {
112
- return JSON.stringify(value);
113
+ // bigIntJson, not JSON.stringify: a default/minimum/maximum value that's an
114
+ // object or array can itself carry an unsafe-integer BigInt from
115
+ // bigIntJson.parse(), which plain JSON.stringify throws on.
116
+ return bigIntJson.stringify(value);
113
117
  }
114
118
  if (typeof value === 'string') {
115
119
  return `"${value}"`;
@@ -393,7 +397,11 @@ function generateJsonReport(report, oldVersion, newVersion, outputPath) {
393
397
  details: report
394
398
  };
395
399
 
396
- fs.writeFileSync(outputPath, JSON.stringify(jsonReport, null, 2));
400
+ // bigIntJson, not JSON.stringify: `report.details` carries oldDefault/newDefault
401
+ // values straight from bigIntJson.parse(), so an unsafe-integer default is a
402
+ // real BigInt here. Plain JSON.stringify throws on that ("Do not know how to
403
+ // serialize a BigInt") instead of writing the report.
404
+ fs.writeFileSync(outputPath, bigIntJson.stringify(jsonReport, 2));
397
405
  console.log(`📄 Detailed JSON report saved to: ${outputPath}`);
398
406
  }
399
407
 
@@ -424,8 +432,8 @@ function comparePropertyFiles(oldFilePath, newFilePath, oldVersion, newVersion,
424
432
  console.log(` Old: ${oldFilePath}`);
425
433
  console.log(` New: ${newFilePath}`);
426
434
 
427
- const oldData = JSON.parse(fs.readFileSync(oldFilePath, 'utf8'));
428
- const newData = JSON.parse(fs.readFileSync(newFilePath, 'utf8'));
435
+ const oldData = bigIntJson.parse(fs.readFileSync(oldFilePath, 'utf8'));
436
+ const newData = bigIntJson.parse(fs.readFileSync(newFilePath, 'utf8'));
429
437
 
430
438
  const report = compareProperties(oldData, newData, oldVersion, newVersion);
431
439
 
@@ -447,7 +455,10 @@ function comparePropertyFiles(oldFilePath, newFilePath, oldVersion, newVersion,
447
455
  });
448
456
 
449
457
  newData.properties = newProps;
450
- fs.writeFileSync(newFilePath, JSON.stringify(newData, null, 2), 'utf8');
458
+ // bigIntJson: this rewrites the dataset in place, so a plain stringify
459
+ // corrupts the uint64/int64 limits on disk -- ahead of, and defeating, the
460
+ // lossless read in generate-handlebars-docs.js.
461
+ fs.writeFileSync(newFilePath, bigIntJson.stringify(newData, 2), 'utf8');
451
462
  console.log(`\nMerged ${report.removedDeprecatedProperties.length} removed deprecated properties back into ${newVersion} JSON`);
452
463
  }
453
464