@redpanda-data/docs-extensions-and-macros 5.52.0 → 5.54.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.
@@ -0,0 +1,382 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ /**
7
+ * doc-tools connect-docs-diff: what a connect PR changes on docs.redpanda.com.
8
+ *
9
+ * Compares two trees generated by connect's docs generator (the PR's merge
10
+ * base and its head) and maps every added, removed, or changed partial or
11
+ * example to the published page or pages that include it. Reviewing this
12
+ * output instead of the Go source catches every published change: connect
13
+ * strings, benthos strings that arrive through a go.mod bump, and generator
14
+ * changes alike.
15
+ *
16
+ * A tree root is any of: a directory that contains modules/components/
17
+ * (connect's docs/ directory), the modules/ directory itself (the root of the
18
+ * redpanda-connect-docs.tar.gz asset and of the docs artifact connect
19
+ * uploads), or the components/ directory.
20
+ */
21
+
22
+ const DEFAULTS = Object.freeze({
23
+ siteUrl: 'https://docs.redpanda.com',
24
+ // The connect component publishes at /connect/. /redpanda-connect/ is an
25
+ // older prefix that now redirects there.
26
+ componentPath: 'connect',
27
+ maxBytes: 60000,
28
+ maxFileDiffBytes: 8000,
29
+ contextLines: 3
30
+ })
31
+
32
+ // The generator writes some families with a hyphenated type directory
33
+ // (fields/rate-limits, examples/common/rate-limits) and others with an
34
+ // underscore (descriptions/rate_limits). Pages use the underscore form.
35
+ const TYPE_DIRS = new Set(['inputs', 'outputs', 'processors', 'caches', 'buffers', 'metrics', 'tracers', 'scanners', 'rate_limits'])
36
+
37
+ // fields/config/<name> and the config examples are the reference sections of
38
+ // a few hand-written pages. An explicit map, checked against the include
39
+ // directives in rp-connect-docs; anything not listed is reported as having no
40
+ // single page rather than guessed.
41
+ const CONFIG_PAGES = Object.freeze({
42
+ http: 'components/http/about/',
43
+ logger: 'components/logger/about/',
44
+ redpanda: 'components/redpanda/about/',
45
+ templates: 'configuration/templating/'
46
+ })
47
+
48
+ const NO_SINGLE_PAGE = 'no single page'
49
+ // A page row lists this many files, then a count.
50
+ const MAX_FILES_PER_ROW = 10
51
+
52
+ function resolveComponentsDir (root) {
53
+ const candidates = [
54
+ path.join(root, 'modules', 'components'),
55
+ path.join(root, 'docs', 'modules', 'components'),
56
+ path.join(root, 'components'),
57
+ root
58
+ ]
59
+ for (const dir of candidates) {
60
+ if (fs.existsSync(path.join(dir, 'partials')) || fs.existsSync(path.join(dir, 'examples'))) return dir
61
+ }
62
+ throw new Error(`${root} is not a generated docs tree: no modules/components, components, or partials/examples directory found`)
63
+ }
64
+
65
+ /** Files under partials/ and examples/, relative to the components dir. */
66
+ function listTree (componentsDir) {
67
+ const out = new Map()
68
+ for (const family of ['partials', 'examples']) {
69
+ const base = path.join(componentsDir, family)
70
+ if (!fs.existsSync(base)) continue
71
+ const stack = [base]
72
+ while (stack.length) {
73
+ const current = stack.pop()
74
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
75
+ const full = path.join(current, entry.name)
76
+ if (entry.isDirectory()) stack.push(full)
77
+ else if (entry.isFile()) out.set(path.relative(componentsDir, full).split(path.sep).join('/'), full)
78
+ }
79
+ }
80
+ }
81
+ return out
82
+ }
83
+
84
+ function normalizeType (type) {
85
+ return type.replace(/-/g, '_')
86
+ }
87
+
88
+ /**
89
+ * The published pages for one generated file, relative to the component
90
+ * root, or [] when the file feeds no single page.
91
+ * `rel` is relative to the components dir: partials/... or examples/...
92
+ */
93
+ function pagesFor (rel) {
94
+ const parts = rel.replace(/\.(adoc|ya?ml|json)$/, '').split('/')
95
+ const [family, kind, ...rest] = parts
96
+ if (family === 'partials') {
97
+ if (kind === 'bloblang-functions' && rest.length === 1) return [{ page: 'guides/bloblang/functions/', anchor: rest[0] }]
98
+ if (kind === 'bloblang-methods' && rest.length === 1) return [{ page: 'guides/bloblang/methods/', anchor: rest[0] }]
99
+ if (kind === 'bloblang' && rest.length === 1 && (rest[0] === 'functions' || rest[0] === 'methods')) {
100
+ return [{ page: `guides/bloblang/${rest[0]}/` }]
101
+ }
102
+ if (['fields', 'descriptions', 'metadata', 'examples', 'availability'].includes(kind) && rest.length === 2) {
103
+ const [type, name] = rest
104
+ if (type === 'config') return CONFIG_PAGES[name] ? [{ page: CONFIG_PAGES[name] }] : []
105
+ const t = normalizeType(type)
106
+ if (TYPE_DIRS.has(t)) return [{ page: `components/${t}/${name}/` }]
107
+ }
108
+ return []
109
+ }
110
+ if (family === 'examples' && (kind === 'common' || kind === 'advanced') && rest.length === 2) {
111
+ const [type, name] = rest
112
+ if (type === 'config') return CONFIG_PAGES[name] ? [{ page: CONFIG_PAGES[name] }] : []
113
+ const t = normalizeType(type)
114
+ if (TYPE_DIRS.has(t)) return [{ page: `components/${t}/${name}/` }]
115
+ }
116
+ return []
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Unified diff (Myers O(ND)), small and dependency-free.
121
+
122
+ function diffLines (a, b) {
123
+ const n = a.length
124
+ const m = b.length
125
+ const max = n + m
126
+ const offset = max
127
+ const v = new Array(2 * max + 2).fill(0)
128
+ const trace = []
129
+ outer:
130
+ for (let d = 0; d <= max; d++) {
131
+ trace.push(v.slice())
132
+ for (let k = -d; k <= d; k += 2) {
133
+ let x
134
+ if (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])) x = v[offset + k + 1]
135
+ else x = v[offset + k - 1] + 1
136
+ let y = x - k
137
+ while (x < n && y < m && a[x] === b[y]) { x++; y++ }
138
+ v[offset + k] = x
139
+ if (x >= n && y >= m) break outer
140
+ }
141
+ }
142
+ // Backtrack into an edit script of { op: ' ' | '-' | '+', line }.
143
+ const ops = []
144
+ let x = n
145
+ let y = m
146
+ for (let d = trace.length - 1; d >= 0 && (x > 0 || y > 0); d--) {
147
+ const vd = trace[d]
148
+ const k = x - y
149
+ let prevK
150
+ if (k === -d || (k !== d && vd[offset + k - 1] < vd[offset + k + 1])) prevK = k + 1
151
+ else prevK = k - 1
152
+ const prevX = vd[offset + prevK]
153
+ const prevY = prevX - prevK
154
+ while (x > prevX && y > prevY) { ops.push({ op: ' ', line: a[x - 1] }); x--; y-- }
155
+ if (d > 0) {
156
+ if (x === prevX) { ops.push({ op: '+', line: b[y - 1] }); y-- } else { ops.push({ op: '-', line: a[x - 1] }); x-- }
157
+ }
158
+ }
159
+ return ops.reverse()
160
+ }
161
+
162
+ function splitLines (text) {
163
+ if (text === '') return []
164
+ const lines = text.split('\n')
165
+ if (lines[lines.length - 1] === '') lines.pop()
166
+ return lines
167
+ }
168
+
169
+ function unifiedDiff (oldText, newText, { oldName = 'a', newName = 'b', context = DEFAULTS.contextLines } = {}) {
170
+ const a = splitLines(oldText)
171
+ const b = splitLines(newText)
172
+ const ops = diffLines(a, b)
173
+ if (!ops.some((o) => o.op !== ' ')) return ''
174
+ // Group changes into hunks with `context` lines around them.
175
+ const hunks = []
176
+ let i = 0
177
+ let aLine = 0
178
+ let bLine = 0
179
+ const positions = ops.map((o) => {
180
+ const pos = { a: aLine, b: bLine }
181
+ if (o.op !== '+') aLine++
182
+ if (o.op !== '-') bLine++
183
+ return pos
184
+ })
185
+ while (i < ops.length) {
186
+ if (ops[i].op === ' ') { i++; continue }
187
+ let start = Math.max(0, i - context)
188
+ let end = i
189
+ // Extend while the next change is within 2*context of the current one.
190
+ while (end < ops.length) {
191
+ if (ops[end].op !== ' ') { end++; continue }
192
+ let j = end
193
+ while (j < ops.length && ops[j].op === ' ') j++
194
+ if (j < ops.length && j - end <= 2 * context) { end = j; continue }
195
+ end = Math.min(ops.length, end + context)
196
+ break
197
+ }
198
+ hunks.push({ start, end })
199
+ i = end
200
+ }
201
+ const out = [`--- ${oldName}`, `+++ ${newName}`]
202
+ for (const h of hunks) {
203
+ const slice = ops.slice(h.start, h.end)
204
+ const aStart = positions[h.start].a
205
+ const bStart = positions[h.start].b
206
+ const aCount = slice.filter((o) => o.op !== '+').length
207
+ const bCount = slice.filter((o) => o.op !== '-').length
208
+ out.push(`@@ -${aCount ? aStart + 1 : aStart},${aCount} +${bCount ? bStart + 1 : bStart},${bCount} @@`)
209
+ for (const o of slice) out.push(`${o.op}${o.line}`)
210
+ }
211
+ return out.join('\n') + '\n'
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+
216
+ function diffGenerated ({ baseDir, headDir, siteUrl = DEFAULTS.siteUrl, componentPath = DEFAULTS.componentPath } = {}) {
217
+ const base = listTree(resolveComponentsDir(baseDir))
218
+ const head = listTree(resolveComponentsDir(headDir))
219
+ const all = [...new Set([...base.keys(), ...head.keys()])].sort()
220
+ const root = `${siteUrl.replace(/\/$/, '')}/${componentPath}/`
221
+ const files = []
222
+ for (const rel of all) {
223
+ const oldFile = base.get(rel)
224
+ const newFile = head.get(rel)
225
+ const oldText = oldFile ? fs.readFileSync(oldFile, 'utf8') : ''
226
+ const newText = newFile ? fs.readFileSync(newFile, 'utf8') : ''
227
+ let status
228
+ if (!oldFile) status = 'added'
229
+ else if (!newFile) status = 'removed'
230
+ else if (oldText !== newText) status = 'changed'
231
+ else continue
232
+ const pages = pagesFor(rel).map((p) => ({
233
+ url: `${root}${p.page}${p.anchor ? `#${p.anchor}` : ''}`,
234
+ page: `${root}${p.page}`,
235
+ // Where Antora writes the page, relative to the output directory.
236
+ sitePath: `${componentPath}/${p.page}index.html`
237
+ }))
238
+ files.push({ path: `modules/components/${rel}`, status, pages, oldText, newText })
239
+ }
240
+ // One row per page; files that feed no single page are listed on their own.
241
+ const byPage = new Map()
242
+ const unmapped = []
243
+ for (const f of files) {
244
+ if (!f.pages.length) { unmapped.push(f.path); continue }
245
+ for (const p of f.pages) {
246
+ const entry = byPage.get(p.page) || { url: p.page, sitePath: p.sitePath, anchors: [], files: [] }
247
+ if (p.url !== p.page && !entry.anchors.includes(p.url)) entry.anchors.push(p.url)
248
+ entry.files.push({ path: f.path, status: f.status })
249
+ byPage.set(p.page, entry)
250
+ }
251
+ }
252
+ const pages = [...byPage.values()].sort((a, b) => a.url.localeCompare(b.url))
253
+ return {
254
+ summary: {
255
+ files: files.length,
256
+ added: files.filter((f) => f.status === 'added').length,
257
+ removed: files.filter((f) => f.status === 'removed').length,
258
+ changed: files.filter((f) => f.status === 'changed').length,
259
+ pages: pages.length,
260
+ unmapped: unmapped.length
261
+ },
262
+ pages,
263
+ unmapped,
264
+ files
265
+ }
266
+ }
267
+
268
+ function fence (text) {
269
+ // A fence longer than any backtick run in the text, so a diff of AsciiDoc
270
+ // with its own fences cannot close the block early.
271
+ const longest = Math.max(2, ...(text.match(/`+/g) || []).map((s) => s.length))
272
+ return '`'.repeat(longest + 1)
273
+ }
274
+
275
+ function formatMarkdown (result, { maxBytes = DEFAULTS.maxBytes, maxFileDiffBytes = DEFAULTS.maxFileDiffBytes } = {}) {
276
+ const s = result.summary
277
+ const lines = []
278
+ lines.push('## Connect reference docs: published changes', '')
279
+ if (!s.files) {
280
+ lines.push('This PR does not change the generated reference docs.', '')
281
+ return lines.join('\n')
282
+ }
283
+ lines.push(`${s.files} generated ${s.files === 1 ? 'file' : 'files'} differ from the merge base (${s.changed} changed, ${s.added} added, ${s.removed} removed), on ${s.pages} published ${s.pages === 1 ? 'page' : 'pages'}.`, '')
284
+ if (result.pages.length) {
285
+ lines.push('| Page | Changed files |', '|---|---|')
286
+ for (const p of result.pages) {
287
+ const label = p.url.replace(/^https?:\/\/[^/]+/, '')
288
+ const shownFiles = p.files.slice(0, MAX_FILES_PER_ROW).map((f) => {
289
+ const name = f.path.replace(/^modules\/components\//, '')
290
+ // Per-entry partials (one Bloblang function or method) link to their
291
+ // section of the page.
292
+ const anchor = p.anchors.find((a) => a.endsWith(`#${path.basename(name).replace(/\.[^.]+$/, '')}`))
293
+ return `${anchor && f.status !== 'removed' ? `[${name}](${anchor})` : name} (${f.status})`
294
+ })
295
+ if (p.files.length > MAX_FILES_PER_ROW) shownFiles.push(`and ${p.files.length - MAX_FILES_PER_ROW} more`)
296
+ lines.push(`| [${label}](${p.url}) | ${shownFiles.join('<br>')} |`)
297
+ }
298
+ lines.push('')
299
+ }
300
+ if (result.unmapped.length) {
301
+ lines.push(`Files with ${NO_SINGLE_PAGE} (they feed several pages, or none directly):`, '')
302
+ for (const f of result.unmapped) lines.push(`- ${f}`)
303
+ lines.push('')
304
+ }
305
+ lines.push('### Diffs', '')
306
+ let used = Buffer.byteLength(lines.join('\n'))
307
+ let shown = 0
308
+ for (const f of result.files) {
309
+ let diff = unifiedDiff(f.oldText, f.newText, {
310
+ oldName: f.status === 'added' ? '/dev/null' : `a/${f.path}`,
311
+ newName: f.status === 'removed' ? '/dev/null' : `b/${f.path}`
312
+ })
313
+ let note = ''
314
+ if (Buffer.byteLength(diff) > maxFileDiffBytes) {
315
+ diff = diff.slice(0, maxFileDiffBytes)
316
+ diff = diff.slice(0, diff.lastIndexOf('\n') + 1)
317
+ note = `\n_Diff truncated at ${maxFileDiffBytes} bytes._\n`
318
+ }
319
+ const f2 = fence(diff)
320
+ const block = [
321
+ '<details>',
322
+ `<summary>${f.path} (${f.status})</summary>`,
323
+ '',
324
+ `${f2}diff`,
325
+ diff.replace(/\n$/, ''),
326
+ f2,
327
+ note,
328
+ '</details>',
329
+ ''
330
+ ].join('\n')
331
+ if (used + Buffer.byteLength(block) > maxBytes) break
332
+ lines.push(block)
333
+ used += Buffer.byteLength(block) + 1
334
+ shown++
335
+ }
336
+ if (shown < result.files.length) {
337
+ lines.push(`_${result.files.length - shown} more ${result.files.length - shown === 1 ? 'diff is' : 'diffs are'} not shown: the summary is capped at ${maxBytes} bytes._`, '')
338
+ }
339
+ return lines.join('\n')
340
+ }
341
+
342
+ function toJson (result) {
343
+ return {
344
+ summary: result.summary,
345
+ pages: result.pages,
346
+ unmapped: result.unmapped,
347
+ files: result.files.map((f) => ({ path: f.path, status: f.status, pages: f.pages.map((p) => p.url) }))
348
+ }
349
+ }
350
+
351
+ function runCli (baseDir, headDir, options = {}) {
352
+ let result
353
+ try {
354
+ for (const [label, dir] of [['base', baseDir], ['head', headDir]]) {
355
+ if (!dir || !fs.existsSync(dir)) throw new Error(`${label} directory not found: ${dir}`)
356
+ }
357
+ result = diffGenerated({ baseDir, headDir, siteUrl: options.siteUrl })
358
+ } catch (err) {
359
+ console.error(`Error: ${err.message}`)
360
+ process.exit(2)
361
+ }
362
+ const maxBytes = options.maxBytes != null ? Number(options.maxBytes) : DEFAULTS.maxBytes
363
+ const out = options.format === 'json' ? JSON.stringify(toJson(result), null, 2) : formatMarkdown(result, { maxBytes })
364
+ console.log(out)
365
+ if (options.output) {
366
+ fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true })
367
+ fs.writeFileSync(path.resolve(options.output), out + '\n')
368
+ }
369
+ process.exit(0)
370
+ }
371
+
372
+ module.exports = {
373
+ DEFAULTS,
374
+ CONFIG_PAGES,
375
+ resolveComponentsDir,
376
+ pagesFor,
377
+ unifiedDiff,
378
+ diffGenerated,
379
+ formatMarkdown,
380
+ toJson,
381
+ runCli
382
+ }
@@ -0,0 +1,104 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+
5
+ /**
6
+ * The connect-docs-check PR comment: one sticky comment per connect PR that
7
+ * tells the author and the docs team what the PR changes on the published
8
+ * Connect docs, whether the docs build passed, and where to review the
9
+ * rendered pages.
10
+ *
11
+ * Usage: node pr-comment.js --build <pass|fail> [--diff diff.json] [--html-findings <n>]
12
+ * [--preview <deploy url>] [--run-url <url>] [--sha <sha>]
13
+ * [--output comment.md]
14
+ *
15
+ * Prints JSON { body, notify }. `notify` is true when the PR changes a
16
+ * published page, breaks the build, or has rendered-HTML findings on a page
17
+ * it changes: the cases where the docs team should
18
+ * look. The workflow posts the comment only when notify is true or a
19
+ * comment already exists, so PRs that touch no docs get no comment.
20
+ */
21
+
22
+ const MARKER = '<!-- connect-docs-check -->'
23
+ const MAX_ROWS = 30
24
+ const PROD = 'https://docs.redpanda.com'
25
+
26
+ function pagePath (sitePath) {
27
+ return '/' + String(sitePath).replace(/index\.html$/, '')
28
+ }
29
+
30
+ function buildComment ({ build, diff, preview, runUrl, sha, htmlFindings = 0 }) {
31
+ const pages = (diff && diff.pages) || []
32
+ const summary = (diff && diff.summary) || null
33
+ const failed = build !== 'pass'
34
+ const lines = [MARKER, '### Connect docs check', '']
35
+
36
+ lines.push(failed
37
+ ? '**The docs build failed.** This PR breaks the Connect docs build: see the job summary for the errors.'
38
+ : 'The docs build passed with this PR\'s generated docs.')
39
+ lines.push('')
40
+ if (htmlFindings > 0) {
41
+ lines.push(`**${htmlFindings} rendered-HTML ${htmlFindings === 1 ? 'finding' : 'findings'} on pages this PR changes**, such as broken anchors or unconverted AsciiDoc. They are listed in the job summary.`, '')
42
+ }
43
+
44
+ if (preview) {
45
+ lines.push(`Preview: ${preview.replace(/\/$/, '')}/connect/home/`, '')
46
+ }
47
+
48
+ if (!diff) {
49
+ lines.push('The merge base was not generated, so the changed pages are not listed.', '')
50
+ } else if (!pages.length && !(summary && summary.files)) {
51
+ lines.push('This PR doesn\'t change any published Connect docs page.', '')
52
+ } else {
53
+ const s = summary || {}
54
+ lines.push(`This PR changes ${pages.length} published ${pages.length === 1 ? 'page' : 'pages'} (${s.files || 0} generated ${s.files === 1 ? 'file' : 'files'}: ${s.changed || 0} changed, ${s.added || 0} added, ${s.removed || 0} removed). Review the rendered pages, not only the Go strings.`, '')
55
+ if (pages.length) {
56
+ lines.push(preview ? '| Page | Preview |' : '| Page |', preview ? '|---|---|' : '|---|')
57
+ for (const p of pages.slice(0, MAX_ROWS)) {
58
+ const pth = pagePath(p.sitePath)
59
+ const live = `[${pth}](${PROD}${pth})`
60
+ lines.push(preview ? `| ${live} | [preview](${preview.replace(/\/$/, '')}${pth}) |` : `| ${live} |`)
61
+ }
62
+ if (pages.length > MAX_ROWS) lines.push('', `And ${pages.length - MAX_ROWS} more pages.`)
63
+ lines.push('')
64
+ }
65
+ }
66
+
67
+ const tail = []
68
+ if (runUrl) tail.push(`Per-file diffs and the rendered-HTML findings are in the [job summary](${runUrl}).`)
69
+ if (sha) tail.push(`Checked at ${String(sha).slice(0, 9)}.`)
70
+ if (tail.length) lines.push(tail.join(' '))
71
+
72
+ return { body: lines.join('\n').trimEnd() + '\n', notify: failed || htmlFindings > 0 || pages.length > 0 }
73
+ }
74
+
75
+ function parseArgs (argv) {
76
+ const opts = {}
77
+ for (let i = 0; i < argv.length; i++) {
78
+ const m = /^--(build|diff|html-findings|preview|run-url|sha|output)$/.exec(argv[i])
79
+ if (!m) throw new Error(`unknown argument: ${argv[i]}`)
80
+ opts[m[1]] = argv[++i]
81
+ }
82
+ if (opts.build !== 'pass' && opts.build !== 'fail') throw new Error('--build must be pass or fail')
83
+ return opts
84
+ }
85
+
86
+ function runCli (argv) {
87
+ const opts = parseArgs(argv)
88
+ const diff = opts.diff && fs.existsSync(opts.diff) ? JSON.parse(fs.readFileSync(opts.diff, 'utf8')) : null
89
+ const result = buildComment({ build: opts.build, diff, htmlFindings: Number(opts['html-findings'] || 0), preview: opts.preview || '', runUrl: opts['run-url'], sha: opts.sha })
90
+ if (opts.output) fs.writeFileSync(opts.output, result.body)
91
+ process.stdout.write(JSON.stringify({ notify: result.notify }) + '\n')
92
+ return result
93
+ }
94
+
95
+ if (require.main === module) {
96
+ try {
97
+ runCli(process.argv.slice(2))
98
+ } catch (err) {
99
+ console.error(`Error: ${err.message}`)
100
+ process.exit(2)
101
+ }
102
+ }
103
+
104
+ module.exports = { MARKER, MAX_ROWS, buildComment, runCli }