@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.
- package/bin/doc-tools.js +127 -0
- package/extensions/REFERENCE.adoc +45 -9
- package/extensions/generate-rp-connect-info.js +79 -24
- package/extensions/modify-connect-tag-playbook.js +319 -74
- package/extensions/url-to-xref.js +3 -0
- package/extensions/util/connect-catalog.js +10 -5
- package/extensions/util/connect-docs-asset.js +187 -0
- package/extensions/util/connect-github.js +16 -0
- package/extensions/version-fetcher/list-connect-releases.js +10 -0
- package/package.json +1 -1
- 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
|
@@ -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
|
@@ -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, '<')
|
|
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
|
+
}
|