@redpanda-data/docs-extensions-and-macros 5.54.0 → 5.56.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.
Files changed (87) hide show
  1. package/bin/doc-tools-mcp.js +4 -5
  2. package/bin/doc-tools.js +354 -9
  3. package/bin/mcp-tools/generated-docs-review.js +26 -9
  4. package/bin/mcp-tools/overrides-audit.js +1 -1
  5. package/bin/mcp-tools/rpcn-docs.js +4 -14
  6. package/cli-utils/docs-links-to-xrefs.js +174 -0
  7. package/cli-utils/format-helm-spec.js +4 -3
  8. package/cli-utils/generated-marker.js +43 -0
  9. package/cli-utils/helm-commented-values.js +125 -21
  10. package/cli-utils/self-managed-docs-branch.js +36 -30
  11. package/extension-utils/html-utils.js +27 -0
  12. package/extension-utils/llms-utils.js +6 -2
  13. package/extension-utils/metadata-candidates.js +304 -0
  14. package/extension-utils/metadata-check.js +174 -0
  15. package/extension-utils/metadata-suggest.js +573 -0
  16. package/extension-utils/taxonomy-categories.js +106 -0
  17. package/extension-utils/taxonomy.js +566 -0
  18. package/extensions/REFERENCE.adoc +110 -8
  19. package/extensions/add-git-dates.js +13 -1
  20. package/extensions/aggregate-terms.js +34 -20
  21. package/extensions/algolia-indexer/generate-index.js +13 -1
  22. package/extensions/algolia-indexer/index.js +4 -2
  23. package/extensions/convert-sitemap-to-markdown.js +47 -12
  24. package/extensions/convert-to-markdown.js +176 -0
  25. package/extensions/generate-fields-only-pages.js +14 -0
  26. package/extensions/modify-connect-tag-playbook.js +181 -3
  27. package/extensions/resolve-xrefs-in-attachments.js +1 -12
  28. package/extensions/url-to-xref.js +92 -22
  29. package/extensions/util/connect-docs-asset.js +6 -5
  30. package/extensions/validate-attributes.js +10 -1
  31. package/extensions/version-fetcher/fetch-latest-docker-tag.js +35 -13
  32. package/extensions/version-fetcher/get-latest-redpanda-helm-version-from-operator.js +64 -40
  33. package/extensions/version-fetcher/list-connect-releases.js +3 -3
  34. package/extensions/version-fetcher/release-lines.js +64 -0
  35. package/extensions/version-fetcher/set-latest-version.js +73 -3
  36. package/macros/README.adoc +1 -0
  37. package/macros/REFERENCE.adoc +99 -0
  38. package/macros/badge.js +5 -8
  39. package/macros/enterprise.js +2 -5
  40. package/macros/glossary.js +4 -14
  41. package/macros/iceberg-explorer.js +242 -0
  42. package/macros/prop.js +3 -13
  43. package/mcp/USER_GUIDE.adoc +25 -135
  44. package/package.json +5 -2
  45. package/tools/check-metadata/index.js +523 -0
  46. package/tools/cloud-regions/cloud-regions-table-adoc.hbs +1 -2
  47. package/tools/cloud-regions/cloud-regions-table-md.hbs +2 -2
  48. package/tools/cloud-regions/render-cloud-regions.js +3 -0
  49. package/tools/dispatch-graph/check-dispatch-graph.js +790 -0
  50. package/tools/dispatch-graph/classify-runs.js +128 -0
  51. package/tools/dispatch-graph/parse-workflows.js +499 -0
  52. package/tools/doc-impact-recording/index.js +190 -0
  53. package/tools/enterprise-features/detect-gating-changes.js +393 -0
  54. package/tools/enterprise-features/verify.js +2 -1
  55. package/tools/gen-rpk-ascii.py +7 -1
  56. package/tools/lint-strings/fixtures/connect/lint_connect.go +8 -0
  57. package/tools/lint-strings/fixtures/crd/lint_types.go +4 -1
  58. package/tools/lint-strings/fixtures/rpk-pr461/delete.base.go +42 -0
  59. package/tools/lint-strings/fixtures/rpk-pr461/delete.head.go +45 -0
  60. package/tools/lint-strings/fixtures/rpk-pr461/reviewer-comments.json +1 -0
  61. package/tools/lint-strings/index.js +133 -3
  62. package/tools/lint-strings/rules/verbatim-asciidoc.js +166 -1
  63. package/tools/lint-strings/surfaces/connect.js +72 -12
  64. package/tools/lint-strings/surfaces/crd.js +10 -6
  65. package/tools/metrics/metrics.py +11 -0
  66. package/tools/overrides-audit/README.adoc +1 -2
  67. package/tools/overrides-audit/adapters/properties.js +1 -1
  68. package/tools/overrides-audit/index.js +3 -4
  69. package/tools/property-extractor/generate-handlebars-docs.js +9 -1
  70. package/tools/property-extractor/helpers/applyPropertyLinks.js +35 -0
  71. package/tools/redpanda-connect/AUTOMATION.md +3 -36
  72. package/tools/redpanda-connect/README.adoc +5 -33
  73. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +32 -11
  74. package/tools/redpanda-connect/pr-summary-formatter.js +6 -4
  75. package/tools/redpanda-connect/report-delta.js +6 -3
  76. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +42 -25
  77. package/tools/redpanda-connect/templates/bloblang-function.hbs +1 -1
  78. package/tools/redpanda-connect/templates/descriptions-partials.hbs +1 -1
  79. package/tools/redpanda-connect/templates/examples-partials.hbs +1 -1
  80. package/tools/redpanda-connect/templates/fields-partials.hbs +2 -2
  81. package/tools/redpanda-connect/templates/metadata-partials.hbs +1 -1
  82. package/tools/redpanda-release-notes/generate-release-notes.js +116 -1
  83. package/tools/rpk-docs/generate-rpk-docs.js +2 -0
  84. package/tools/rpk-docs/generate-x-env-partial.js +7 -4
  85. package/tools/rpk-docs/helpers/index.js +4 -1
  86. package/tools/rpk-docs/templates/command.hbs +2 -2
  87. package/tools/overrides-audit/adapters/connect.js +0 -144
