@redpanda-data/docs-extensions-and-macros 5.53.0 → 5.55.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 (51) hide show
  1. package/bin/doc-tools-mcp.js +4 -5
  2. package/bin/doc-tools.js +166 -2
  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/format-helm-spec.js +4 -3
  7. package/cli-utils/helm-commented-values.js +88 -18
  8. package/cli-utils/self-managed-docs-branch.js +36 -30
  9. package/extension-utils/html-utils.js +27 -0
  10. package/extensions/REFERENCE.adoc +64 -12
  11. package/extensions/add-git-dates.js +13 -1
  12. package/extensions/aggregate-terms.js +34 -20
  13. package/extensions/generate-rp-connect-info.js +79 -24
  14. package/extensions/modify-connect-tag-playbook.js +498 -75
  15. package/extensions/resolve-xrefs-in-attachments.js +1 -12
  16. package/extensions/url-to-xref.js +3 -0
  17. package/extensions/util/connect-catalog.js +10 -5
  18. package/extensions/util/connect-docs-asset.js +188 -0
  19. package/extensions/util/connect-github.js +16 -0
  20. package/extensions/version-fetcher/fetch-latest-docker-tag.js +35 -13
  21. package/extensions/version-fetcher/get-latest-redpanda-helm-version-from-operator.js +64 -40
  22. package/extensions/version-fetcher/list-connect-releases.js +10 -0
  23. package/extensions/version-fetcher/release-lines.js +64 -0
  24. package/extensions/version-fetcher/set-latest-version.js +73 -3
  25. package/macros/README.adoc +1 -0
  26. package/macros/REFERENCE.adoc +99 -0
  27. package/macros/badge.js +5 -8
  28. package/macros/enterprise.js +2 -5
  29. package/macros/glossary.js +4 -14
  30. package/macros/iceberg-explorer.js +242 -0
  31. package/macros/prop.js +3 -13
  32. package/mcp/USER_GUIDE.adoc +25 -135
  33. package/package.json +4 -2
  34. package/tools/connect-docs/check-build-log.js +311 -0
  35. package/tools/connect-docs/check-rendered-html.js +469 -0
  36. package/tools/connect-docs/ci-playbook.yml +77 -0
  37. package/tools/connect-docs/diff-generated.js +382 -0
  38. package/tools/connect-docs/pr-comment.js +104 -0
  39. package/tools/doc-impact-recording/index.js +190 -0
  40. package/tools/overrides-audit/README.adoc +1 -2
  41. package/tools/overrides-audit/adapters/properties.js +1 -1
  42. package/tools/overrides-audit/index.js +3 -4
  43. package/tools/redpanda-connect/AUTOMATION.md +3 -36
  44. package/tools/redpanda-connect/README.adoc +5 -33
  45. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +3 -3
  46. package/tools/redpanda-connect/pr-summary-formatter.js +6 -4
  47. package/tools/redpanda-connect/report-delta.js +6 -3
  48. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +38 -16
  49. package/tools/redpanda-release-notes/generate-release-notes.js +116 -1
  50. package/tools/rpk-docs/templates/command.hbs +1 -1
  51. package/tools/overrides-audit/adapters/connect.js +0 -144
@@ -1,38 +1,90 @@
1
1
  'use strict';
2
2
 
3
- // Sources the Redpanda Connect reference partials from the connect repo.
3
+ // Sources the Redpanda Connect generated reference docs (field reference,
4
+ // examples, metadata, descriptions, config snippets, Bloblang reference, and
5
+ // platforms/catalog.json) for the `connect` component.
4
6
  //
