@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.
- package/bin/doc-tools-mcp.js +4 -5
- package/bin/doc-tools.js +166 -2
- package/bin/mcp-tools/generated-docs-review.js +26 -9
- package/bin/mcp-tools/overrides-audit.js +1 -1
- package/bin/mcp-tools/rpcn-docs.js +4 -14
- package/cli-utils/format-helm-spec.js +4 -3
- package/cli-utils/helm-commented-values.js +88 -18
- package/cli-utils/self-managed-docs-branch.js +36 -30
- package/extension-utils/html-utils.js +27 -0
- package/extensions/REFERENCE.adoc +64 -12
- package/extensions/add-git-dates.js +13 -1
- package/extensions/aggregate-terms.js +34 -20
- package/extensions/generate-rp-connect-info.js +79 -24
- package/extensions/modify-connect-tag-playbook.js +498 -75
- package/extensions/resolve-xrefs-in-attachments.js +1 -12
- package/extensions/url-to-xref.js +3 -0
- package/extensions/util/connect-catalog.js +10 -5
- package/extensions/util/connect-docs-asset.js +188 -0
- package/extensions/util/connect-github.js +16 -0
- package/extensions/version-fetcher/fetch-latest-docker-tag.js +35 -13
- package/extensions/version-fetcher/get-latest-redpanda-helm-version-from-operator.js +64 -40
- package/extensions/version-fetcher/list-connect-releases.js +10 -0
- package/extensions/version-fetcher/release-lines.js +64 -0
- package/extensions/version-fetcher/set-latest-version.js +73 -3
- package/macros/README.adoc +1 -0
- package/macros/REFERENCE.adoc +99 -0
- package/macros/badge.js +5 -8
- package/macros/enterprise.js +2 -5
- package/macros/glossary.js +4 -14
- package/macros/iceberg-explorer.js +242 -0
- package/macros/prop.js +3 -13
- package/mcp/USER_GUIDE.adoc +25 -135
- package/package.json +4 -2
- package/tools/connect-docs/check-build-log.js +311 -0
- package/tools/connect-docs/check-rendered-html.js +469 -0
- package/tools/connect-docs/ci-playbook.yml +77 -0
- package/tools/connect-docs/diff-generated.js +382 -0
- package/tools/connect-docs/pr-comment.js +104 -0
- package/tools/doc-impact-recording/index.js +190 -0
- package/tools/overrides-audit/README.adoc +1 -2
- package/tools/overrides-audit/adapters/properties.js +1 -1
- package/tools/overrides-audit/index.js +3 -4
- package/tools/redpanda-connect/AUTOMATION.md +3 -36
- package/tools/redpanda-connect/README.adoc +5 -33
- package/tools/redpanda-connect/generate-rpcn-connector-docs.js +3 -3
- package/tools/redpanda-connect/pr-summary-formatter.js +6 -4
- package/tools/redpanda-connect/report-delta.js +6 -3
- package/tools/redpanda-connect/rpcn-connector-docs-handler.js +38 -16
- package/tools/redpanda-release-notes/generate-release-notes.js +116 -1
- package/tools/rpk-docs/templates/command.hbs +1 -1
- 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, '`').replace(/</g, '<')
|
|
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
|