@redpanda-data/docs-extensions-and-macros 5.44.1 → 5.46.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.js CHANGED
@@ -2748,6 +2748,55 @@ programCli
2748
2748
  runCli(options)
2749
2749
  })
2750
2750
 
2751
+ /**
2752
+ * lint-screenshots
2753
+ *
2754
+ * @description
2755
+ * Deterministic checks from the docs screenshot standard, run against the
2756
+ * .adoc pages under modules/ in a docs repo: every image macro (block and
2757
+ * inline) has alt text, the alt text is at most 125 characters, does not
2758
+ * start with "screenshot of" / "image of" / "picture of", and has no
2759
+ * unquoted comma (Asciidoctor would split it into width/height); the
2760
+ * referenced image file exists and is at most 100KB. Those are errors and
2761
+ * exit 1. Warnings (exit 0 unless --warnings-as-errors) cover the rest of
2762
+ * the standard: images above the 50KB target, JPEG/WebP where PNG or SVG
2763
+ * belongs, captures wider than 1920px, and - in --files mode - changed
2764
+ * images nothing references. Commented-out macros are skipped.
2765
+ *
2766
+ * @why
2767
+ * cloud-docs enforced these rules on every PR and its daily screenshot cron
2768
+ * with a repo-local script; the other docs repos had nothing. One command
2769
+ * in doc-tools lets every docs repo and the shared PR review pipeline run
2770
+ * the same checks, so no two jobs disagree about the same 100KB limit.
2771
+ *
2772
+ * @example
2773
+ * # Whole repo (from the docs repo root): what the cloud-docs PR check runs
2774
+ * npx doc-tools lint-screenshots
2775
+ *
2776
+ * # PR mode: only pages changed in the PR, plus pages that use a changed image
2777
+ * gh pr diff 123 --name-only > changed.txt
2778
+ * npx doc-tools lint-screenshots --files changed.txt --format json --output review-output/screenshot-lint.json
2779
+ *
2780
+ * # Adopt the stricter items as blocking
2781
+ * npx doc-tools lint-screenshots --warnings-as-errors
2782
+ */
2783
+ programCli
2784
+ .command('lint-screenshots')
2785
+ .description('Lint image macros and image files in a docs repo against the screenshot standard (alt text, size, format, width)')
2786
+ .option('--root <path>', 'Docs repo root, the directory that holds modules/ (default: current directory)')
2787
+ .option('--files <path>', 'Changed-files list (one repo-relative path per line, for example from `gh pr diff --name-only`): lint only listed .adoc files and pages that reference a listed image')
2788
+ .option('--format <format>', 'Output format: human or json', 'human')
2789
+ .option('--output <path>', 'Also write the JSON result to this file')
2790
+ .option('--max-alt-length <n>', 'Alt text character limit', String(require('../tools/lint-screenshots').DEFAULTS.maxAltLength))
2791
+ .option('--max-bytes <n>', 'Image size ceiling in bytes (error above this)', String(require('../tools/lint-screenshots').DEFAULTS.maxBytes))
2792
+ .option('--target-bytes <n>', 'Image size target in bytes (warning above this)', String(require('../tools/lint-screenshots').DEFAULTS.targetBytes))
2793
+ .option('--max-width <n>', 'Intrinsic image width limit in pixels (warning above this)', String(require('../tools/lint-screenshots').DEFAULTS.maxWidth))
2794
+ .option('--warnings-as-errors', 'Exit 1 on warnings too')
2795
+ .action((options) => {
2796
+ const { runCli } = require('../tools/lint-screenshots')
2797
+ runCli(options)
2798
+ })
2799
+
2751
2800
  /**
2752
2801
  * preview-string
2753
2802
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.44.1",
3
+ "version": "5.46.0",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -0,0 +1,383 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ /**
7
+ * doc-tools lint-screenshots: deterministic checks from the docs screenshot
8
+ * standard that need no browser and no model - alt text and image macro
9
+ * correctness, plus the image file facts (existence, size, format, capture
10
+ * width) that a PR reviewer would otherwise measure by hand.
11
+ *
12
+ * Promoted from cloud-docs' tests/doc-detective/lint-screenshots.js so every
13
+ * docs repo runs ONE implementation. The error-severity rules are exactly the
14
+ * rules that script enforced (its findings were already blocking cloud-docs
15
+ * PRs); the stricter items from the screenshot standard that no repo enforced
16
+ * before are warnings, so adopting the command never turns a previously green
17
+ * repo red on day one.
18
+ *
19
+ * This does NOT judge the "purpose test" (whether an image should exist at
20
+ * all), annotation style, PII overlays, or alt-text accuracy. Those need a
21
+ * human or a vision pass.
22
+ */
23
+
24
+ const DEFAULTS = Object.freeze({
25
+ maxAltLength: 125,
26
+ maxBytes: 100 * 1024,
27
+ targetBytes: 50 * 1024,
28
+ maxWidth: 1920, // 2x capture of a <=960px display width
29
+ bannedAltPrefixes: ['screenshot of', 'image of', 'picture of']
30
+ })
31
+
32
+ const IMAGE_EXT_RE = /\.(png|svg|jpe?g|gif|webp|avif)$/i
33
+
34
+ // Block macro: image::target[attrs]. Same shape the cloud-docs linter
35
+ // matched, so its existing findings carry over unchanged.
36
+ const BLOCK_MACRO_RE = /image::([^\[\]]+)\[([^\]]*)\]/g
37
+ // Inline macro: image:target[attrs] - single colon, not preceded by a word
38
+ // character (that would be another macro name) or a colon (the block form).
39
+ const INLINE_MACRO_RE = /(?<![\w:])image:(?!:)([^\[\]\s]+)\[([^\]]*)\]/g
40
+
41
+ // A comma after unquoted alt text is only legitimate when every segment that
42
+ // follows is itself a recognized image attribute (width=100, role=foo, or a
43
+ // bare numeric width/height). Any other trailing segment means the comma split
44
+ // real alt-text content, which Asciidoctor then reads as width/height.
45
+ const NAMED_ATTR_RE = /^[a-zA-Z_:][a-zA-Z0-9_-]*\s*=/
46
+ const BARE_DIMENSION_RE = /^\d+$/
47
+
48
+ function parseAltText (attrs) {
49
+ const trimmed = attrs.trim()
50
+ if (trimmed.startsWith('"')) {
51
+ const closingIdx = trimmed.indexOf('"', 1)
52
+ if (closingIdx !== -1) {
53
+ return { altText: trimmed.slice(1, closingIdx), hasUnquotedComma: false }
54
+ }
55
+ }
56
+ const segments = trimmed.split(',').map((s) => s.trim())
57
+ const [altText, ...rest] = segments
58
+ const allRestAreAttrs = rest.every((s) => NAMED_ATTR_RE.test(s) || BARE_DIMENSION_RE.test(s))
59
+ return { altText, hasUnquotedComma: rest.length > 0 && !allRestAreAttrs }
60
+ }
61
+
62
+ function findAdocFiles (dir, out = []) {
63
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
64
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
65
+ const full = path.join(dir, entry.name)
66
+ if (entry.isDirectory()) findAdocFiles(full, out)
67
+ else if (entry.name.endsWith('.adoc')) out.push(full)
68
+ }
69
+ return out
70
+ }
71
+
72
+ /** Antora module a page belongs to, from its path under modules/. */
73
+ function moduleOf (modulesDir, filePath) {
74
+ const rel = path.relative(modulesDir, filePath)
75
+ if (rel.startsWith('..')) return null
76
+ return rel.split(path.sep)[0]
77
+ }
78
+
79
+ /**
80
+ * Resolve an image macro target to a file under modules/, the way Antora
81
+ * does for the two forms that resolve inside one component:
82
+ * "shared:file.png" -> modules/shared/images/file.png
83
+ * "file.png" -> modules/<page's module>/images/file.png
84
+ * Returns null when the target cannot be checked locally: remote URLs,
85
+ * attribute references ({imagesdir}/x.png), and component-qualified IDs
86
+ * (other-component:module:file.png).
87
+ */
88
+ function resolveImage (target, adocFile, modulesDir) {
89
+ const ref = target.trim()
90
+ if (/^https?:\/\//.test(ref) || ref.includes('{')) return null
91
+ const parts = ref.split(':')
92
+ if (parts.length > 2) return null
93
+ if (parts.length === 2 && /^[a-zA-Z0-9_.-]+$/.test(parts[0])) {
94
+ return path.join(modulesDir, parts[0], 'images', parts[1])
95
+ }
96
+ const mod = moduleOf(modulesDir, adocFile)
97
+ if (!mod) return null
98
+ return path.join(modulesDir, mod, 'images', ref)
99
+ }
100
+
101
+ /** Intrinsic pixel width from the file header; null when unknown. */
102
+ function imageWidth (filePath) {
103
+ let fd
104
+ try {
105
+ fd = fs.openSync(filePath, 'r')
106
+ const buf = Buffer.alloc(32)
107
+ const read = fs.readSync(fd, buf, 0, 32, 0)
108
+ if (read >= 24 && buf.readUInt32BE(0) === 0x89504e47 && buf.toString('ascii', 12, 16) === 'IHDR') {
109
+ return buf.readUInt32BE(16)
110
+ }
111
+ if (read >= 10 && buf.toString('ascii', 0, 4) === 'GIF8') {
112
+ return buf.readUInt16LE(6)
113
+ }
114
+ return null
115
+ } catch (err) {
116
+ return null
117
+ } finally {
118
+ if (fd !== undefined) fs.closeSync(fd)
119
+ }
120
+ }
121
+
122
+ function kb (bytes) {
123
+ return `${Math.round(bytes / 1024)}KB`
124
+ }
125
+
126
+ /**
127
+ * Collect every image macro reference from one page. Comment lines and
128
+ * comment blocks are skipped: a commented-out macro is not published.
129
+ */
130
+ function collectReferences (adocFile, modulesDir, root) {
131
+ const content = fs.readFileSync(adocFile, 'utf8')
132
+ const relPath = path.relative(root, adocFile)
133
+ const refs = []
134
+ let inCommentBlock = false
135
+ const lines = content.split('\n')
136
+ for (let i = 0; i < lines.length; i++) {
137
+ const line = lines[i]
138
+ if (line.trim() === '////') { inCommentBlock = !inCommentBlock; continue }
139
+ if (inCommentBlock || line.trimStart().startsWith('//')) continue
140
+ for (const [re, form] of [[BLOCK_MACRO_RE, 'block'], [INLINE_MACRO_RE, 'inline']]) {
141
+ re.lastIndex = 0
142
+ let m
143
+ while ((m = re.exec(line)) !== null) {
144
+ const [, target, attrs] = m
145
+ refs.push({
146
+ adocFile,
147
+ relPath,
148
+ line: i + 1,
149
+ form,
150
+ target: target.trim(),
151
+ attrs,
152
+ resolved: resolveImage(target, adocFile, modulesDir)
153
+ })
154
+ }
155
+ }
156
+ }
157
+ return refs
158
+ }
159
+
160
+ /**
161
+ * Run the linter.
162
+ *
163
+ * @param {Object} options
164
+ * @param {string} options.root - Docs repo root (the directory holding modules/)
165
+ * @param {string[]} [options.files] - Changed-files scope (repo-relative paths, for
166
+ * example from `gh pr diff --name-only`). When set, only .adoc files in the
167
+ * list and .adoc files that reference an image in the list are linted, and
168
+ * listed images nothing references are reported. When omitted, every .adoc
169
+ * under modules/ is linted.
170
+ * @param {number} [options.maxAltLength]
171
+ * @param {number} [options.maxBytes]
172
+ * @param {number} [options.targetBytes]
173
+ * @param {number} [options.maxWidth]
174
+ * @param {string[]} [options.bannedAltPrefixes]
175
+ * @returns {Object} { findings, summary, scope }
176
+ */
177
+ function lintScreenshots (options) {
178
+ // Explicit undefined wins a spread, and intOption() returns undefined for
179
+ // an option nobody passed, so a plain `{ ...DEFAULTS, ...options }` lets a
180
+ // programmatic caller silently erase a threshold: every `size > undefined`
181
+ // is then false and the size rules vanish without a word. Drop undefined
182
+ // keys so an omitted threshold means "the default", not "no limit".
183
+ const provided = Object.fromEntries(
184
+ Object.entries(options || {}).filter(([, value]) => value !== undefined))
185
+ const opts = { ...DEFAULTS, ...provided }
186
+ if (!opts.root) throw new Error('lint-screenshots requires a root directory')
187
+ const root = path.resolve(opts.root)
188
+ const modulesDir = path.join(root, 'modules')
189
+ if (!fs.existsSync(modulesDir)) {
190
+ throw new Error(`No modules/ directory under ${root} - point --root at an Antora docs repo`)
191
+ }
192
+
193
+ const scoped = Array.isArray(opts.files)
194
+ const changedAdoc = new Set()
195
+ const changedImages = new Set()
196
+ // Deleted images stay in scope. Skipping a path that no longer exists would
197
+ // drop the pages that still reference it out of the scoped pass, so a PR
198
+ // that deletes a screenshot still referenced by a page would report nothing
199
+ // in --files mode while a whole-repo run reports image-missing. They are
200
+ // tracked separately so the orphaned-image rule can skip them: a file that
201
+ // is gone cannot be orphaned.
202
+ const deletedImages = new Set()
203
+ if (scoped) {
204
+ for (const f of opts.files) {
205
+ const abs = path.resolve(root, f)
206
+ const exists = fs.existsSync(abs)
207
+ if (f.endsWith('.adoc')) {
208
+ if (exists) changedAdoc.add(abs)
209
+ } else if (IMAGE_EXT_RE.test(f)) {
210
+ changedImages.add(abs)
211
+ if (!exists) deletedImages.add(abs)
212
+ }
213
+ }
214
+ }
215
+
216
+ const adocFiles = findAdocFiles(modulesDir)
217
+ const allRefs = adocFiles.flatMap((f) => collectReferences(f, modulesDir, root))
218
+ const refs = scoped
219
+ ? allRefs.filter((r) => changedAdoc.has(r.adocFile) || (r.resolved && changedImages.has(r.resolved)))
220
+ : allRefs
221
+
222
+ const findings = []
223
+ const add = (rule, severity, ref, message, file) => {
224
+ findings.push({
225
+ rule,
226
+ severity,
227
+ file: file || ref.relPath,
228
+ line: ref ? ref.line : null,
229
+ image: ref ? ref.target : null,
230
+ message
231
+ })
232
+ }
233
+
234
+ const seenImages = new Set()
235
+ for (const ref of refs) {
236
+ const { altText, hasUnquotedComma } = parseAltText(ref.attrs)
237
+
238
+ if (!altText) {
239
+ add('alt-missing', 'error', ref, 'Missing alt text')
240
+ } else {
241
+ if (hasUnquotedComma) {
242
+ add('alt-unquoted-comma', 'error', ref,
243
+ `Unquoted alt text contains a comma, which Asciidoctor splits into separate width/height attributes at render time. Wrap the alt text in double quotes or remove the comma: "${altText}"`)
244
+ }
245
+ if (altText.length > opts.maxAltLength) {
246
+ add('alt-too-long', 'error', ref, `Alt text is ${altText.length} chars (max ${opts.maxAltLength}): "${altText}"`)
247
+ }
248
+ const lowerAlt = altText.toLowerCase()
249
+ if (opts.bannedAltPrefixes.some((p) => lowerAlt.startsWith(p))) {
250
+ add('alt-banned-prefix', 'error', ref, `Alt text starts with a banned prefix: "${altText}"`)
251
+ }
252
+ }
253
+
254
+ if (!ref.resolved) continue
255
+ if (!fs.existsSync(ref.resolved)) {
256
+ add('image-missing', 'error', ref, `Referenced image not found: ${path.relative(root, ref.resolved)}`)
257
+ continue
258
+ }
259
+ // Image-file facts are reported once per image, on its first reference,
260
+ // so a shared image on five pages is one finding, not five.
261
+ if (seenImages.has(ref.resolved)) continue
262
+ seenImages.add(ref.resolved)
263
+
264
+ const size = fs.statSync(ref.resolved).size
265
+ if (size > opts.maxBytes) {
266
+ add('image-too-large', 'error', ref, `Image is ${kb(size)}, over the ${kb(opts.maxBytes)} ceiling`)
267
+ } else if (size > opts.targetBytes) {
268
+ add('image-above-target', 'warning', ref, `Image is ${kb(size)}, above the ${kb(opts.targetBytes)} target (${kb(opts.maxBytes)} ceiling)`)
269
+ }
270
+
271
+ const ext = path.extname(ref.resolved).toLowerCase()
272
+ if (['.jpg', '.jpeg', '.webp', '.avif'].includes(ext)) {
273
+ add('image-format', 'warning', ref, `Use PNG for screenshots or SVG for diagrams (got ${ext})`)
274
+ }
275
+
276
+ const width = imageWidth(ref.resolved)
277
+ if (width !== null && width > opts.maxWidth) {
278
+ add('image-too-wide', 'warning', ref, `Image is ${width}px wide, over the ${opts.maxWidth}px capture width (2x of a ${opts.maxWidth / 2}px display)`)
279
+ }
280
+ }
281
+
282
+ if (scoped) {
283
+ const referenced = new Set(allRefs.map((r) => r.resolved).filter(Boolean))
284
+ for (const img of changedImages) {
285
+ if (deletedImages.has(img)) continue // gone, so not orphaned
286
+ if (!referenced.has(img)) {
287
+ add('orphaned-image', 'warning', null,
288
+ 'No image macro under modules/ references this file - if it is used through an attribute or from another repo, ignore this; otherwise remove it or add the macro',
289
+ path.relative(root, img))
290
+ }
291
+ }
292
+ }
293
+
294
+ const severityRank = { error: 0, warning: 1 }
295
+ findings.sort((a, b) => severityRank[a.severity] - severityRank[b.severity] ||
296
+ a.file.localeCompare(b.file) || (a.line || 0) - (b.line || 0))
297
+
298
+ const lintedFiles = new Set(refs.map((r) => r.relPath))
299
+ return {
300
+ scope: scoped ? 'changed' : 'repo',
301
+ findings,
302
+ summary: {
303
+ adoc_files_scanned: adocFiles.length,
304
+ adoc_files_linted: scoped ? lintedFiles.size : adocFiles.length,
305
+ references: refs.length,
306
+ images: seenImages.size,
307
+ errors: findings.filter((f) => f.severity === 'error').length,
308
+ warnings: findings.filter((f) => f.severity === 'warning').length
309
+ }
310
+ }
311
+ }
312
+
313
+ function formatHuman (result) {
314
+ const { summary, findings } = result
315
+ const scopeNote = result.scope === 'changed'
316
+ ? `${summary.adoc_files_linted} changed .adoc file(s) (${summary.references} image reference(s))`
317
+ : `${summary.adoc_files_scanned} .adoc files (${summary.references} image reference(s))`
318
+ if (findings.length === 0) {
319
+ return `Checked ${scopeNote}. No screenshot standard violations found.`
320
+ }
321
+ const lines = [`Checked ${scopeNote}. Found ${summary.errors} error(s), ${summary.warnings} warning(s):`, '']
322
+ const fmt = (f) => `${f.file}${f.line ? `:${f.line}` : ''}${f.image ? ` [${f.image}]` : ''} ${f.message}`
323
+ const errors = findings.filter((f) => f.severity === 'error')
324
+ const warnings = findings.filter((f) => f.severity === 'warning')
325
+ if (errors.length) {
326
+ lines.push('Errors:')
327
+ for (const f of errors) lines.push(` ${fmt(f)}`)
328
+ }
329
+ if (warnings.length) {
330
+ if (errors.length) lines.push('')
331
+ lines.push('Warnings (not blocking):')
332
+ for (const f of warnings) lines.push(` ${fmt(f)}`)
333
+ }
334
+ return lines.join('\n')
335
+ }
336
+
337
+ function readFileList (listPath) {
338
+ const text = fs.readFileSync(path.resolve(listPath), 'utf8')
339
+ return text.split('\n').map((l) => l.trim()).filter(Boolean)
340
+ }
341
+
342
+ function intOption (value, name) {
343
+ if (value === undefined || value === null) return undefined
344
+ const n = Number(value)
345
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`${name} must be a positive integer (got ${value})`)
346
+ return n
347
+ }
348
+
349
+ function runCli (options) {
350
+ let result
351
+ try {
352
+ result = lintScreenshots({
353
+ root: options.root || process.cwd(),
354
+ files: options.files ? readFileList(options.files) : undefined,
355
+ maxAltLength: intOption(options.maxAltLength, '--max-alt-length'),
356
+ maxBytes: intOption(options.maxBytes, '--max-bytes'),
357
+ targetBytes: intOption(options.targetBytes, '--target-bytes'),
358
+ maxWidth: intOption(options.maxWidth, '--max-width')
359
+ })
360
+ } catch (err) {
361
+ console.error(`Error: ${err.message}`)
362
+ process.exit(2)
363
+ }
364
+
365
+ if (options.output) {
366
+ fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true })
367
+ fs.writeFileSync(path.resolve(options.output), JSON.stringify(result, null, 2) + '\n')
368
+ }
369
+ console.log(options.format === 'json' ? JSON.stringify(result, null, 2) : formatHuman(result))
370
+
371
+ const blocking = result.summary.errors + (options.warningsAsErrors ? result.summary.warnings : 0)
372
+ process.exit(blocking > 0 ? 1 : 0)
373
+ }
374
+
375
+ module.exports = {
376
+ DEFAULTS,
377
+ lintScreenshots,
378
+ formatHuman,
379
+ runCli,
380
+ parseAltText,
381
+ resolveImage,
382
+ imageWidth
383
+ }