5
- // A playbook lists the connect repo as a content source:
7
+ // The default: the playbook has no connect content source. Every connect
8
+ // release publishes the generated tree as a redpanda-connect-docs.tar.gz
9
+ // release asset (util/connect-docs-asset). In contentClassified, the extension
10
+ // resolves the release to use, downloads that asset, and adds its partials and
11
+ // examples to the `connect` component that rp-connect-docs provides:
12
+ //
13
+ // - The latest stable vX.Y.Z release by default, so the docs never show fields
14
+ // from unreleased code on connect's main branch.
15
+ // - The `tag` extension config key picks a specific release instead.
16
+ // - The REDPANDA_CONNECT_DOCS_DIR environment variable points at a local copy
17
+ // of the tree (a directory that contains modules/, such as a connect
18
+ // checkout's docs/ after running its generator, or a local copy of the
19
+ // tarball) to preview unreleased connect changes. It wins over everything,
20
+ // and its files replace any copy another source provides.
21
+ //
22
+ // Connect publishes a release first and attaches the asset later, in a
23
+ // separate job that can also fail. So when the latest release has no asset
24
+ // (404), the extension falls back to the newest older stable release that has
25
+ // one, and logs a warning that names both. It checks the asset lists from the
26
+ // GitHub releases API (at most FALLBACK_RELEASE_LIMIT releases), or, without
27
+ // the API, probes the downloads of the next FALLBACK_PROBE_LIMIT lower stable
28
+ // tags from git ls-remote. A release picked with the `tag` config never falls
29
+ // back: a release picked with `tag` that has no asset stops the build. When
30
+ // no release has the asset, nothing is added, and the guard in
31
+ // generate-rp-connect-info stops the build if connector pages then have no
32
+ // generated partials. Any other download or archive failure stops the build
33
+ // here. A file another source already provides (rp-connect-docs still commits
34
+ // its own copies until it migrates) is skipped.
35
+ //
36
+ // Playbooks that still list the upstream connect repo with `tags: latest` as
37
+ // its only ref (the setup from before the release asset):
6
38
  //
7
39
  // - url: https://github.com/redpanda-data/connect
8
- // tags: latest # replaced with the latest release tag at build time
40
+ // tags: latest
9
41
  // start_path: docs
10
42
  //
11
- // The extension does two things:
43
+ // That source is removed from the playbook in contextStarted, with a warning,
44
+ // and the build continues with the release asset. Connect releases no longer
45
+ // ship docs/antora.yml, so Antora could not aggregate it anyway.
12
46
  //
13
- // 1. Pins the source to the latest connect release tag, so the docs never
14
- // show fields from unreleased code on connect's main branch.
15
- // 2. Filters what the source contributes: only the generated
16
- // modules/components/partials and modules/components/examples trees of the
17
- // `connect` component are kept, and any file another source (rp-connect-docs)
18
- // already provides is dropped. Antora fails the build on a duplicate page or
19
- // partial, so this makes it safe to add the source to a playbook before or
20
- // after rp-connect-docs stops committing its own generated copies, and it
21
- // ignores the full pages that older connect tags still carry.
47
+ // A connect source with explicit refs (a branch, a specific tag, a fork, or a
48
+ // local clone named connect) is how people preview a connect branch, so it is
49
+ // kept, and no asset is downloaded. Only the generated
50
+ // modules/components/partials and modules/components/examples trees of the
51
+ // `connect` component are kept from that source, and any file another source
52
+ // already provides is dropped, because Antora fails the build on a duplicate
53
+ // page or partial.
22
54
  //
23
- // It also shares the tag it resolves (util/connect-catalog), so
24
- // generate-rp-connect-info reads info.csv from the same ref as the reference
25
- // content.
55
+ // The release asset tag is shared (util/connect-catalog), so
56
+ // generate-rp-connect-info reads info.csv and catalog.json from the same ref
57
+ // as the reference content.
26
58
 
27
59
  const { raiseListenerLimit } = require('./util/raise-listener-limit')
28
60
  const getLatestConnectTag = require('./version-fetcher/get-latest-connect')
29
- const { getGitHubApiToken } = require('../cli-utils/github-token')
61
+ const listConnectReleases = require('./version-fetcher/list-connect-releases')
62
+ const { createGitHub } = require('./util/connect-github')
30
63
  const { isConnectSource, isConnectOrigin, githubRepoOf, setResolvedConnectRef } = require('./util/connect-catalog')
64
+ const asset = require('./util/connect-docs-asset')
65
+ const { getGitHubApiToken } = require('../cli-utils/github-token')
66
+ const { gitAuthEnv } = require('../cli-utils/git-credential-env')
31
67
 
32
68
  const OWNER = 'redpanda-data'
33
69
  const REPO = 'connect'