@@ -8,6 +8,98 @@ const TurndownService = require('turndown')
8
8
  const turndownPluginGfm = require('turndown-plugin-gfm')
9
9
  const { gfm } = turndownPluginGfm
10
10
 
11
+ // Every doc-tools generator writes one comment line near the top of each file
12
+ // it produces, in this shape:
13
+ //
14
+ // // This content is autogenerated. Do not edit manually. Generated by doc-tools (<generator>) from <source>. <how to change it>
15
+ //
16
+ // (cli-utils/generated-marker.js builds it.) Some files written before the
17
+ // version was dropped carry one after "doc-tools". Older files carry only the
18
+ // first two sentences. The twin's front matter reports what the marker says, so a
19
+ // reader (or an agent) can tell generated text from hand-written text on the
20
+ // same page and knows where the fix goes. The comment must open with the
21
+ // generated phrase, which every marker in the docs repos does ("This content
22
+ // is autogenerated", "AUTO-GENERATED FILE", "Generated by `doc-tools ...`"),
23
+ // so a prose comment such as "// Table generated by the build" is not one.
24
+ // "Generated by" counts only when doc-tools follows, so a hand-written
25
+ // "// Generated by running rpk cluster info, then edited" is not one either.
26
+ const GENERATED_MARKER_SCAN_LINES = 15
27
+ const GENERATED_MARKER_RE = /^\/\/\s*((?:this content is auto-?generated\b|auto-?generated\b|generated by\s+`?doc-tools\b).*)$/i
28
+ // "doc-tools (property-docs)", or with a version token: "doc-tools 5.55.0
29
+ // (rpk-docs)". A token that is not a version (the Python helpers once wrote
30
+ // "unknown") still yields the generator.
31
+ const MARKER_TOOL_RE = /doc-tools(?:\s+([^\s()]+))?\s*\(([^)]+)\)/i
32
+ // The source is named only right after the tool, or after "autogenerated"
33
+ // (connect's own banner), so a "from" later in a legacy banner's prose is not
34
+ // read as one.
35
+ const MARKER_SOURCE_RE = /(?:doc-tools(?:\s+[^\s()]+)?\s*\([^)]+\)|auto-?generated)\s+from\s+(.+?)(?:\.(?=\s|$)|$)/i
36
+ const INCLUDE_TARGET_RE = /^include::([^[\s{]+)\[/gm
37
+
38
+ /**
39
+ * Read the generated-content marker from the first lines of an AsciiDoc file.
40
+ * @param {Buffer|string} contents - The file's AsciiDoc source
41
+ * @returns {{note: string, generator: (string|null), version: (string|null), source: (string|null)}|null}
42
+ */
43
+ function readGeneratedMarker (contents) {
44
+ if (!contents) return null
45
+ const text = Buffer.isBuffer(contents) ? contents.toString('utf8') : String(contents)
46
+ const lines = text.split(/\r?\n/, GENERATED_MARKER_SCAN_LINES)
47
+ for (const line of lines) {
48
+ const m = line.match(GENERATED_MARKER_RE)
49
+ if (!m) continue
50
+ const note = m[1].trim()
51
+ const tool = note.match(MARKER_TOOL_RE)
52
+ const generator = tool ? tool[2].trim() : null
53
+ const token = tool && tool[1] ? tool[1].replace(/^v(?=\d)/i, '') : null
54
+ const version = token && /^\d/.test(token) ? token : null
55
+ const source = (note.match(MARKER_SOURCE_RE) || [])[1] || null
56
+ return { note, generator, version, source: source ? source.trim() : null }
57
+ }
58
+ return null
59
+ }
60
+
61
+ /**
62
+ * List the include targets in an AsciiDoc source. Targets that carry an
63
+ * attribute reference cannot be resolved here and are skipped.
64
+ * @param {Buffer|string} contents - The file's AsciiDoc source
65
+ * @returns {string[]} Unique include targets, in order of first appearance
66
+ */
67
+ function findIncludeTargets (contents) {
68
+ if (!contents) return []
69
+ const text = Buffer.isBuffer(contents) ? contents.toString('utf8') : String(contents)
70
+ const targets = []
71
+ for (const m of text.matchAll(INCLUDE_TARGET_RE)) {
72
+ if (!targets.includes(m[1])) targets.push(m[1])
73
+ }
74
+ return targets
75
+ }
76
+
77
+ /**
78
+ * Work out which text on a page is generated: the page itself, and any
79
+ * include whose file carries the marker. Runs while the catalog still holds
80
+ * AsciiDoc, before conversion.
81
+ * @param {Object} page - Antora page file (src, contents)
82
+ * @param {Object} contentCatalog - Antora content catalog
83
+ * @returns {{page: (Object|null), includes: Object[]}}
84
+ */
85
+ function collectGeneratedProvenance (page, contentCatalog) {
86
+ const own = readGeneratedMarker(page.contents)
87
+ const includes = []
88
+ for (const target of findIncludeTargets(page.contents)) {
89
+ let resource
90
+ try {
91
+ resource = contentCatalog.resolveResource(target, page.src, 'partial', ['partial', 'page', 'example'])
92
+ } catch (err) {
93
+ resource = null
94
+ }
95
+ if (!resource || !resource.contents) continue
96
+ const marker = readGeneratedMarker(resource.contents)
97
+ if (!marker) continue
98
+ includes.push({ include: target, generator: marker.generator, version: marker.version, source: marker.source, note: marker.note })
99
+ }
100
+ return { page: own, includes }
101
+ }
102
+
11
103
  /**
12
104
  * Decode HTML entities in a string
13
105
  * @param {string} str - String with potential HTML entities
@@ -70,6 +162,17 @@ function generateFrontmatter(page) {
70
162
  'page-eol-date',
71
163
  'page-git-created-date',
72
164
  'page-git-modified-date',
165
+ // Generated-content provenance, from the markers generators write
166
+ 'page-generated-content',
167
+ 'page-autogenerated',
168
+ 'page-generated-by',
169
+ 'page-generated-from',
170
+ 'page-generated-sources',
171
+ // Release that introduced the page's feature, and the product that
172
+ // release belongs to. Generated Connect reference pages set them on
173
+ // self-managed pages; the UI shows them as a "Since" badge.
174
+ 'page-introduced-in',
175
+ 'page-introduced-in-product',
73
176
  // Component-specific version attributes (from antora.yml)
74
177
  'latest-redpanda-tag', // Redpanda docker tag (e.g., v25.3.5)
75
178
  'latest-console-tag',
@@ -107,6 +210,29 @@ function generateFrontmatter(page) {
107
210
  return
108
211
  }
109
212
 
213
+ // Provenance travels as a JSON string in the attribute; the twin wants a list
214
+ if (key === 'page-generated-sources') {
215
+ try {
216
+ frontmatter[key] = JSON.parse(value)
217
+ } catch (err) {
218
+ frontmatter[key] = value
219
+ }
220
+ return
221
+ }
222
+ if (key === 'page-autogenerated') {
223
+ frontmatter[key] = value === '' || value === true || value === 'true'
224
+ return
225
+ }
226
+ if (key === 'page-introduced-in' || key === 'page-introduced-in-product') {
227
+ // Not boolean flags: an empty value (an unset attribute in a template)
228
+ // means there is nothing to record.
229
+ const text = String(value).trim()
230
+ if (!text) return
231
+ // A bare X.Y.Z, as the UI badge shows it.
232
+ frontmatter[key] = key === 'page-introduced-in' ? text.replace(/^v/i, '').replace(/\.+$/, '') : text
233
+ return
234
+ }
235
+
110
236
  // Skip empty attributes (AsciiDoc boolean flags)
111
237
  if (value === '') {
112
238
  // Special handling for version fields - use actual version from page source
@@ -294,8 +420,12 @@ function createEnterpriseFeatureRules () {
294
420
  }
295
421
 
296
422
  module.exports.createEnterpriseFeatureRules = createEnterpriseFeatureRules
423
+ module.exports.generateFrontmatter = generateFrontmatter
297
424
  module.exports.formatStatusMarker = formatStatusMarker
298
425
  module.exports.ENTERPRISE_MARKER = ENTERPRISE_MARKER
426
+ module.exports.readGeneratedMarker = readGeneratedMarker
427
+ module.exports.findIncludeTargets = findIncludeTargets
428
+ module.exports.collectGeneratedProvenance = collectGeneratedProvenance
299
429
 
300
430
  module.exports.register = function () {
301
431
  raiseListenerLimit(this)
@@ -556,6 +686,27 @@ module.exports.register = function () {
556
686
  return outerTurndown
557
687
  }
558
688
 
689
+ // Record which text on each page is generated, while the catalog still
690
+ // holds AsciiDoc: the page's own marker, and the marker on every include it
691
+ // resolves to. An extension that generates pages at build time sets
692
+ // page.generatedProvenance itself, and that wins.
693
+ this.on('contentClassified', ({ contentCatalog }) => {
694
+ const pages = contentCatalog.findBy({ family: 'page' })
695
+ let marked = 0
696
+ for (const page of pages) {
697
+ if (page.generatedProvenance) {
698
+ marked++
699
+ continue
700
+ }
701
+ const provenance = collectGeneratedProvenance(page, contentCatalog)
702
+ if (provenance.page || provenance.includes.length) {
703
+ page.generatedProvenance = provenance
704
+ marked++
705
+ }
706
+ }
707
+ logger.info(`Recorded generated-content provenance on ${marked} of ${pages.length} pages`)
708
+ })
709
+
559
710
  // Add marker attribute before UI rendering so templates can detect markdown availability
560
711
  this.on('documentsConverted', ({ contentCatalog }) => {
561
712
  const pages = contentCatalog.findBy({ family: 'page' })
@@ -568,6 +719,31 @@ module.exports.register = function () {
568
719
 
569
720
  // Add marker that UI templates can check
570
721
  page.asciidoc.attributes['page-has-markdown'] = ''
722
+
723
+ // Provenance as page attributes, so the twin's front matter and the UI
724
+ // templates can both read it. page-generated-content is set on every
725
+ // page, so "none" is a statement and not an absence.
726
+ const provenance = page.generatedProvenance
727
+ let generatedContent = 'none'
728
+ if (provenance?.page) {
729
+ generatedContent = 'all'
730
+ page.asciidoc.attributes['page-autogenerated'] = 'true'
731
+ const { generator, version, source } = provenance.page
732
+ if (generator) {
733
+ page.asciidoc.attributes['page-generated-by'] = `doc-tools${version ? ` ${version}` : ''} (${generator})`
734
+ }
735
+ // Only a named source: a legacy banner has none, and its prose is
736
+ // not one.
737
+ if (source) page.asciidoc.attributes['page-generated-from'] = source
738
+ } else if (provenance?.includes?.length) {
739
+ generatedContent = 'partial'
740
+ }
741
+ if (provenance?.includes?.length) {
742
+ page.asciidoc.attributes['page-generated-sources'] = JSON.stringify(
743
+ provenance.includes.map(({ include, generator, version, source }) => ({ include, generator, version, source }))
744
+ )
745
+ }
746
+ page.asciidoc.attributes['page-generated-content'] = generatedContent
571
747
  })
572
748
  })
573
749
 
@@ -8,6 +8,7 @@ const os = require('os')
8
8
 
9
9
 
10
10
  const handlebars = require('handlebars')
11
+ const { generatedMarker, DOC_TOOLS_VERSION } = require('../cli-utils/generated-marker')
11
12
 
12
13
  // Import and register Redpanda Connect helpers
13
14
  const helpers = require('../tools/redpanda-connect/helpers')
@@ -269,6 +270,19 @@ module.exports.register = function ({ config }) {
269
270
  // Mark as field-only page (used by convert-to-markdown and add-llms-directive to skip directive)
270
271
  file.isFieldOnlyPage = true
271
272
 
273
+ // The whole page is generated here at build time, so say so the way
274
+ // a file marker would; convert-to-markdown exports it as
275
+ // page-autogenerated and page-generated-from in the twin.
276
+ file.generatedProvenance = {
277
+ page: {
278
+ generator: 'fields-only-pages',
279
+ version: DOC_TOOLS_VERSION,
280
+ source: 'the component spec in redpanda-data/connect',
281
+ note: generatedMarker('fields-only-pages', 'the component spec in redpanda-data/connect').replace(/^\/\/\s*/, ''),
282
+ },
283
+ includes: [],
284
+ }
285
+
272
286
  pagesGenerated++
273
287
  } catch (err) {
274
288
  logger.error(`Failed to generate field-only page for ${type}/${item.name}: ${err.message}`)
@@ -75,6 +75,16 @@ const LOCAL_DIR_ENV = 'REDPANDA_CONNECT_DOCS_DIR'
75
75
  // the probe fallback tries, when the latest release has no asset yet.
76
76
  const FALLBACK_RELEASE_LIMIT = 10
77
77
  const FALLBACK_PROBE_LIMIT = 3
78
+ // Dating pages by release (only when add-git-dates is in the playbook): the
79
+ // page size of the releases listing, the first release that has the asset
80
+ // (no older release can be compared), and the most older releases the walk
81
+ // downloads per build.
82
+ const DATE_RELEASE_PAGE_SIZE = 100
83
+ const FIRST_ASSET_RELEASE = 'v4.113.0'
84
+ const DATE_RELEASE_LIMIT = 10
85
+ // How deep the walk follows includes inside the asset's own partials, such as
86
+ // bloblang/methods.adoc including every bloblang-methods/* partial.
87
+ const NESTED_INCLUDE_DEPTH = 3
78
88
 
79
89
  // HTTPS, SSH (ssh://), and scp-style (git@host:owner/repo) URLs are all
80
90
  // remote content sources in Antora.
@@ -284,6 +294,163 @@ async function findOlderReleaseWithAsset (latestTag, url, logger) {
284
294
  return null
285
295
  }
286
296
 
297
+ // The resource keys (`partial$<relative>`, `example$<relative>`) of generated
298
+ // files that differ between two releases' assets, or are new in `files`.
299
+ function changedReferenceKeys (files, previousFiles) {
300
+ const before = new Map()
301
+ for (const f of previousFiles) {
302
+ const r = asset.toResource(f.path)
303
+ if (r && !before.has(`${r.family}$${r.relative}`)) before.set(`${r.family}$${r.relative}`, f.contents)
304
+ }
305
+ const changed = new Set()
306
+ const seen = new Set()
307
+ for (const f of files) {
308
+ const r = asset.toResource(f.path)
309
+ if (!r || r.family === 'attachment') continue
310
+ const key = `${r.family}$${r.relative}`
311
+ // addConnectDocs retains the first copy of a resource in an asset.
312
+ if (seen.has(key)) continue
313
+ seen.add(key)
314
+ const old = before.get(key)
315
+ if (old === undefined || Buffer.compare(Buffer.from(old), Buffer.from(f.contents)) !== 0) changed.add(key)
316
+ }
317
+ return changed
318
+ }
319
+
320
+ // Includes of the connect components module in a page: `partial$x` and
321
+ // `example$x` keys. In the connect component's own components module the
322
+ // module may be implicit; elsewhere only fully qualified includes count. With
323
+ // a content catalog, only files this asset added count, and includes inside
324
+ // those files are followed too (up to NESTED_INCLUDE_DEPTH levels), so a page
325
+ // that includes an aggregate partial is dated by the partials it pulls in.
326
+ const INCLUDE_RX = /^include::([^[\s]+)\[/gm
327
+ function includeKey (target, src) {
328
+ const own = src.component === COMPONENT && src.module === asset.MODULE
329
+ const q = target.match(/^connect:components:((?:partial|example)\$.+)$/) ||
330
+ (own ? target.match(/^(?:components:)?((?:partial|example)\$.+)$/) : null)
331
+ return q ? q[1] : null
332
+ }
333
+ function connectIncludes (page, contentCatalog, origin) {
334
+ const keys = []
335
+ const seen = new Set()
336
+ const visit = (file, depth) => {
337
+ for (const m of file.contents.toString('utf8').matchAll(INCLUDE_RX)) {
338
+ const key = includeKey(m[1], file.src)
339
+ if (!key) continue
340
+ if (!contentCatalog) {
341
+ keys.push(key)
342
+ continue
343
+ }
344
+ const included = contentCatalog.resolveResource(m[1], file.src)
345
+ // Only files inserted from this asset can acquire a release date.
346
+ if (!included || included.src.origin !== origin || seen.has(included)) continue
347
+ seen.add(included)
348
+ keys.push(key)
349
+ if (depth < NESTED_INCLUDE_DEPTH) visit(included, depth + 1)
350
+ }
351
+ }
352
+ visit(page, 1)
353
+ return keys
354
+ }
355
+
356
+ // Marks pages with the newest change date of their included asset references.
357
+ // Catalog resolution excludes retained duplicates and respects each version.
358
+ // Cloud stubs inherit the date of the connect page they actually include.
359
+ function markReferenceChanges (contentCatalog, dates, origin) {
360
+ if (!dates.size) return 0
361
+ const marked = new Set()
362
+ const pages = contentCatalog.getPages()
363
+ const mark = (page, date) => {
364
+ if (!date) return
365
+ if (!page.connectReferenceModified || date > page.connectReferenceModified) page.connectReferenceModified = date
366
+ marked.add(page)
367
+ }
368
+ for (const page of pages) {
369
+ for (const key of connectIncludes(page, contentCatalog, origin)) mark(page, dates.get(key))
370
+ }
371
+ for (const page of pages) {
372
+ for (const m of page.contents.toString('utf8').matchAll(INCLUDE_RX)) {
373
+ if (!/^connect:([^:$]+):([^$]+\.adoc)$/.test(m[1])) continue
374
+ const included = contentCatalog.resolvePage(m[1], page.src)
375
+ if (marked.has(included)) mark(page, included.connectReferenceModified)
376
+ }
377
+ }
378
+ return marked.size
379
+ }
380
+
381
+ // The date of the newest release that changed each of `keys`, as a Map of
382
+ // key to YYYY-MM-DD. It compares the release with older releases that have
383
+ // the asset, newest first, so a file left unchanged by the current release
384
+ // keeps the date of the release that last changed it. Bounded so the cost
385
+ // does not grow with every connect release: the listing stops paging at the
386
+ // first release older than FIRST_ASSET_RELEASE, and the walk downloads at
387
+ // most DATE_RELEASE_LIMIT older releases. A file still unchanged at the end
388
+ // of the walk gets no date. A failed listing, download, or read only costs
389
+ // dates: it logs a warning, and every file not dated yet keeps its git date.
390
+ async function referenceReleaseDates (tag, files, keys, logger) {
391
+ const dates = new Map()
392
+ if (!keys.size) return dates
393
+ const floor = stableVersion(FIRST_ASSET_RELEASE)
394
+ const releases = []
395
+ try {
396
+ const github = await createGitHub()
397
+ for (let page = 1; ; page++) {
398
+ const batch = await listConnectReleases(github, OWNER, REPO, DATE_RELEASE_PAGE_SIZE, page)
399
+ releases.push(...batch)
400
+ if (batch.length < DATE_RELEASE_PAGE_SIZE) break
401
+ // Newest first: past the first release older than the asset, no
402
+ // release can have it.
403
+ if (batch.some((r) => r && stableVersion(r.tag_name) && compareVersions(stableVersion(r.tag_name), floor) < 0)) break
404
+ }
405
+ } catch (error) {
406
+ logger.warn(`Couldn't list Redpanda Connect releases (${error.message}), so Connect pages keep their git modified dates`)
407
+ return dates
408
+ }
409
+ let newer = releases.find((r) => r && r.tag_name === tag)
410
+ if (!newer || !newer.published_at) return dates
411
+ const latest = stableVersion(tag)
412
+ const older = releases
413
+ .filter((r) => r && !r.draft && !r.prerelease && stableVersion(r.tag_name) && latest && compareVersions(stableVersion(r.tag_name), latest) < 0)
414
+ .filter((r) => compareVersions(stableVersion(r.tag_name), floor) >= 0)
415
+ .filter((r) => (r.assets || []).some((a) => a && a.name === asset.ASSET_NAME))
416
+ .sort((a, b) => compareVersions(stableVersion(b.tag_name), stableVersion(a.tag_name)))
417
+ .slice(0, DATE_RELEASE_LIMIT)
418
+ const pending = new Set(keys)
419
+ for (const release of older) {
420
+ let previousFiles
421
+ try {
422
+ const archive = await downloadRelease(release.tag_name, logger)
423
+ if (!archive) throw new Error(`the release lists a ${asset.ASSET_NAME} asset but its download returns 404`)
424
+ previousFiles = await asset.readTarGz(archive)
425
+ } catch (error) {
426
+ // Skipping the release would credit its changes to the newer release,
427
+ // so stop here: dates found so far are exact, the rest keep git dates.
428
+ logger.warn(`Couldn't read the Redpanda Connect ${release.tag_name} reference docs to date pages (${error.message}), so ${pending.size} generated files keep their pages' git modified dates`)
429
+ break
430
+ }
431
+ const changed = changedReferenceKeys(files, previousFiles)
432
+ for (const key of pending) {
433
+ if (!changed.has(key)) continue
434
+ if (newer.published_at) dates.set(key, String(newer.published_at).slice(0, 10))
435
+ pending.delete(key)
436
+ }
437
+ if (!pending.size) break
438
+ files = previousFiles
439
+ newer = release
440
+ }
441
+ return dates
442
+ }
443
+
444
+ // True when the playbook registers add-git-dates, the only reader of the
445
+ // release dates. Without it, the dating step is skipped.
446
+ function usesGitDates (playbook) {
447
+ const extensions = (playbook && playbook.antora && playbook.antora.extensions) || []
448
+ return [].concat(extensions).some((ext) => {
449
+ const id = typeof ext === 'string' ? ext : ext && ext.enabled !== false ? ext.require || ext.id : null
450
+ return /(^|[/\\])add-git-dates(\.js)?$/.test(String(id || ''))
451
+ })
452
+ }
453
+
287
454
  // Adds the generated files to every version of the connect component.
