@redpanda-data/docs-extensions-and-macros 5.53.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,187 @@
1
+ 'use strict'
2
+
3
+ // Reads the Redpanda Connect generated reference docs.
4
+ //
5
+ // Every connect release (vX.Y.Z, including prereleases) publishes a
6
+ // redpanda-connect-docs.tar.gz asset whose root is `modules/`: the tree that
7
+ // connect's docs generator writes under its docs/ directory. Only
8
+ // modules/components/partials/** and modules/components/examples/** belong to
9
+ // the reference. Releases from before the asset existed answer 404.
10
+ //
11
+ // The same tree can come from a local directory instead (a connect checkout's
12
+ // docs/ after running its generator), or from a local copy of the tarball.
13
+
14
+ const fs = require('fs')
15
+ const path = require('path')
16
+ const zlib = require('zlib')
17
+
18
+ const OWNER = 'redpanda-data'
19
+ const REPO = 'connect'
20
+ const ASSET_NAME = 'redpanda-connect-docs.tar.gz'
21
+ const MODULE = 'components'
22
+ const FAMILY_DIRS = { partials: 'partial', examples: 'example' }
23
+ const DOWNLOAD_ATTEMPTS = 3
24
+ const DOWNLOAD_TIMEOUT_MS = 120000
25
+
26
+ function assetUrl (tag) {
27
+ return `https://github.com/${OWNER}/${REPO}/releases/download/${encodeURIComponent(tag)}/${ASSET_NAME}`
28
+ }
29
+
30
+ // The Antora resource of a path inside the tree, or null for anything outside
31
+ // modules/components/partials and modules/components/examples.
32
+ function toResource (entryPath) {
33
+ const p = String(entryPath || '').replace(/\\/g, '/').replace(/^(\.\/)+/, '')
34
+ const m = p.match(/^modules\/components\/(partials|examples)\/(.+)$/)
35
+ if (!m || m[2].endsWith('/')) return null
36
+ // A path that climbs out of its directory is not part of the tree.
37
+ if (m[2].split('/').some((segment) => segment === '..' || segment === '')) return null
38
+ return { family: FAMILY_DIRS[m[1]], relative: m[2], path: `modules/components/${m[1]}/${m[2]}` }
39
+ }
40
+
41
+ // Every regular file in a gzipped tar, as [{ path, contents }]. Throws on a
42
+ // corrupt or truncated archive rather than returning a partial list.
43
+ async function readTarGz (buffer) {
44
+ let tarBuffer
45
+ // The real asset unpacks to a few tens of MB. The cap turns a bad asset
46
+ // into a clear error instead of running the build out of memory.
47
+ const limit = module.exports.maxUnpackedBytes
48
+ try {
49
+ tarBuffer = zlib.gunzipSync(buffer, { maxOutputLength: limit })
50
+ } catch (error) {
51
+ if (error.code === 'ERR_BUFFER_TOO_LARGE') {
52
+ throw new Error(`the archive unpacks to more than ${limit} bytes, which no real reference docs asset does`)
53
+ }
54
+ throw new Error(`not a valid gzip archive: ${error.message}`)
55
+ }
56
+ // A complete tar ends with two zero blocks. Without them the archive was cut
57
+ // short at an entry boundary, which the parser alone does not report.
58
+ const end = tarBuffer.subarray(Math.max(0, tarBuffer.length - 1024))
59
+ if (tarBuffer.length % 512 !== 0 || end.length < 1024 || end.some((b) => b !== 0)) {
60
+ throw new Error('not a valid tar archive: missing the end-of-archive marker, so the archive is truncated')
61
+ }
62
+ const { Parser } = require('tar')
63
+ return new Promise((resolve, reject) => {
64
+ const files = []
65
+ let failure = null
66
+ const parser = new Parser({ strict: true })
67
+ parser.on('entry', (entry) => {
68
+ if (entry.type !== 'File' && entry.type !== 'OldFile' && entry.type !== 'ContiguousFile') {
69
+ entry.resume()
70
+ return
71
+ }
72
+ const chunks = []
73
+ entry.on('data', (chunk) => chunks.push(chunk))
74
+ entry.on('end', () => files.push({ path: entry.path, contents: Buffer.concat(chunks) }))
75
+ })
76
+ const fail = (error) => { if (!failure) failure = error }
77
+ parser.on('warn', (code, message) => fail(new Error(`not a valid tar archive: ${message}`)))
78
+ parser.on('error', (error) => fail(new Error(`not a valid tar archive: ${error.message}`)))
79
+ parser.on('close', () => (failure ? reject(failure) : resolve(files)))
80
+ try {
81
+ parser.end(tarBuffer)
82
+ } catch (error) {
83
+ fail(new Error(`not a valid tar archive: ${error.message}`))
84
+ reject(failure)
85
+ }
86
+ })
87
+ }
88
+
89
+ // One download attempt: { status } on an HTTP error, { body } on success.
90
+ // With a token it goes through the releases API, which works whether or not
91
+ // the connect repo is public. The asset endpoint redirects to a short-lived
92
+ // signed URL, which is fetched without the token.
93
+ async function fetchAssetOnce (tag, { fetchImpl, token }) {
94
+ const timeout = () => AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)
95
+ const ua = { 'User-Agent': 'Redpanda Docs' }
96
+ if (!token) {
97
+ const response = await fetchImpl(assetUrl(tag), { redirect: 'follow', signal: timeout(), headers: ua })
98
+ // Read the body here, so a connection that drops or times out
99
+ // mid-download is retried like a failed request.
100
+ return response.ok ? { body: Buffer.from(await response.arrayBuffer()) } : { status: response.status, statusText: response.statusText }
101
+ }
102
+ const auth = { ...ua, Authorization: `Bearer ${token}` }
103
+ const api = `https://api.github.com/repos/${OWNER}/${REPO}/releases/tags/${encodeURIComponent(tag)}`
104
+ const release = await fetchImpl(api, { signal: timeout(), headers: { ...auth, Accept: 'application/vnd.github+json' } })
105
+ // A token that is expired or lacks access must not break a build that the
106
+ // public download would serve. GitHub answers 404, not 403, for a repo the
107
+ // token can't see, so a 404 here falls back too, and is flagged so the
108
+ // caller can say why when the public download has no asset either.
109
+ if (release.status === 401 || release.status === 403) return fetchAssetOnce(tag, { fetchImpl, token: null })
110
+ if (release.status === 404) return { ...(await fetchAssetOnce(tag, { fetchImpl, token: null })), tokenCouldNotSee: true }
111
+ if (!release.ok) return { status: release.status, statusText: release.statusText }
112
+ const found = ((await release.json()).assets || []).find((a) => a.name === ASSET_NAME)
113
+ if (!found) return { status: 404, statusText: 'Not Found' }
114
+ let response = await fetchImpl(found.url, { redirect: 'manual', signal: timeout(), headers: { ...auth, Accept: 'application/octet-stream' } })
115
+ if (response.status === 401 || response.status === 403) return fetchAssetOnce(tag, { fetchImpl, token: null })
116
+ if (response.status >= 300 && response.status < 400 && response.headers.get('location')) {
117
+ response = await fetchImpl(response.headers.get('location'), { redirect: 'follow', signal: timeout(), headers: ua })
118
+ }
119
+ return response.ok ? { body: Buffer.from(await response.arrayBuffer()) } : { status: response.status, statusText: response.statusText }
120
+ }
121
+
122
+ // Downloads the asset of a release tag. Returns null when the release has no
123
+ // asset (404). Throws on any other failure, after retrying network errors and
124
+ // server errors. Pass the GitHub token when there is one: an unauthenticated
125
+ // download only works while the connect repo is public.
126
+ async function downloadAsset (tag, { fetchImpl = globalThis.fetch, logger, token = null } = {}) {
127
+ const url = token ? `the ${tag} release asset ${ASSET_NAME} through the GitHub API` : assetUrl(tag)
128
+ let lastError = null
129
+ for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt++) {
130
+ let result
131
+ try {
132
+ result = await fetchAssetOnce(tag, { fetchImpl, token })
133
+ } catch (error) {
134
+ lastError = new Error(`could not download ${url}: ${error.message}`)
135
+ }
136
+ if (result && result.body) return result.body
137
+ if (result) {
138
+ if (result.status === 404) {
139
+ if (result.tokenCouldNotSee && logger) {
140
+ logger.warn(`The GitHub API found no ${tag} release of ${OWNER}/${REPO} with the GitHub token, and the public download has no ${ASSET_NAME} either. If the repo is private, check that the token has read access to it.`)
141
+ }
142
+ return null
143
+ }
144
+ lastError = new Error(`could not download ${url}: HTTP ${result.status} ${result.statusText || ''}`.trim())
145
+ // A client error other than 404 will not change on a retry.
146
+ if (result.status < 500 && result.status !== 429) break
147
+ }
148
+ if (attempt < DOWNLOAD_ATTEMPTS) {
149
+ if (logger) logger.warn(`${lastError.message}; retrying (${attempt}/${DOWNLOAD_ATTEMPTS - 1})`)
150
+ await new Promise((resolve) => setTimeout(resolve, module.exports.retryDelayMs * attempt))
151
+ }
152
+ }
153
+ throw lastError
154
+ }
155
+
156
+ function walk (dir, base, out) {
157
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
158
+ const abs = path.join(dir, entry.name)
159
+ const rel = base ? `${base}/${entry.name}` : entry.name
160
+ if (entry.isDirectory()) walk(abs, rel, out)
161
+ else if (entry.isFile()) out.push({ path: rel, abspath: abs })
162
+ }
163
+ return out
164
+ }
165
+
166
+ // The files of a local copy of the tree: a directory that contains modules/
167
+ // (or a connect checkout whose docs/ does), or a local .tar.gz of the asset.
168
+ async function readLocal (location) {
169
+ const resolved = path.resolve(location)
170
+ let stat
171
+ try {
172
+ stat = fs.statSync(resolved)
173
+ } catch (error) {
174
+ throw new Error(`${resolved} does not exist`)
175
+ }
176
+ if (stat.isFile()) return readTarGz(fs.readFileSync(resolved))
177
+ let root = resolved
178
+ if (!fs.existsSync(path.join(root, 'modules')) && fs.existsSync(path.join(root, 'docs', 'modules'))) root = path.join(root, 'docs')
179
+ if (!fs.existsSync(path.join(root, 'modules', MODULE))) {
180
+ throw new Error(`${resolved} has no modules/${MODULE} directory`)
181
+ }
182
+ return walk(path.join(root, 'modules', MODULE), `modules/${MODULE}`, [])
183
+ .map((f) => ({ path: f.path, contents: fs.readFileSync(f.abspath) }))
184
+ }
185
+
186
+ // retryDelayMs is a property so tests can shorten the backoff.
187
+ module.exports = { OWNER, REPO, ASSET_NAME, MODULE, retryDelayMs: 1000, maxUnpackedBytes: 512 * 1024 * 1024, assetUrl, toResource, readTarGz, downloadAsset, readLocal }
@@ -0,0 +1,16 @@
1
+ 'use strict'
2
+
3
+ // The GitHub API client the Redpanda Connect extensions use for release
4
+ // lookups: authenticated when a token is available, retrying transient
5
+ // failures but not rate limits or missing resources.
6
+
7
+ const { getGitHubApiToken } = require('../../cli-utils/github-token')
8
+
9
+ async function createGitHub () {
10
+ const { Octokit } = await import('@octokit/rest')
11
+ const { retry } = await import('@octokit/plugin-retry')
12
+ const token = getGitHubApiToken()
13
+ return new (Octokit.plugin(retry))({ userAgent: 'Redpanda Docs', auth: token || undefined, retry: { doNotRetry: [401, 403, 404, 429] } })
14
+ }
15
+
16
+ module.exports = { createGitHub }
@@ -0,0 +1,10 @@
1
+ 'use strict'
2
+
3
+ // The most recent releases of a repository, newest first, as the GitHub API
4
+ // lists them (one page of at most `limit` releases). Each release carries its
5
+ // assets, so a caller can check for an asset without downloading it. Throws
6
+ // when the API is unavailable, so the caller can fall back to git tags.
7
+ module.exports = async (github, owner, repo, limit) => {
8
+ const { data } = await github.rest.repos.listReleases({ owner, repo, per_page: limit })
9
+ return Array.isArray(data) ? data.slice(0, limit) : []
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.53.0",
3
+ "version": "5.54.0",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -0,0 +1,311 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ /**
7
+ * doc-tools check-build-log: the blocking gate of the Connect docs PR check.
8
+ *
9
+ * Antora's own --log-failure-level makes Antora exit non-zero, but it says
10
+ * nothing about WHAT failed in a form a job summary can show, and it cannot
11
+ * catch the build that exits 0 having written no pages at all (Antora on an
12
+ * unsupported Node version does exactly that). So the workflow runs Antora
13
+ * with --log-failure-level=fatal and --log-format json, and this command
14
+ * decides:
15
+ *
16
+ * - any record at level error or fatal fails the check. With
17
+ * --blocking-sources, an error fails it only when it comes from one of
18
+ * those repositories (its own source or any page in its include chain),
19
+ * or has no source at all (an extension error); errors from the other
20
+ * sources are listed but do not block. Fatal records always block,
21
+ * - with --min-pages and --site-dir, fewer HTML pages than expected fails it,
22
+ * - warnings never fail it; they are listed in the summary.
23
+ *
24
+ * The summary is Markdown, written to stdout, for $GITHUB_STEP_SUMMARY.
25
+ */
26
+
27
+ const LEVELS = ['fatal', 'error', 'warn', 'info', 'debug', 'trace']
28
+ const BLOCKING_LEVELS = new Set(['fatal', 'error'])
29
+ // pino's numeric levels, in case a log was written without level labels.
30
+ const NUMERIC_LEVELS = { 10: 'trace', 20: 'debug', 30: 'info', 40: 'warn', 50: 'error', 60: 'fatal' }
31
+
32
+ const DEFAULTS = Object.freeze({
33
+ maxGroups: 50,
34
+ maxLocations: 5
35
+ })
36
+
37
+ function normalizeLevel (level) {
38
+ if (typeof level === 'number') return NUMERIC_LEVELS[level] || String(level)
39
+ return String(level || 'info').toLowerCase()
40
+ }
41
+
42
+ /**
43
+ * A human-readable location for one log record: the source repository and
44
+ * ref, then the file and line. Local worktree paths are made relative to the
45
+ * worktree so the same file reads the same on every runner.
46
+ */
47
+ function relativePath (file, source) {
48
+ let filePath = (file && file.path) || ''
49
+ if (filePath && source && source.worktree && path.isAbsolute(filePath)) {
50
+ const rel = path.relative(source.worktree, filePath)
51
+ if (!rel.startsWith('..')) filePath = rel
52
+ }
53
+ return filePath
54
+ }
55
+
56
+ /** owner/name of a source URL, without the host or .git. */
57
+ function repoSlug (url) {
58
+ return String(url || '').replace(/\.git$/, '').replace(/^[a-z]+:\/\/[^/]+\//i, '').replace(/^git@[^:]+:/, '')
59
+ }
60
+
61
+ function locationOf (record) {
62
+ const file = record.file || {}
63
+ const source = record.source || {}
64
+ if (!file.path && !source.url) return null
65
+ const filePath = relativePath(file, source)
66
+ let where = ''
67
+ if (source.url) {
68
+ const repo = repoSlug(source.url)
69
+ where = source.refname ? `${repo}@${source.refname}` : repo
70
+ if (source.startPath) where += `/${source.startPath}`
71
+ }
72
+ let loc = filePath
73
+ if (loc && file.line) loc += `:${file.line}`
74
+ // The include chain: the page that pulled the file in, which is the useful
75
+ // location when the problem is in a shared partial.
76
+ const via = Array.isArray(record.stack) && record.stack.length
77
+ ? record.stack.map((s) => {
78
+ const p = relativePath(s.file, s.source)
79
+ return p ? `${p}${s.file.line ? `:${s.file.line}` : ''}` : null
80
+ }).filter(Boolean)
81
+ : []
82
+ let out = [where, loc].filter(Boolean).join(': ')
83
+ if (via.length) out += ` (included from ${via.join(' < ')})`
84
+ return out || null
85
+ }
86
+
87
+ function messageOf (record) {
88
+ if (record.msg) return String(record.msg)
89
+ if (record.err && record.err.message) return String(record.err.message)
90
+ if (record.message) return String(record.message)
91
+ return '(no message)'
92
+ }
93
+
94
+ /**
95
+ * Parse an Antora JSON (NDJSON) log. Lines that are not JSON objects (stray
96
+ * stderr, a progress bar) are counted, not fatal: a log is still checkable
97
+ * when something else wrote to the same file.
98
+ */
99
+ function parseLog (text) {
100
+ const records = []
101
+ let unparsed = 0
102
+ for (const line of String(text).split('\n')) {
103
+ const trimmed = line.trim()
104
+ if (!trimmed) continue
105
+ let obj
106
+ try {
107
+ obj = JSON.parse(trimmed)
108
+ } catch {
109
+ unparsed++
110
+ continue
111
+ }
112
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
113
+ unparsed++
114
+ continue
115
+ }
116
+ records.push(obj)
117
+ }
118
+ return { records, unparsed }
119
+ }
120
+
121
+ function countHtmlPages (dir) {
122
+ let count = 0
123
+ const stack = [dir]
124
+ while (stack.length) {
125
+ const current = stack.pop()
126
+ let entries
127
+ try {
128
+ entries = fs.readdirSync(current, { withFileTypes: true })
129
+ } catch {
130
+ continue
131
+ }
132
+ for (const entry of entries) {
133
+ const full = path.join(current, entry.name)
134
+ if (entry.isDirectory()) stack.push(full)
135
+ else if (entry.isFile() && entry.name.endsWith('.html')) count++
136
+ }
137
+ }
138
+ return count
139
+ }
140
+
141
+ /**
142
+ * Check a parsed log. Returns the counts, the grouped messages, and whether
143
+ * the check passed.
144
+ */
145
+ /**
146
+ * Whether an error-level record blocks. Without a source list, every one
147
+ * does. With one, a record blocks when it has no source at all (an
148
+ * extension's own error), or when its source or any source in its include
149
+ * chain is one of the listed repositories.
150
+ */
151
+ function isBlocking (record, level, blockingSources) {
152
+ if (level === 'fatal') return true
153
+ if (!blockingSources || !blockingSources.length) return true
154
+ const sources = [record.source, ...(Array.isArray(record.stack) ? record.stack.map((s) => s && s.source) : [])]
155
+ .filter((s) => s && s.url)
156
+ if (!sources.length) return true
157
+ const wanted = new Set(blockingSources.map((s) => repoSlug(s).toLowerCase()))
158
+ return sources.some((s) => wanted.has(repoSlug(s.url).toLowerCase()))
159
+ }
160
+
161
+ function checkBuildLog ({ text, siteDir, minPages, blockingSources } = {}) {
162
+ const { records, unparsed } = parseLog(text)
163
+ const counts = Object.fromEntries(LEVELS.map((l) => [l, 0]))
164
+ const groups = new Map()
165
+ let blocking = 0
166
+ for (const record of records) {
167
+ let level = normalizeLevel(record.level)
168
+ counts[level] = (counts[level] || 0) + 1
169
+ if (!BLOCKING_LEVELS.has(level) && level !== 'warn') continue
170
+ if (BLOCKING_LEVELS.has(level)) {
171
+ if (isBlocking(record, level, blockingSources)) blocking++
172
+ else level = 'error-elsewhere'
173
+ }
174
+ const msg = messageOf(record)
175
+ const key = `${level}\u0000${msg}`
176
+ let group = groups.get(key)
177
+ if (!group) {
178
+ group = { level, msg, count: 0, locations: [] }
179
+ groups.set(key, group)
180
+ }
181
+ group.count++
182
+ const loc = locationOf(record)
183
+ if (loc && !group.locations.includes(loc)) group.locations.push(loc)
184
+ }
185
+
186
+ const failures = []
187
+ if (blocking > 0) {
188
+ const scope = blockingSources && blockingSources.length ? ` from ${blockingSources.join(', ')}` : ''
189
+ failures.push(`${blocking} log ${blocking === 1 ? 'record' : 'records'} at level error or fatal${scope}`)
190
+ }
191
+
192
+ let pages = null
193
+ if (minPages != null) {
194
+ if (!siteDir) throw new Error('--min-pages needs --site-dir')
195
+ pages = fs.existsSync(siteDir) ? countHtmlPages(siteDir) : 0
196
+ if (pages < minPages) {
197
+ failures.push(`${pages} HTML ${pages === 1 ? 'page' : 'pages'} written under ${siteDir}, fewer than the required ${minPages}`)
198
+ }
199
+ }
200
+
201
+ const byLevel = (level) => [...groups.values()]
202
+ .filter((g) => g.level === level)
203
+ .sort((a, b) => b.count - a.count || a.msg.localeCompare(b.msg))
204
+
205
+ return {
206
+ ok: failures.length === 0,
207
+ failures,
208
+ counts,
209
+ total: records.length,
210
+ unparsed,
211
+ pages,
212
+ minPages: minPages == null ? null : minPages,
213
+ blocking,
214
+ blockingSources: blockingSources && blockingSources.length ? blockingSources : null,
215
+ groups: {
216
+ fatal: byLevel('fatal'),
217
+ error: byLevel('error'),
218
+ errorElsewhere: byLevel('error-elsewhere'),
219
+ warn: byLevel('warn')
220
+ }
221
+ }
222
+ }
223
+
224
+ // Markdown table cells and inline code cannot hold a raw pipe or backtick
225
+ // run, and a message can contain both.
226
+ function mdInline (text) {
227
+ return String(text).replace(/\r?\n/g, ' ').replace(/\|/g, '\\|').replace(/</g, '&lt;')
228
+ }
229
+
230
+ function formatGroups (title, groups, { maxGroups, maxLocations }) {
231
+ if (!groups.length) return []
232
+ const lines = [`### ${title}`, '']
233
+ for (const g of groups.slice(0, maxGroups)) {
234
+ lines.push(`- ${g.count > 1 ? `(${g.count}x) ` : ''}${mdInline(g.msg)}`)
235
+ for (const loc of g.locations.slice(0, maxLocations)) lines.push(` - ${mdInline(loc)}`)
236
+ if (g.locations.length > maxLocations) lines.push(` - and ${g.locations.length - maxLocations} more locations`)
237
+ }
238
+ if (groups.length > maxGroups) lines.push(`- and ${groups.length - maxGroups} more distinct messages`)
239
+ lines.push('')
240
+ return lines
241
+ }
242
+
243
+ function formatMarkdown (result, opts = {}) {
244
+ const { maxGroups, maxLocations } = { ...DEFAULTS, ...opts }
245
+ const lines = []
246
+ lines.push(`## Antora build log: ${result.ok ? 'passed' : 'failed'}`, '')
247
+ if (!result.ok) {
248
+ for (const f of result.failures) lines.push(`- **Blocking:** ${f}`)
249
+ lines.push('')
250
+ }
251
+ lines.push('| Level | Count |', '|---|---|')
252
+ for (const level of LEVELS) {
253
+ if (level === 'debug' || level === 'trace') {
254
+ if (!result.counts[level]) continue
255
+ }
256
+ lines.push(`| ${level} | ${result.counts[level] || 0} |`)
257
+ }
258
+ if (result.pages != null) lines.push(`| HTML pages | ${result.pages} (minimum ${result.minPages}) |`)
259
+ if (result.unparsed) lines.push(`| non-JSON lines | ${result.unparsed} |`)
260
+ lines.push('')
261
+ lines.push(...formatGroups('Fatal', result.groups.fatal, { maxGroups, maxLocations }))
262
+ lines.push(...formatGroups('Errors', result.groups.error, { maxGroups, maxLocations }))
263
+ if (result.groups.errorElsewhere.length) {
264
+ lines.push(...formatGroups(`Errors from other sources (do not block; only ${result.blockingSources.join(', ')} block)`, result.groups.errorElsewhere, { maxGroups, maxLocations }))
265
+ }
266
+ lines.push(...formatGroups('Warnings (do not block)', result.groups.warn, { maxGroups, maxLocations }))
267
+ return lines.join('\n')
268
+ }
269
+
270
+ function intOption (value, flag) {
271
+ if (value == null) return undefined
272
+ const n = Number(value)
273
+ if (!Number.isInteger(n) || n < 0) throw new Error(`${flag} must be a non-negative integer, got ${value}`)
274
+ return n
275
+ }
276
+
277
+ function runCli (logFile, options = {}) {
278
+ let result
279
+ try {
280
+ if (!logFile || !fs.existsSync(logFile)) throw new Error(`log file not found: ${logFile}`)
281
+ result = checkBuildLog({
282
+ text: fs.readFileSync(logFile, 'utf8'),
283
+ siteDir: options.siteDir,
284
+ minPages: intOption(options.minPages, '--min-pages'),
285
+ blockingSources: options.blockingSources
286
+ ? String(options.blockingSources).split(',').map((x) => x.trim()).filter(Boolean)
287
+ : undefined
288
+ })
289
+ } catch (err) {
290
+ console.error(`Error: ${err.message}`)
291
+ process.exit(2)
292
+ }
293
+ const out = options.format === 'json' ? JSON.stringify(result, null, 2) : formatMarkdown(result)
294
+ console.log(out)
295
+ if (options.output) {
296
+ fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true })
297
+ fs.writeFileSync(path.resolve(options.output), out + '\n')
298
+ }
299
+ process.exit(result.ok ? 0 : 1)
300
+ }
301
+
302
+ module.exports = {
303
+ DEFAULTS,
304
+ parseLog,
305
+ checkBuildLog,
306
+ countHtmlPages,
307
+ formatMarkdown,
308
+ locationOf,
309
+ isBlocking,
310
+ runCli
311
+ }