34
70
  const COMPONENT = 'connect'
35
71
  const KEPT_PATHS = ['modules/components/partials/', 'modules/components/examples/']
72
+ const CONNECT_URL = `https://github.com/${OWNER}/${REPO}`
73
+ const LOCAL_DIR_ENV = 'REDPANDA_CONNECT_DOCS_DIR'
74
+ // How many releases the API fallback looks at, and how many lower git tags
75
+ // the probe fallback tries, when the latest release has no asset yet.
76
+ const FALLBACK_RELEASE_LIMIT = 10
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
36
88
 
37
89
  // HTTPS, SSH (ssh://), and scp-style (git@host:owner/repo) URLs are all
38
90
  // remote content sources in Antora.
@@ -54,23 +106,43 @@ function redact (text) {
54
106
  return String(text || '').replace(/([a-z][a-z+.-]*:\/\/)[^@/\s]*@/gi, '$1')
55
107
  }
56
108
 
109
+ // [major, minor, patch] of a stable vX.Y.Z tag, or null for anything else,
110
+ // such as a prerelease.
111
+ function stableVersion (tag) {
112
+ const m = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag || '')
113
+ return m ? m.slice(1).map(Number) : null
114
+ }
115
+
116
+ function compareVersions (a, b) {
117
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]
118
+ }
119
+
120
+ // Stable vX.Y.Z tags from `git ls-remote` output, highest first.
121
+ function stableTags (lsRemoteOutput) {
122
+ const tags = new Set()
123
+ for (const line of String(lsRemoteOutput || '').split('\n')) {
124
+ const m = line.match(/refs\/tags\/(v\d+\.\d+\.\d+)$/)
125
+ if (m) tags.add(m[1])
126
+ }
127
+ return [...tags].sort((a, b) => compareVersions(stableVersion(b), stableVersion(a)))
128
+ }
129
+
57
130
  // Highest stable vX.Y.Z tag from `git ls-remote` output. Used when the GitHub
58
131
  // API is unavailable, for example when an unauthenticated build is rate limited.
59
132
  function highestStableTag (lsRemoteOutput) {
60
- const newer = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]
61
- let best = null
62
- for (const line of String(lsRemoteOutput || '').split('\n')) {
63
- const m = line.match(/refs\/tags\/(v(\d+)\.(\d+)\.(\d+))$/)
64
- if (!m) continue
65
- const v = m.slice(2, 5).map(Number)
66
- if (!best || newer(v, best.v) > 0) best = { tag: m[1], v }
67
- }
68
- return best && best.tag
133
+ return stableTags(lsRemoteOutput)[0] || null
69
134
  }
70
135
 
