@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
@@ -0,0 +1,469 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ /**
7
+ * doc-tools check-rendered-html: scan the rendered HTML of one or more Antora
8
+ * component paths for AsciiDoc that did not convert, and for links that go
9
+ * nowhere. Every rule here is a symptom a reader sees on the published page
10
+ * and that the Antora log does not report:
11
+ *
12
+ * literal-backtick a backtick in prose, outside code. Two backticks for
13
+ * an empty enum option, or a glued backtick such as
14
+ * `name`can, both land here.
15
+ * literal-xref the text `xref:` outside code: a cross reference that
16
+ * was never parsed as a macro.
17
+ * literal-include the text `include::` outside code.
18
+ * leftover-attribute a `{page-...}` or `{env-...}` reference left in the
19
+ * output because the attribute was not set.
20
+ * table-markup `|===` in the output: a table that did not parse.
21
+ * unresolved-include Asciidoctor's "Unresolved include directive" text,
22
+ * checked everywhere, code blocks included.
23
+ * unresolved-xref a link Antora marked with the `unresolved` class.
24
+ * empty-section a heading followed directly by a heading of the same
25
+ * or a higher level, so the section has no content.
26
+ * broken-anchor a link fragment (`#x`, `../kafka/#tls`) that matches
27
+ * no `id` (or `a[name]`) on the target page.
28
+ * broken-link a relative or root-relative link to a page under a
29
+ * link root (by default `connect/` and
30
+ * `cloud-data-platform/develop/connect/`) that is not in
31
+ * the build.
32
+ *
33
+ * Links are checked against the built site only: external links, `mailto:`
34
+ * and the like are ignored, and a link into a path outside every link root,
35
+ * or into a link root the build does not contain (a trimmed build), is counted
36
+ * as unchecked, never as a finding.
37
+ *
38
+ * Only the page body (`article.doc`) is scanned, so the site navigation and
39
+ * footer never count. Findings are reported per page; the command exits 0
40
+ * unless --strict, because the published docs still have known instances.
41
+ * With a changed-pages list, findings are split into those on the listed
42
+ * pages and the rest, and --strict fails only on the listed pages.
43
+ */
44
+
45
+ const RULES = Object.freeze({
46
+ 'literal-backtick': 'Literal backtick outside code',
47
+ 'literal-xref': 'Literal xref: text outside code',
48
+ 'literal-include': 'Literal include:: text outside code',
49
+ 'leftover-attribute': 'Leftover {page-*} or {env-*} attribute reference',
50
+ 'table-markup': 'Literal |=== table markup',
51
+ 'unresolved-include': 'Unresolved include directive',
52
+ 'unresolved-xref': 'Unresolved xref (link with the unresolved class)',
53
+ 'empty-section': 'Empty section (heading followed directly by another heading of the same or higher level)',
54
+ 'broken-anchor': 'Broken anchor (link fragment with no matching id on the target page)',
55
+ 'broken-link': 'Broken link (target page not in the build)'
56
+ })
57
+
58
+ // Text inside these elements is code (or not prose at all), where a backtick
59
+ // or `include::` is legitimate content.
60
+ const CODE_ELEMENTS = new Set(['code', 'pre', 'kbd', 'samp', 'tt', 'script', 'style', 'textarea', 'svg', 'math'])
61
+
62
+ const TEXT_RULES = [
63
+ { id: 'literal-backtick', re: /`/g },
64
+ { id: 'literal-xref', re: /\bxref:/g },
65
+ { id: 'literal-include', re: /include::/g },
66
+ { id: 'leftover-attribute', re: /\{(?:page|env)-[A-Za-z0-9_-]+\}/g },
67
+ { id: 'table-markup', re: /\|===/g }
68
+ ]
69
+ const UNRESOLVED_INCLUDE_RE = /Unresolved include directive/g
70
+
71
+ const DEFAULTS = Object.freeze({
72
+ component: 'connect',
73
+ // Site paths whose pages must exist when linked to. A root the build does
74
+ // not contain is skipped, so a trimmed build reports unchecked links there.
75
+ linkRoots: Object.freeze(['connect', 'cloud-data-platform/develop/connect']),
76
+ maxSamples: 3,
77
+ maxPages: 100,
78
+ contextChars: 40
79
+ })
80
+
81
+ function snippet (text, index, length, contextChars) {
82
+ const start = Math.max(0, index - contextChars)
83
+ const end = Math.min(text.length, index + length + contextChars)
84
+ return `${start > 0 ? '...' : ''}${text.slice(start, end)}${end < text.length ? '...' : ''}`.replace(/\s+/g, ' ').trim()
85
+ }
86
+
87
+ function headingLevel (node) {
88
+ if (!node || node.type !== 'tag') return 0
89
+ const m = /^h([1-6])$/.exec(node.name)
90
+ return m ? Number(m[1]) : 0
91
+ }
92
+
93
+ function isBlankText (node) {
94
+ return node.type === 'text' ? !node.data.trim() : node.type === 'comment'
95
+ }
96
+
97
+ function hasClass (node, cls) {
98
+ const value = node.attribs && node.attribs.class
99
+ return !!value && value.split(/\s+/).includes(cls)
100
+ }
101
+
102
+ function safeDecode (text) {
103
+ try {
104
+ return decodeURIComponent(text)
105
+ } catch {
106
+ return text
107
+ }
108
+ }
109
+
110
+ // Every fragment target in a document: element ids and named anchors.
111
+ function collectIds ($) {
112
+ const ids = new Set()
113
+ $('[id]').each((_, el) => { ids.add(el.attribs.id) })
114
+ $('a[name]').each((_, el) => { ids.add(el.attribs.name) })
115
+ return ids
116
+ }
117
+
118
+ function loadHtml (html) {
119
+ // cheerio/slim parses with htmlparser2 and leaves out the fetch helpers,
120
+ // whose undici dependency needs Node.js 20 or later.
121
+ const cheerio = require('cheerio/slim')
122
+ return cheerio.load(html)
123
+ }
124
+
125
+ // The origin pages are resolved against. Only its path matters.
126
+ const SITE_ORIGIN = 'http://site.invalid'
127
+
128
+ /**
129
+ * Resolve links from built pages against an Antora output directory.
130
+ * check(fromPage, href) returns { status } where status is one of 'ok',
131
+ * 'external', 'unchecked', 'broken-link' or 'broken-anchor'. Target pages are
132
+ * parsed once and their ids cached.
133
+ */
134
+ function createLinkResolver ({ siteDir, linkRoots = DEFAULTS.linkRoots } = {}) {
135
+ const norm = (r) => String(r).replace(/^\/+|\/+$/g, '')
136
+ // A root counts only when the build contains it.
137
+ const roots = [...new Set(linkRoots.map(norm).filter(Boolean))].filter((r) => fs.existsSync(path.join(siteDir, r)))
138
+ const idCache = new Map()
139
+ const fileCache = new Map()
140
+ const isFile = (abs) => {
141
+ if (!fileCache.has(abs)) {
142
+ let ok = false
143
+ try { ok = fs.statSync(abs).isFile() } catch {}
144
+ fileCache.set(abs, ok)
145
+ }
146
+ return fileCache.get(abs)
147
+ }
148
+ const resolveFile = (rel) => {
149
+ const abs = path.join(siteDir, rel)
150
+ if (rel === '' || rel.endsWith('/')) return isFile(path.join(abs, 'index.html')) ? path.join(abs, 'index.html') : null
151
+ for (const candidate of [abs, path.join(abs, 'index.html'), `${abs}.html`]) if (isFile(candidate)) return candidate
152
+ return null
153
+ }
154
+ const idsOf = (file) => {
155
+ if (!idCache.has(file)) idCache.set(file, collectIds(loadHtml(fs.readFileSync(file, 'utf8'))))
156
+ return idCache.get(file)
157
+ }
158
+ return {
159
+ roots,
160
+ // Seed the cache with a page already parsed for its own checks.
161
+ remember (page, ids) { idCache.set(path.join(siteDir, page), ids) },
162
+ check (fromPage, href) {
163
+ const raw = href.trim()
164
+ // Any scheme (http:, mailto:, javascript:, ...) or a protocol-relative URL.
165
+ if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('//')) return { status: 'external' }
166
+ let url
167
+ try {
168
+ url = new URL(raw, `${SITE_ORIGIN}/${fromPage.split(path.sep).join('/')}`)
169
+ } catch {
170
+ return { status: 'unchecked' }
171
+ }
172
+ if (url.origin !== SITE_ORIGIN) return { status: 'external' }
173
+ const rel = safeDecode(url.pathname).replace(/^\/+/, '')
174
+ if (!roots.some((r) => rel === r || rel.startsWith(`${r}/`))) return { status: 'unchecked' }
175
+ const target = resolveFile(rel)
176
+ if (!target) return { status: 'broken-link' }
177
+ const fragment = safeDecode(url.hash.slice(1))
178
+ if (!fragment || !target.endsWith('.html')) return { status: 'ok' }
179
+ return { status: idsOf(target).has(fragment) ? 'ok' : 'broken-anchor' }
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Check one HTML document. Returns { findings: { ruleId: { count, samples } }, links }.
186
+ * Same-page fragments are always checked. Links to other pages are checked
187
+ * only when `resolver` (from createLinkResolver) and `page` (this page's path
188
+ * relative to the site directory) are given.
189
+ */
190
+ function checkHtml (html, { contextChars = DEFAULTS.contextChars, maxSamples = DEFAULTS.maxSamples, resolver, page } = {}) {
191
+ const $ = loadHtml(html)
192
+ const article = $('article.doc').get(0) || $('body').get(0) || $.root().get(0)
193
+ const findings = {}
194
+ const add = (id, sample) => {
195
+ const f = findings[id] || (findings[id] = { count: 0, samples: [] })
196
+ f.count++
197
+ if (sample && f.samples.length < maxSamples && !f.samples.includes(sample)) f.samples.push(sample)
198
+ }
199
+
200
+ // Text rules. A text node is "in code" when any ancestor is a code element.
201
+ const walk = (node, inCode) => {
202
+ if (node.type === 'text') {
203
+ const text = node.data
204
+ UNRESOLVED_INCLUDE_RE.lastIndex = 0
205
+ const unresolved = UNRESOLVED_INCLUDE_RE.test(text)
206
+ if (unresolved) {
207
+ UNRESOLVED_INCLUDE_RE.lastIndex = 0
208
+ let m
209
+ while ((m = UNRESOLVED_INCLUDE_RE.exec(text))) add('unresolved-include', snippet(text, m.index, m[0].length, contextChars))
210
+ }
211
+ if (inCode) return
212
+ for (const rule of TEXT_RULES) {
213
+ // The unresolved-include message quotes the include:: line; count it
214
+ // once, as unresolved-include, not again as literal text.
215
+ if (unresolved && rule.id === 'literal-include') continue
216
+ rule.re.lastIndex = 0
217
+ let m
218
+ while ((m = rule.re.exec(text))) add(rule.id, snippet(text, m.index, m[0].length, contextChars))
219
+ }
220
+ return
221
+ }
222
+ if (node.type !== 'tag' && node.type !== 'root' && node.type !== 'script' && node.type !== 'style') return
223
+ const childInCode = inCode || CODE_ELEMENTS.has(node.name)
224
+ if (node.name === 'a' && hasClass(node, 'unresolved')) {
225
+ add('unresolved-xref', `${$(node).text().trim()} -> ${node.attribs.href || ''}`.trim())
226
+ }
227
+ for (const child of node.children || []) walk(child, childInCode)
228
+ }
229
+ walk(article, false)
230
+
231
+ // Empty sections. Asciidoctor wraps each section in div.sectN with the
232
+ // heading first; a level-1 section's content sits in div.sectionbody. A
233
+ // section is empty when nothing but its heading (and blank text) is in it.
234
+ // A section that holds only subsections is not empty: its heading is
235
+ // followed by a LOWER-level heading.
236
+ $(article).find('div.sect1, div.sect2, div.sect3, div.sect4, div.sect5, div.sect6').each((_, sect) => {
237
+ const children = (sect.children || []).filter((c) => !isBlankText(c))
238
+ const heading = children.find((c) => headingLevel(c) > 0)
239
+ if (!heading) return
240
+ let content = children.filter((c) => c !== heading)
241
+ content = content.flatMap((c) => (c.type === 'tag' && hasClass(c, 'sectionbody') ? (c.children || []).filter((x) => !isBlankText(x)) : [c]))
242
+ if (content.length === 0) add('empty-section', $(heading).text().trim())
243
+ })
244
+
245
+ // Links. An unresolved xref is already reported as unresolved-xref.
246
+ const ids = collectIds($)
247
+ if (resolver && page) resolver.remember(page, ids)
248
+ const links = { internal: 0, external: 0, unchecked: 0 }
249
+ $(article).find('a[href]').each((_, a) => {
250
+ if (hasClass(a, 'unresolved')) return
251
+ const href = a.attribs.href.trim()
252
+ if (!href || href === '#') return
253
+ const label = `${$(a).text().replace(/\s+/g, ' ').trim()} -> ${href}`.trim()
254
+ if (href.startsWith('#')) {
255
+ links.internal++
256
+ if (!ids.has(safeDecode(href.slice(1)))) add('broken-anchor', label)
257
+ return
258
+ }
259
+ if (!resolver || !page) return
260
+ const { status } = resolver.check(page, href)
261
+ if (status === 'external') links.external++
262
+ else if (status === 'unchecked') links.unchecked++
263
+ else {
264
+ links.internal++
265
+ if (status !== 'ok') add(status, label)
266
+ }
267
+ })
268
+
269
+ return { findings, links }
270
+ }
271
+
272
+ function listHtmlFiles (dir) {
273
+ const out = []
274
+ const stack = [dir]
275
+ while (stack.length) {
276
+ const current = stack.pop()
277
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
278
+ const full = path.join(current, entry.name)
279
+ if (entry.isDirectory()) stack.push(full)
280
+ else if (entry.isFile() && entry.name.endsWith('.html')) out.push(full)
281
+ }
282
+ }
283
+ return out.sort()
284
+ }
285
+
286
+ const toPosix = (p) => p.split(path.sep).join('/')
287
+ const normPage = (p) => toPosix(path.normalize(String(p).trim())).replace(/^\.?\//, '')
288
+
289
+ function summarize (pageResults, scanned) {
290
+ const totals = Object.fromEntries(Object.keys(RULES).map((id) => [id, 0]))
291
+ for (const p of pageResults) for (const [id, f] of Object.entries(p.findings)) totals[id] += f.count
292
+ const total = Object.values(totals).reduce((a, b) => a + b, 0)
293
+ return { scanned, total, totals, pages: pageResults }
294
+ }
295
+
296
+ /**
297
+ * Scan every HTML page of one or more component paths in an Antora output
298
+ * directory. `component` is a path under the site directory, or an array of
299
+ * them (for example ['connect', 'cloud-data-platform/develop/connect']).
300
+ * `pages` (optional) limits the scan to these paths relative to the site
301
+ * directory. `changedPages` (optional) splits the result: `changed` holds the
302
+ * findings on those pages, `other` the rest, and `changed.missing` the listed
303
+ * pages that are not in the scan.
304
+ */
305
+ function checkRenderedHtml ({ siteDir, component = DEFAULTS.component, pages, changedPages, linkRoots = DEFAULTS.linkRoots, ...opts } = {}) {
306
+ if (!siteDir || !fs.existsSync(siteDir)) throw new Error(`site directory not found: ${siteDir}`)
307
+ const components = (Array.isArray(component) ? component : [component]).filter((c) => c !== undefined && c !== null)
308
+ const dirs = components.length && components.every(Boolean) ? components.map((c) => path.join(siteDir, c)) : [siteDir]
309
+ for (const dir of dirs) {
310
+ if (!fs.existsSync(dir)) throw new Error(`component directory not found: ${dir} (is --component right?)`)
311
+ }
312
+ let files = [...new Set(dirs.flatMap(listHtmlFiles))].sort()
313
+ if (pages) {
314
+ const wanted = new Set(pages.map(normPage))
315
+ files = files.filter((f) => wanted.has(toPosix(path.relative(siteDir, f))))
316
+ }
317
+ // Scanned components are always link roots: their pages are in the build.
318
+ const resolver = createLinkResolver({ siteDir, linkRoots: [...linkRoots, ...components.filter(Boolean)] })
319
+ const links = { internal: 0, external: 0, unchecked: 0 }
320
+ const results = []
321
+ for (const file of files) {
322
+ const page = toPosix(path.relative(siteDir, file))
323
+ const { findings, links: l } = checkHtml(fs.readFileSync(file, 'utf8'), { ...opts, resolver, page })
324
+ for (const k of Object.keys(links)) links[k] += l[k]
325
+ if (!Object.keys(findings).length) continue
326
+ results.push({ page, findings })
327
+ }
328
+ results.sort((a, b) => count(b) - count(a) || a.page.localeCompare(b.page))
329
+ const result = { ...summarize(results, files.length), links, linkRoots: resolver.roots }
330
+ if (changedPages) {
331
+ const listed = new Set(changedPages.map(normPage))
332
+ const scannedPages = new Set(files.map((f) => toPosix(path.relative(siteDir, f))))
333
+ const inChanged = (p) => listed.has(p.page)
334
+ result.changed = {
335
+ ...summarize(results.filter(inChanged), [...listed].filter((p) => scannedPages.has(p)).length),
336
+ listed: listed.size,
337
+ missing: [...listed].filter((p) => !scannedPages.has(p)).sort()
338
+ }
339
+ result.other = summarize(results.filter((p) => !inChanged(p)), files.length - result.changed.scanned)
340
+ }
341
+ return result
342
+ }
343
+
344
+ /**
345
+ * Read a changed-pages file: either one page path per line (relative to the
346
+ * site directory, the `sitePath` that connect-docs-diff reports), or the JSON
347
+ * output of `connect-docs-diff --format json`, whose pages[].sitePath is used.
348
+ */
349
+ function readChangedPages (file) {
350
+ const text = fs.readFileSync(file, 'utf8')
351
+ const trimmed = text.trim()
352
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
353
+ const data = JSON.parse(trimmed)
354
+ const list = Array.isArray(data) ? data : data.pages || []
355
+ return list.map((p) => (typeof p === 'string' ? p : p.sitePath)).filter(Boolean)
356
+ }
357
+ return text.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
358
+ }
359
+
360
+ function count (pageResult) {
361
+ return Object.values(pageResult.findings).reduce((a, f) => a + f.count, 0)
362
+ }
363
+
364
+ function mdCell (text) {
365
+ return String(text).replace(/\r?\n/g, ' ').replace(/\|/g, '\\|').replace(/`/g, '&#96;').replace(/</g, '&lt;')
366
+ }
367
+
368
+ function pageTable (lines, pageResults, maxPages) {
369
+ if (!pageResults.length) return
370
+ lines.push('| Page | Findings | Example |', '|---|---|---|')
371
+ for (const p of pageResults.slice(0, maxPages)) {
372
+ const counts = Object.entries(p.findings).map(([id, f]) => `${id}: ${f.count}`).join(', ')
373
+ const first = Object.values(p.findings)[0]
374
+ lines.push(`| ${mdCell(p.page)} | ${counts} | ${mdCell(first.samples[0] || '')} |`)
375
+ }
376
+ if (pageResults.length > maxPages) lines.push('', `${pageResults.length - maxPages} more pages with findings are not listed.`)
377
+ lines.push('')
378
+ }
379
+
380
+ function linksLine (lines, result) {
381
+ if (!result.links) return
382
+ const l = result.links
383
+ lines.push(`Links: ${l.internal} checked, ${l.unchecked} not checked (target outside ${result.linkRoots && result.linkRoots.length ? result.linkRoots.map((r) => `${r}/`).join(' and ') : 'the build'}), ${l.external} external.`, '')
384
+ }
385
+
386
+ function formatMarkdown (result, { strict = false, maxPages = DEFAULTS.maxPages } = {}) {
387
+ const lines = []
388
+ if (result.changed) return formatChangedMarkdown(result, { strict, maxPages })
389
+ const state = result.total === 0 ? 'clean' : strict ? 'failed' : `${result.total} findings (warning only)`
390
+ lines.push(`## Rendered HTML checks: ${state}`, '')
391
+ lines.push(`Scanned ${result.scanned} pages; ${result.pages.length} have findings.`, '')
392
+ linksLine(lines, result)
393
+ lines.push('| Check | Count |', '|---|---|')
394
+ for (const [id, label] of Object.entries(RULES)) lines.push(`| ${mdCell(label)} | ${result.totals[id]} |`)
395
+ lines.push('')
396
+ pageTable(lines, result.pages, maxPages)
397
+ return lines.join('\n')
398
+ }
399
+
400
+ function formatChangedMarkdown (result, { strict, maxPages }) {
401
+ const { changed, other } = result
402
+ const lines = []
403
+ const state = changed.total === 0 ? 'clean' : strict ? 'failed' : `${changed.total} findings on changed pages (warning only)`
404
+ lines.push(`## Rendered HTML checks: ${state}`, '')
405
+ lines.push(`Scanned ${result.scanned} pages, ${changed.scanned} of them changed. ${changed.pages.length} changed and ${other.pages.length} other pages have findings.`, '')
406
+ linksLine(lines, result)
407
+ lines.push('| Check | Changed pages | Other pages |', '|---|---|---|')
408
+ for (const [id, label] of Object.entries(RULES)) lines.push(`| ${mdCell(label)} | ${changed.totals[id]} | ${other.totals[id]} |`)
409
+ lines.push('')
410
+ if (changed.missing.length) {
411
+ lines.push(`${changed.missing.length} changed ${changed.missing.length === 1 ? 'page is' : 'pages are'} not in the scan (removed, or outside the scanned components): ${changed.missing.map((p) => `\`${p}\``).join(', ')}`, '')
412
+ }
413
+ lines.push('### Findings on changed pages', '')
414
+ if (changed.pages.length) pageTable(lines, changed.pages, maxPages)
415
+ else lines.push('None.', '')
416
+ if (other.pages.length) {
417
+ lines.push('<details>', `<summary>Findings on other pages (${other.total})</summary>`, '')
418
+ pageTable(lines, other.pages, maxPages)
419
+ lines.push('</details>', '')
420
+ }
421
+ return lines.join('\n')
422
+ }
423
+
424
+ // --component and --link-root take a value more than once, or a comma list.
425
+ function splitList (value) {
426
+ if (value === undefined || value === null) return undefined
427
+ const list = (Array.isArray(value) ? value : [value]).flatMap((v) => String(v).split(',')).map((v) => v.trim()).filter(Boolean)
428
+ return list.length ? list : undefined
429
+ }
430
+
431
+ function runCli (siteDir, options = {}) {
432
+ let result
433
+ try {
434
+ const pages = options.pages
435
+ ? fs.readFileSync(options.pages, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean)
436
+ : undefined
437
+ const changedPages = options.changedPages ? readChangedPages(options.changedPages) : undefined
438
+ result = checkRenderedHtml({
439
+ siteDir,
440
+ component: splitList(options.component) || DEFAULTS.component,
441
+ linkRoots: splitList(options.linkRoot) || DEFAULTS.linkRoots,
442
+ pages,
443
+ changedPages
444
+ })
445
+ } catch (err) {
446
+ console.error(`Error: ${err.message}`)
447
+ process.exit(2)
448
+ }
449
+ const out = options.format === 'json' ? JSON.stringify(result, null, 2) : formatMarkdown(result, { strict: !!options.strict })
450
+ console.log(out)
451
+ if (options.output) {
452
+ fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true })
453
+ fs.writeFileSync(path.resolve(options.output), out + '\n')
454
+ }
455
+ // With a changed-pages list, only findings on those pages fail --strict.
456
+ const failing = result.changed ? result.changed.total : result.total
457
+ process.exit(options.strict && failing > 0 ? 1 : 0)
458
+ }
459
+
460
+ module.exports = {
461
+ DEFAULTS,
462
+ RULES,
463
+ checkHtml,
464
+ checkRenderedHtml,
465
+ createLinkResolver,
466
+ formatMarkdown,
467
+ readChangedPages,
468
+ runCli
469
+ }
@@ -0,0 +1,77 @@
1
+ # Trimmed playbook for the Connect docs PR check
2
+ # (.github/workflows/connect-docs-check.yml).
3
+ #
4
+ # The workflow clones each source into a sibling directory and copies this
5
+ # file into the rp-connect-docs clone, so every url below is a local
6
+ # directory relative to that clone. Antora fetches nothing with credentials:
7
+ # the private repos are already on disk, and the token that cloned them is not
8
+ # in the Antora process environment.
9
+ #
10
+ # Sources: rp-connect-docs at the ref under test, docs main (the streaming
11
+ # component root and the shared component), cloud-docs main, redpanda-labs
12
+ # main, and the docs-site home, data-platform, and self-managed components.
13
+ # Without labs and the two docs-site components, docs and docs-site pages
14
+ # report xref errors to them (9 in October 2026); with them the build is
15
+ # clean. The connect generated reference docs come from
16
+ # REDPANDA_CONNECT_DOCS_DIR, which modify-connect-tag-playbook reads.
17
+ #
18
+ # Extensions that only serve the production site are left out:
19
+ # set-latest-version needs a GitHub token to resolve versions and logs an
20
+ # error without one, and the redirect, attachment, and related-labs
21
+ # extensions do not change what the connect pages render.
22
+ site:
23
+ title: Redpanda Docs
24
+ start_page: connect:home:index.adoc
25
+ url: https://docs.redpanda.com
26
+ robots: disallow
27
+ urls:
28
+ html_extension_style: indexify
29
+ content:
30
+ sources:
31
+ - url: .
32
+ branches: HEAD
33
+ - url: ../docs
34
+ branches: HEAD
35
+ - url: ../docs
36
+ branches: HEAD
37
+ start_path: shared
38
+ - url: ../cloud-docs
39
+ branches: HEAD
40
+ - url: ../redpanda-labs
41
+ branches: HEAD
42
+ start_paths: [docs, '*/docs']
43
+ - url: ../docs-site
44
+ branches: HEAD
45
+ start_paths: [home, data-platform, self-managed]
46
+ ui:
47
+ bundle:
48
+ url: https://github.com/redpanda-data/docs-ui/releases/latest/download/ui-bundle.zip
49
+ # The workflow caches the Antora cache directory under a weekly key, so
50
+ # the bundle is downloaded at most once a week rather than on every run.
51
+ snapshot: false
52
+ asciidoc:
53
+ attributes:
54
+ enterprise-validate: warn
55
+ extensions:
56
+ - '@asciidoctor/tabs'
57
+ - '@redpanda-data/docs-extensions-and-macros/macros/rp-connect-components'
58
+ - '@redpanda-data/docs-extensions-and-macros/macros/glossary'
59
+ - '@redpanda-data/docs-extensions-and-macros/macros/config-ref'
60
+ - '@redpanda-data/docs-extensions-and-macros/macros/helm-ref'
61
+ - '@redpanda-data/docs-extensions-and-macros/asciidoc-extensions/add-line-numbers-highlights'
62
+ - '@redpanda-data/docs-extensions-and-macros/macros/badge'
63
+ - '@redpanda-data/docs-extensions-and-macros/macros/enterprise'
64
+ antora:
65
+ extensions:
66
+ - require: '@redpanda-data/docs-extensions-and-macros/extensions/unified-navigation'
67
+ - require: '@redpanda-data/docs-extensions-and-macros/extensions/modify-connect-tag-playbook'
68
+ - require: '@redpanda-data/docs-extensions-and-macros/extensions/generate-rp-connect-info'
69
+ - require: '@redpanda-data/docs-extensions-and-macros/extensions/generate-rp-connect-categories'
70
+ - require: '@redpanda-data/docs-extensions-and-macros/extensions/collect-bloblang-samples'
71
+ - require: '@redpanda-data/docs-extensions-and-macros/extensions/unpublish-pages'
72
+ - require: '@redpanda-data/docs-extensions-and-macros/extensions/unlisted-pages'
73
+ - require: '@redpanda-data/docs-extensions-and-macros/extensions/add-global-attributes'
74
+ - require: '@redpanda-data/docs-extensions-and-macros/extensions/validate-attributes'
75
+ - require: '@redpanda-data/docs-extensions-and-macros/extensions/aggregate-terms'
76
+ output:
77
+ clean: true