288
455
  // Mutates the content catalog and returns what it added, replaced, and
289
456
  // skipped. A file another source already provides is skipped, unless
@@ -332,6 +499,8 @@ module.exports.register = function ({ config }) {
332
499
  // Set in contextStarted: true when the playbook still lists a connect
333
500
  // content source with explicit refs.
334
501
  let gitSourceMode = false
502
+ // Set in contextStarted: true when add-git-dates is in the playbook.
503
+ let dateReferencePages = false
335
504
 
336
505
  this.on('contextStarted', ({ playbook }) => {
337
506
  // Clear a ref left over from an earlier build in the same process.
@@ -345,6 +514,7 @@ module.exports.register = function ({ config }) {
345
514
  )
346
515
  }
347
516
  gitSourceMode = hasConnectSource(playbook)
517
+ dateReferencePages = usesGitDates(playbook)
348
518
  if (gitSourceMode && (process.env[LOCAL_DIR_ENV] || '').trim()) {
349
519
  logger.warn(`${LOCAL_DIR_ENV} is ignored because the playbook lists the connect repo as a content source`)
350
520
  }
@@ -422,7 +592,7 @@ module.exports.register = function ({ config }) {
422
592
  from = `the ${tag} ${asset.ASSET_NAME} release asset`
423
593
  }
424
594
  if (!files.some((f) => asset.toResource(f.path))) {
425
- throw new Error(`${from} has no files under modules/components/partials or modules/components/examples`)
595
+ throw new Error(`${from} has no files under modules/components/partials, modules/components/examples, or modules/components/attachments`)
426
596
  }
427
597
  const r = addConnectDocs(contentCatalog, files, origin, { replace: !!localDir })
428
598
  const versions = r.versions.filter(Boolean)
@@ -432,9 +602,17 @@ module.exports.register = function ({ config }) {
432
602
  (localDir
433
603
  ? `replaced ${r.replaced} provided by another source; skipped ${r.outsideGenerated}`
434
604
  : `skipped ${r.providedElsewhere} already provided by another source and ${r.outsideGenerated}`) +
435
- ' outside the generated partials and examples'
605
+ ' outside the generated partials, examples, and attachments'
436
606
  )
607
+ // Resolve only included files supplied by this asset. Retained copies
608
+ // from another source keep their git dates, even if the asset differs.
609
+ if (!localDir && dateReferencePages) {
610
+ const keys = new Set(contentCatalog.getPages().flatMap((page) => connectIncludes(page, contentCatalog, origin)))
611
+ const dates = await referenceReleaseDates(origin.tag, files, keys, logger)
612
+ const count = markReferenceChanges(contentCatalog, dates, origin)
613
+ logger.info(`Redpanda Connect ${origin.tag}: resolved last change dates for ${dates.size} included generated files; ${count} pages use those release dates when later than their last commit`)
614
+ }
437
615
  })