71
- function latestTagFromGit (url) {
136
+ // With a token, git authenticates through the shared credential helper, so a
137
+ // private connect repo still lists its tags.
138
+ function gitTags (url) {
72
139
  const { execFileSync } = require('child_process')
73
- return highestStableTag(execFileSync('git', ['ls-remote', '--tags', '--refs', url], { encoding: 'utf8', timeout: 60000 }))
140
+ const env = gitAuthEnv(getGitHubApiToken())
141
+ return execFileSync('git', ['ls-remote', '--tags', '--refs', url], { encoding: 'utf8', timeout: 60000, env })
142
+ }
143
+
144
+ function latestTagFromGit (url) {
145
+ return highestStableTag(gitTags(url))
74
146
  }
75
147
 
76
148
  function toTag (tagName) {
@@ -78,9 +150,10 @@ function toTag (tagName) {
78
150
  return tagName.startsWith('v') ? tagName : `v${tagName}`
79
151
  }
80
152
 
81
- // The upstream connect source asks to be pinned with `tags: latest` as its
82
- // only ref. A source with any other refs, such as a fork or a PR branch used
83
- // to preview a connect change, keeps what the playbook says.
153
+ // The upstream connect source with `tags: latest` as its only ref: the setup
154
+ // from before the release asset. A source with any other refs, such as a fork
155
+ // or a PR branch used to preview a connect change, keeps what the playbook
156
+ // says.
84
157
  function wantsLatest (source) {
85
158
  if (!isRemote(source.url) || !isConnectSource(source.url) || !isUpstreamConnect(source.url)) return false
86
159
  const tags = Array.isArray(source.tags) ? source.tags : [source.tags]
@@ -88,20 +161,17 @@ function wantsLatest (source) {
88
161
  return tags.length === 1 && tags[0] === 'latest' && branches.length === 0
89
162
  }
90
163
 
91
- // Rewrites the connect content sources that ask for the latest release to
92
- // use `tag`. Returns true when a source was updated.
93
- function pinConnectSource (playbook, tag) {
164
+ // Removes the upstream connect sources that ask for the latest release, which
165
+ // the release asset replaces. Mutates the playbook's source list in place and
166
+ // returns the removed sources.
167
+ function removeLatestConnectSources (playbook) {
94
168
  const sources = playbook && playbook.content && playbook.content.sources
95
- if (!Array.isArray(sources) || !tag) return false
96
- let updated = false
97
- for (const source of sources) {
98
- if (!wantsLatest(source)) continue
99
- source.tags = [tag]
100
- // Without this, Antora's default branch patterns also pull in main.
101
- source.branches = []
102
- updated = true
169
+ if (!Array.isArray(sources)) return []
170
+ const removed = []
171
+ for (let i = sources.length - 1; i >= 0; i--) {
172
+ if (wantsLatest(sources[i])) removed.unshift(...sources.splice(i, 1))
103
173
  }
104
- return updated
174
+ return removed
105
175
  }
106
176
 
107
177
  // Removes connect-sourced files that must not be published. Mutates the
@@ -143,53 +213,406 @@ function filterConnectContent (contentAggregate) {
143
213
  return report
144
214
  }
145
215
 
146
- module.exports.register = function () {
147
- raiseListenerLimit(this)
148
- const logger = this.getLogger('modify-connect-tag-playbook-extension')
216
+ // True when the playbook lists the connect repo (or a local clone named
217
+ // connect) as a content source, which keeps the git source behavior. Run it
218
+ // after removeLatestConnectSources.
219
+ function hasConnectSource (playbook) {
220
+ const sources = (playbook && playbook.content && playbook.content.sources) || []
221
+ return sources.some((source) => isConnectSource(source.url))
222
+ }
149
223
 
150
- this.on('contextStarted', async ({ playbook }) => {
151
- const sources = (playbook && playbook.content && playbook.content.sources) || []
152
- // Clear a ref left over from an earlier build in the same process.
153
- setResolvedConnectRef(null)
154
- const source = sources.find(wantsLatest)
155
- if (!source) return
156
- let tag = null
224
+ // The latest stable connect release tag: the GitHub API first, then the
225
+ // highest stable vX.Y.Z tag from git ls-remote. Null when both fail.
226
+ async function resolveLatestTag (url, logger) {
227
+ let tag = null
228
+ try {
229
+ tag = toTag(await getLatestConnectTag(await createGitHub(), OWNER, REPO, logger))
230
+ } catch (error) {
231
+ logger.warn(`GitHub API lookup of the latest Redpanda Connect release failed: ${error.message}`)
232
+ }
233
+ if (!tag) {
157
234
  try {
158
- const { Octokit } = await import('@octokit/rest')
159
- const { retry } = await import('@octokit/plugin-retry')
160
- const token = getGitHubApiToken()
161
- const github = new (Octokit.plugin(retry))({ userAgent: 'Redpanda Docs', auth: token || undefined, retry: { doNotRetry: [403, 404, 429] } })
162
- tag = toTag(await getLatestConnectTag(github, OWNER, REPO, logger))
235
+ tag = latestTagFromGit(url)
236
+ if (tag) logger.info(`Resolved the latest Redpanda Connect release from git tags: ${tag}`)
163
237
  } catch (error) {
164
- logger.warn(`GitHub API lookup of the latest Redpanda Connect release failed: ${error.message}`)
238
+ logger.warn(redact(`git ls-remote of ${url} failed: ${error.message}`))
165
239
  }
166
- if (!tag) {
167
- try {
168
- tag = latestTagFromGit(source.url)
169
- if (tag) logger.info(`Resolved the latest Redpanda Connect release from git tags: ${tag}`)
170
- } catch (error) {
171
- logger.warn(redact(`git ls-remote of ${source.url} failed: ${error.message}`))
240
+ }
241
+ return tag
242
+ }
243
+
244
+ // Downloads the asset of a release. Null on a 404; throws on any other failure.
245
+ async function downloadRelease (tag, logger) {
246
+ try {
247
+ return await asset.downloadAsset(tag, { logger, token: getGitHubApiToken() })
248
+ } catch (error) {
249
+ throw new Error(`Could not download the Redpanda Connect reference docs for ${tag}: ${error.message}`)
250
+ }
251
+ }
252
+
253
+ // The newest stable release older than `latestTag` that has the asset, as
254
+ // { tag, archive }, or null when none has it. The GitHub releases API lists
255
+ // the most recent releases with their assets, so only the chosen release is
256
+ // downloaded. Without the API, it probes the downloads of the next few lower
257
+ // stable tags from git ls-remote instead.
258
+ async function findOlderReleaseWithAsset (latestTag, url, logger) {
259
+ const latest = stableVersion(latestTag)
260
+ const isOlder = (tag) => {
261
+ const v = stableVersion(tag)
262
+ return !!v && (!latest || compareVersions(v, latest) < 0)
263
+ }
264
+ let releases = null
265
+ try {
266
+ releases = await listConnectReleases(await createGitHub(), OWNER, REPO, FALLBACK_RELEASE_LIMIT)
267
+ } catch (error) {
268
+ logger.warn(`GitHub API listing of Redpanda Connect releases failed: ${error.message}; probing the next ${FALLBACK_PROBE_LIMIT} lower release tags from git instead`)
269
+ }
270
+ if (releases) {
271
+ const found = releases
272
+ .filter((r) => r && !r.draft && !r.prerelease && isOlder(r.tag_name))
273
+ .sort((a, b) => compareVersions(stableVersion(b.tag_name), stableVersion(a.tag_name)))
274
+ .find((r) => (r.assets || []).some((a) => a && a.name === asset.ASSET_NAME))
275
+ if (!found) {
276
+ logger.info(`None of the ${releases.length} most recent Redpanda Connect releases has a ${asset.ASSET_NAME} release asset`)
277
+ return null
278
+ }
279
+ const archive = await downloadRelease(found.tag_name, logger)
280
+ return archive ? { tag: found.tag_name, archive } : null
281
+ }
282
+ let tags
283
+ try {
284
+ tags = stableTags(gitTags(url)).filter(isOlder).slice(0, FALLBACK_PROBE_LIMIT)
285
+ } catch (error) {
286
+ logger.warn(redact(`git ls-remote of ${url} failed: ${error.message}`))
287
+ return null
288
+ }
289
+ for (const tag of tags) {
290
+ const archive = await downloadRelease(tag, logger)
291
+ if (archive) return { tag, archive }
292
+ }
293
+ if (tags.length) logger.info(`None of the next ${tags.length} lower Redpanda Connect release tags (${tags.join(', ')}) has a ${asset.ASSET_NAME} release asset`)
294
+ return null
295
+ }
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
+
454
+ // Adds the generated files to every version of the connect component.
455
+ // Mutates the content catalog and returns what it added, replaced, and
456
+ // skipped. A file another source already provides is skipped, unless
457
+ // `replace` is set (a local directory, which previews unreleased changes and
458
+ // so wins over committed copies).
459
+ function addConnectDocs (contentCatalog, files, origin, { replace = false } = {}) {
460
+ const report = { added: 0, replaced: 0, providedElsewhere: 0, outsideGenerated: 0, versions: [] }
461
+ const component = contentCatalog.getComponent(COMPONENT)
462
+ if (!component) return report
463
+ report.versions = component.versions.map((v) => v.version)
464
+ for (const entry of files) {
465
+ const resource = asset.toResource(entry.path)
466
+ if (!resource) {
467
+ report.outsideGenerated++
468
+ continue
469
+ }
470
+ for (const version of report.versions) {
471
+ const src = { component: COMPONENT, version, module: asset.MODULE, family: resource.family, relative: resource.relative }
472
+ const existing = contentCatalog.getById(src)
473
+ if (existing && !replace) {
474
+ report.providedElsewhere++
475
+ continue
172
476
  }
477
+ if (existing) contentCatalog.removeFile(existing)
478
+ contentCatalog.addFile({
479
+ path: resource.path,
480
+ contents: Buffer.from(entry.contents),
481
+ src: { ...src, path: resource.path, origin },
482
+ })
483
+ if (existing) report.replaced++
484
+ else report.added++
485
+ }
486
+ }
487
+ return report
488
+ }
489
+
490
+ module.exports.register = function ({ config }) {
491
+ raiseListenerLimit(this)
492
+ const logger = this.getLogger('modify-connect-tag-playbook-extension')
493
+ const rawTag = config && config.tag != null ? String(config.tag).trim() : ''
494
+ // `latest` is the default; as a tag it would become the nonexistent vlatest.
495
+ if (rawTag.toLowerCase() === 'latest') {
496
+ throw new Error('The `tag` config of modify-connect-tag-playbook is `latest`. The latest stable Redpanda Connect release is the default, so remove `tag`, or set it to a release tag such as v4.113.0.')
497
+ }
498
+ const configuredTag = toTag(rawTag || null)
499
+ // Set in contextStarted: true when the playbook still lists a connect
500
+ // content source with explicit refs.
501
+ let gitSourceMode = false
502
+ // Set in contextStarted: true when add-git-dates is in the playbook.
503
+ let dateReferencePages = false
504
+
505
+ this.on('contextStarted', ({ playbook }) => {
506
+ // Clear a ref left over from an earlier build in the same process.
507
+ setResolvedConnectRef(null)
508
+ const removed = removeLatestConnectSources(playbook)
509
+ if (removed.length) {
510
+ this.updateVariables({ playbook })
511
+ logger.warn(
512
+ `The Redpanda Connect content source (${removed.map((s) => redact(s.url)).join(', ')} with tags: latest) is no longer needed and was removed from the playbook, ` +
513
+ `so the reference docs come from the ${asset.ASSET_NAME} release asset. Remove that source from the playbook.`
514
+ )
173
515
  }
174
- if (!tag) {
175
- // Guessing would let Antora's default branch patterns pull in connect's
176
- // branches, whose docs are unreleased or missing, so stop the build.
177
- throw new Error('Could not resolve the latest Redpanda Connect release tag for the connect content source. Set a GitHub token or check network access.')
516
+ gitSourceMode = hasConnectSource(playbook)
517
+ dateReferencePages = usesGitDates(playbook)
518
+ if (gitSourceMode && (process.env[LOCAL_DIR_ENV] || '').trim()) {
519
+ logger.warn(`${LOCAL_DIR_ENV} is ignored because the playbook lists the connect repo as a content source`)
178
520
  }
179
- pinConnectSource(playbook, tag)
180
- setResolvedConnectRef(tag)
181
- this.updateVariables({ playbook })
182
- logger.info(`Sourcing Redpanda Connect reference content from ${tag}`)
183
521
  })
184
522
 
185
523
  this.on('contentAggregated', ({ contentAggregate }) => {
524
+ if (!gitSourceMode) return
186
525
  const r = filterConnectContent(contentAggregate)
187
526
  if (r.kept + r.outsideGenerated + r.providedElsewhere + r.otherComponents === 0) return
188
527
  logger.info(
189
528
  `Redpanda Connect content: kept ${r.kept} generated files; skipped ${r.providedElsewhere} already provided by another source, ` +
190
- `${r.outsideGenerated} outside the generated partials and examples, and ${r.otherComponents} from other components`
529
+ `${r.outsideGenerated} outside the generated partials, examples, and attachments, and ${r.otherComponents} from other components`
191
530
  )
192
531
  })
532
+
533
+ // Antora runs listeners of an event one at a time, in registration order,
534
+ // which follows the order of the playbook's extension list. Prepending puts
535
+ // this listener first wherever the extension is listed, so the files exist
536
+ // before generate-rp-connect-info's guard and any other contentClassified
537
+ // listener reads the connect partials.
538
+ const subscribe = typeof this.prependListener === 'function' ? this.prependListener : this.on
539
+ subscribe.call(this, 'contentClassified', async ({ contentCatalog }) => {
540
+ if (gitSourceMode) return
541
+ if (!contentCatalog.getComponent(COMPONENT)) return
542
+ const localDir = (process.env[LOCAL_DIR_ENV] || '').trim()
543
+ let files
544
+ let origin
545
+ let from
546
+ if (localDir) {
547
+ setResolvedConnectRef(configuredTag)
548
+ try {
549
+ files = await asset.readLocal(localDir)
550
+ } catch (error) {
551
+ throw new Error(`Could not read the Redpanda Connect reference docs from ${LOCAL_DIR_ENV} (${localDir}): ${error.message}`)
552
+ }
553
+ origin = { type: 'local', url: CONNECT_URL, startPath: 'docs', localDir }
554
+ from = `${LOCAL_DIR_ENV} (${localDir})`
555
+ } else {
556
+ let tag = configuredTag || (await resolveLatestTag(`${CONNECT_URL}.git`, logger))
557
+ if (!tag) {
558
+ throw new Error(
559
+ 'Could not resolve the latest Redpanda Connect release, so the connect reference docs cannot be downloaded. ' +
560
+ `Set a GitHub token, check network access, or set the \`tag\` config of this extension or ${LOCAL_DIR_ENV}.`
561
+ )
562
+ }
563
+ setResolvedConnectRef(tag)
564
+ let archive = await downloadRelease(tag, logger)
565
+ // The latest release can be published before its asset is attached, or
566
+ // its asset job can fail. An explicit `tag` means that release only.
567
+ if (!archive && !configuredTag) {
568
+ const older = await findOlderReleaseWithAsset(tag, `${CONNECT_URL}.git`, logger)
569
+ if (older) {
570
+ logger.warn(`Redpanda Connect ${tag} has no ${asset.ASSET_NAME} asset yet; using ${older.tag}`)
571
+ tag = older.tag
572
+ archive = older.archive
573
+ setResolvedConnectRef(tag)
574
+ }
575
+ }
576
+ if (!archive && configuredTag) {
577
+ throw new Error(
578
+ `Redpanda Connect release ${tag} has no ${asset.ASSET_NAME} asset (404). ` +
579
+ `Set the \`tag\` config of modify-connect-tag-playbook to a release that has it, or remove \`tag\` to use the latest release.`
580
+ )
581
+ }
582
+ if (!archive) {
583
+ logger.info(`Redpanda Connect ${tag} has no ${asset.ASSET_NAME} release asset (404), so no generated reference docs were added from it`)
584
+ return
585
+ }
586
+ try {
587
+ files = await asset.readTarGz(archive)
588
+ } catch (error) {
589
+ throw new Error(`The ${asset.ASSET_NAME} release asset of Redpanda Connect ${tag} is corrupt: ${error.message}`)
590
+ }
591
+ origin = { type: 'release-asset', url: CONNECT_URL, startPath: 'docs', reftype: 'tag', refname: tag, tag, asset: asset.assetUrl(tag) }
592
+ from = `the ${tag} ${asset.ASSET_NAME} release asset`
593
+ }
594
+ if (!files.some((f) => asset.toResource(f.path))) {
595
+ throw new Error(`${from} has no files under modules/components/partials or modules/components/examples`)
596
+ }
597
+ const r = addConnectDocs(contentCatalog, files, origin, { replace: !!localDir })
598
+ const versions = r.versions.filter(Boolean)
599
+ logger.info(
600
+ `Redpanda Connect reference docs from ${from}: added ${r.added} files to the ${COMPONENT} component` +
601
+ `${versions.length ? ` (${versions.join(', ')})` : ''}; ` +
602
+ (localDir
603
+ ? `replaced ${r.replaced} provided by another source; skipped ${r.outsideGenerated}`
604
+ : `skipped ${r.providedElsewhere} already provided by another source and ${r.outsideGenerated}`) +
605
+ ' outside the generated partials, examples, and attachments'
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
+ }
615
+ })
193
616
  }
194
617
 
195
- module.exports._internal = { isRemote, isUpstreamConnect, redact, isConnectSource, isConnectOrigin, toTag, highestStableTag, wantsLatest, pinConnectSource, filterConnectContent }
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 }