438
616
  }
439
617
 
440
- module.exports._internal = { isRemote, isUpstreamConnect, redact, isConnectSource, isConnectOrigin, toTag, highestStableTag, stableTags, findOlderReleaseWithAsset, resolveLatestTag, FALLBACK_RELEASE_LIMIT, FALLBACK_PROBE_LIMIT, wantsLatest, removeLatestConnectSources, filterConnectContent, hasConnectSource, addConnectDocs, LOCAL_DIR_ENV }
618
+ module.exports._internal = { isRemote, isUpstreamConnect, redact, isConnectSource, isConnectOrigin, toTag, highestStableTag, stableTags, findOlderReleaseWithAsset, resolveLatestTag, FALLBACK_RELEASE_LIMIT, FALLBACK_PROBE_LIMIT, wantsLatest, removeLatestConnectSources, filterConnectContent, hasConnectSource, addConnectDocs, LOCAL_DIR_ENV, changedReferenceKeys, connectIncludes, markReferenceChanges, usesGitDates, FIRST_ASSET_RELEASE, DATE_RELEASE_LIMIT }
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+ const { escapeHtml } = require('../extension-utils/html-utils')
4
5
 
5
6
  /**
6
7
  * Resolves AsciiDoc xrefs in JSON attachment files to HTML links.
@@ -240,15 +241,3 @@ function resolveXrefsInString (text, contentCatalog, context, logger) {
240
241
  }
241
242
  })
242
243
  }
243
-
244
- /**
245
- * Escape HTML special characters to prevent XSS.
246
- */
247
- function escapeHtml (text) {
248
- if (!text) return ''
249
- return String(text)
250
- .replace(/&/g, '&amp;')
251
- .replace(/</g, '&lt;')
252
- .replace(/>/g, '&gt;')
253
- .replace(/"/g, '&quot;')
254
- }