@redpanda-data/docs-extensions-and-macros 5.23.0 → 5.29.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/extensions/REFERENCE.adoc +44 -1
- package/extensions/external-link-checker.js +181 -32
- package/extensions/url-to-xref.js +5 -0
- package/extensions/util/scan-content-urls.js +78 -12
- package/extensions/version-fetcher/set-latest-version.js +7 -2
- package/package.json +1 -1
- package/tools/lint-strings/diff.js +67 -1
- package/tools/lint-strings/engine.js +6 -0
- package/tools/lint-strings/index.js +32 -2
|
@@ -574,9 +574,35 @@ Responses are classified as follows:
|
|
|
574
574
|
* 401/403/429: unverifiable — logged at info level, because bot-walled or rate-limited sites are not broken
|
|
575
575
|
* Other statuses, network errors, and timeouts (after one retry): unverifiable — logged as a warning
|
|
576
576
|
|
|
577
|
+
=== What is not checked
|
|
578
|
+
|
|
579
|
+
The extension runs at `contentClassified`, on raw AsciiDoc, before Asciidoctor converts anything. Three kinds of URL are therefore left alone, because checking them reports a broken link where the published page has no link at all:
|
|
580
|
+
|
|
581
|
+
Code blocks::
|
|
582
|
+
URLs inside a listing (`----`), literal (`....`), passthrough (`++++`) or fenced (`+```+`) block. A fenced block counts as one even when the opening delimiter carries a language info string, such as `+```bash+` or `+```yml+`.
|
|
583
|
+
|
|
584
|
+
Comments::
|
|
585
|
+
URLs on a line comment (`//`) or inside a comment block (`////`). Asciidoctor drops these before rendering, so a generator's provenance note, a Doc Detective test step, or a writer's editorial aside never becomes a link.
|
|
586
|
+
|
|
587
|
+
Unresolved attribute references::
|
|
588
|
+
A URL still holding a literal `{name}` reference, such as `+https://github.com/{project-github}/issues+`. Attributes are substituted after this hook runs, so the real target is unknown here while the published URL is usually fine. These are logged at info level with the pages that reference them, and counted separately, so a genuinely undefined attribute is still visible.
|
|
589
|
+
|
|
590
|
+
=== Report attribution
|
|
591
|
+
|
|
592
|
+
Each finding names the repository, branch, and path the URL was found in, for example:
|
|
593
|
+
|
|
594
|
+
----
|
|
595
|
+
Broken external link https://example.com/gone (HTTP 404) referenced in: redpanda-data/docs@main:modules/manage/pages/x.adoc
|
|
596
|
+
----
|
|
597
|
+
|
|
598
|
+
An aggregated site builds the same Antora-relative path from several repositories and many version branches, so a path on its own does not identify a file well enough to act on.
|
|
599
|
+
|
|
577
600
|
=== Environment variables
|
|
578
601
|
|
|
579
|
-
|
|
602
|
+
LINK_CHECK::
|
|
603
|
+
Set to `true` to check links. Without it the extension registers and does nothing, so it can live in a playbook that preview builds also use without those builds making network requests.
|
|
604
|
+
+
|
|
605
|
+
This is an environment variable rather than a configuration option for two reasons: Antora does not interpolate environment variables into playbook values, and Antora consumes a playbook entry's own `enabled` key itself, before the config reaches the extension, so that name is not available.
|
|
580
606
|
|
|
581
607
|
=== Configuration options
|
|
582
608
|
|
|
@@ -589,6 +615,11 @@ List of regular expressions. When set, only URLs matching at least one pattern a
|
|
|
589
615
|
exclude (optional)::
|
|
590
616
|
List of regular expressions. URLs matching any pattern are skipped.
|
|
591
617
|
|
|
618
|
+
versions (optional)::
|
|
619
|
+
Which component versions to check. `latest` checks only each component's latest version, plus every unversioned component; `all` checks every version in the build. Default: `latest`.
|
|
620
|
+
+
|
|
621
|
+
A site that publishes many versions of a component republishes the same dead link once per branch, and those older branches are frozen content that nobody edits, so checking them fills the report with findings that cannot be acted on.
|
|
622
|
+
|
|
592
623
|
concurrency (optional)::
|
|
593
624
|
Number of simultaneous requests. Default: `10`.
|
|
594
625
|
|
|
@@ -598,6 +629,9 @@ Per-request timeout in milliseconds. Default: `10000`.
|
|
|
598
629
|
fail_on_broken (optional)::
|
|
599
630
|
Log broken links at error level so a build run with `--log-failure-level=error` fails. Default: `false`.
|
|
600
631
|
|
|
632
|
+
report_file (optional)::
|
|
633
|
+
Path to write a machine-readable JSON report to. Unset by default, in which case no report is written. Written every time the checker runs, including when there is nothing to say, so a consumer polling for this file can tell a clean run apart from one where the checker never ran at all. The report holds `counts`, a `results` array sorted broken-first, and `unresolvedAttributeReferences`; each result carries the URL, its classification and status, and a `refs` array of `{repo, refname, component, version, path}`. Intended for automation that has to act on the findings rather than read them.
|
|
634
|
+
|
|
601
635
|
=== Registration
|
|
602
636
|
|
|
603
637
|
Register the `external-link-checker` extension in the Antora playbook under the `antora.extensions` key like so:
|
|
@@ -607,9 +641,18 @@ Register the `external-link-checker` extension in the Antora playbook under the
|
|
|
607
641
|
antora:
|
|
608
642
|
extensions:
|
|
609
643
|
- require: '@redpanda-data/docs-extensions-and-macros/extensions/external-link-checker'
|
|
644
|
+
versions: latest
|
|
645
|
+
report_file: link-check.json
|
|
610
646
|
fail_on_broken: false
|
|
611
647
|
----
|
|
612
648
|
|
|
649
|
+
That registration is inert on every build except one run with `LINK_CHECK=true`:
|
|
650
|
+
|
|
651
|
+
[source,bash]
|
|
652
|
+
----
|
|
653
|
+
LINK_CHECK=true npx antora --fetch antora-playbook.yml
|
|
654
|
+
----
|
|
655
|
+
|
|
613
656
|
== Validate attributes
|
|
614
657
|
|
|
615
658
|
This extension ensures the consistency and validity of page attributes, focusing on validating page categories against a predefined list of valid categories and subcategories. It automatically adds missing parent categories for any specified subcategories and removes any specified categories that are invalid. Additionally, it processes specific environment attributes, setting corresponding page-level attributes when environment conditions are met.
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
3
6
|
const { scanContentUrls } = require('./util/scan-content-urls')
|
|
4
7
|
const { raiseListenerLimit } = require('./util/raise-listener-limit')
|
|
5
8
|
|
|
@@ -20,16 +23,34 @@ const { raiseListenerLimit } = require('./util/raise-listener-limit')
|
|
|
20
23
|
* # internal_hostnames: ['docs.redpanda.com'] hosts to skip (default)
|
|
21
24
|
* # include: [] regex allowlist; when set, only matching URLs are checked
|
|
22
25
|
* # exclude: [] regex denylist
|
|
26
|
+
* # versions: 'latest' 'latest' checks only each component's latest
|
|
27
|
+
* # version (plus unversioned components); 'all'
|
|
28
|
+
* # checks every version in the build
|
|
23
29
|
* # concurrency: 10 simultaneous requests
|
|
24
30
|
* # timeout: 10000 per-request timeout in ms
|
|
25
31
|
* # fail_on_broken: false log broken links at error level so a build
|
|
26
32
|
* # run with --log-failure-level=error fails
|
|
33
|
+
* # report_file: '' path to write a machine-readable JSON report to
|
|
34
|
+
*
|
|
35
|
+
* Checking only happens when LINK_CHECK=true is set in the environment, so one
|
|
36
|
+
* playbook can serve both a scheduled link-check build and ordinary preview
|
|
37
|
+
* builds, which must not make network requests. It is an environment variable
|
|
38
|
+
* rather than a config option because Antora does not interpolate environment
|
|
39
|
+
* variables into playbook values, and because Antora consumes the playbook's
|
|
40
|
+
* own `enabled` key itself (generator-context.js strips it before the config
|
|
41
|
+
* reaches an extension), so that name is not available here.
|
|
27
42
|
*
|
|
28
43
|
* Classification:
|
|
29
44
|
* - 2xx/3xx: ok
|
|
30
45
|
* - 404/410: broken (warn, or error with fail_on_broken)
|
|
31
|
-
* - 401/403/429: unverifiable (info)
|
|
46
|
+
* - 401/403/429: unverifiable (info) - bot-walled or rate-limited, not broken
|
|
32
47
|
* - other statuses, network errors, timeouts (after one retry): unverifiable (warn)
|
|
48
|
+
*
|
|
49
|
+
* Why 'latest' is the default for versions: a site that publishes many
|
|
50
|
+
* versions of the same component republishes the same dead link once per
|
|
51
|
+
* branch, and the older branches are frozen content that nobody is going to
|
|
52
|
+
* edit. Checking them turns the report into a list of findings that cannot be
|
|
53
|
+
* acted on. Set versions: 'all' to see them anyway.
|
|
33
54
|
*/
|
|
34
55
|
|
|
35
56
|
const USER_AGENT = 'redpanda-docs-link-checker'
|
|
@@ -37,59 +58,186 @@ const USER_AGENT = 'redpanda-docs-link-checker'
|
|
|
37
58
|
module.exports.register = function ({ config = {} }) {
|
|
38
59
|
raiseListenerLimit(this)
|
|
39
60
|
const logger = this.getLogger('external-link-checker-extension')
|
|
61
|
+
// Inert unless asked for, so this can be registered in a playbook that
|
|
62
|
+
// preview builds also use. See the note above on why this is an environment
|
|
63
|
+
// variable and not a config option.
|
|
64
|
+
if (process.env.LINK_CHECK !== 'true') return
|
|
40
65
|
const internalHostnames = new Set(config.internalHostnames || ['docs.redpanda.com'])
|
|
41
66
|
const include = (config.include || []).map((pattern) => new RegExp(pattern))
|
|
42
67
|
const exclude = (config.exclude || []).map((pattern) => new RegExp(pattern))
|
|
68
|
+
const versions = config.versions || 'latest'
|
|
43
69
|
const concurrency = config.concurrency || 10
|
|
44
70
|
const timeout = config.timeout || 10000
|
|
45
71
|
const failOnBroken = Boolean(config.failOnBroken)
|
|
72
|
+
const reportFile = config.reportFile || null
|
|
46
73
|
|
|
47
74
|
this.on('contentClassified', async ({ contentCatalog }) => {
|
|
48
|
-
const urlReferences = collectExternalUrls(contentCatalog, {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
counts[verdict.classification]++
|
|
54
|
-
const pages = [...urlReferences.get(url)].join(', ')
|
|
55
|
-
if (verdict.classification === 'broken') {
|
|
56
|
-
const message = `Broken external link ${url} (HTTP ${verdict.status}) referenced in: ${pages}`
|
|
57
|
-
failOnBroken ? logger.error(message) : logger.warn(message)
|
|
58
|
-
} else if (verdict.classification === 'unverifiable') {
|
|
59
|
-
const reason = verdict.status ? `HTTP ${verdict.status}` : verdict.reason
|
|
60
|
-
const log = verdict.status && [401, 403, 429].includes(verdict.status) ? 'info' : 'warn'
|
|
61
|
-
logger[log](`Could not verify external link ${url} (${reason}) referenced in: ${pages}`)
|
|
62
|
-
}
|
|
75
|
+
const { urlReferences, unresolved } = collectExternalUrls(contentCatalog, {
|
|
76
|
+
internalHostnames,
|
|
77
|
+
include,
|
|
78
|
+
exclude,
|
|
79
|
+
versions,
|
|
63
80
|
})
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
// An unresolved attribute reference is not a broken link. This hook runs
|
|
82
|
+
// before Asciidoctor substitutes attributes, so {latest-operator-version}
|
|
83
|
+
// is still literal here while the published URL is fine. Report the count
|
|
84
|
+
// so a genuinely undefined attribute is still visible, but never fetch.
|
|
85
|
+
if (unresolved.size) {
|
|
86
|
+
logger.info(
|
|
87
|
+
`Skipped ${unresolved.size} URL(s) holding an unresolved attribute reference ` +
|
|
88
|
+
'(attributes are substituted after this hook runs)'
|
|
89
|
+
)
|
|
90
|
+
for (const [url, refs] of unresolved) {
|
|
91
|
+
logger.info(`Unchecked external link ${url} (unresolved attribute) referenced in: ${formatRefs(refs)}`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const counts = { ok: 0, broken: 0, unverifiable: 0 }
|
|
95
|
+
const results = []
|
|
96
|
+
// Nothing to fetch when every URL was internal, excluded, or an
|
|
97
|
+
// unresolved attribute reference. Still fall through to writeReport
|
|
98
|
+
// below: a consumer polling for report_file (the fixer workflow) cannot
|
|
99
|
+
// tell a clean, nothing-to-check build apart from one where the checker
|
|
100
|
+
// never ran unless the file always exists when configured.
|
|
101
|
+
if (urlReferences.size) {
|
|
102
|
+
await runWithConcurrency([...urlReferences.keys()], concurrency, async (url) => {
|
|
103
|
+
const verdict = await checkUrl(url, { timeout })
|
|
104
|
+
counts[verdict.classification]++
|
|
105
|
+
const refs = urlReferences.get(url)
|
|
106
|
+
results.push({
|
|
107
|
+
url,
|
|
108
|
+
classification: verdict.classification,
|
|
109
|
+
status: verdict.status ?? null,
|
|
110
|
+
reason: verdict.reason ?? null,
|
|
111
|
+
refs: [...refs.values()],
|
|
112
|
+
})
|
|
113
|
+
const pages = formatRefs(refs)
|
|
114
|
+
if (verdict.classification === 'broken') {
|
|
115
|
+
const message = `Broken external link ${url} (HTTP ${verdict.status}) referenced in: ${pages}`
|
|
116
|
+
failOnBroken ? logger.error(message) : logger.warn(message)
|
|
117
|
+
} else if (verdict.classification === 'unverifiable') {
|
|
118
|
+
const reason = verdict.status ? `HTTP ${verdict.status}` : verdict.reason
|
|
119
|
+
const log = verdict.status && [401, 403, 429].includes(verdict.status) ? 'info' : 'warn'
|
|
120
|
+
logger[log](`Could not verify external link ${url} (${reason}) referenced in: ${pages}`)
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
logger.info(
|
|
124
|
+
`Checked ${urlReferences.size} external links: ${counts.ok} ok, ${counts.broken} broken, ` +
|
|
125
|
+
`${counts.unverifiable} unverifiable`
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
if (reportFile) writeReport(reportFile, { results, unresolved, counts, logger })
|
|
68
129
|
})
|
|
69
130
|
}
|
|
70
131
|
|
|
71
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Where a URL was found, in enough detail to act on it: which repository,
|
|
134
|
+
* which branch, and the path within that repository. A report line carrying
|
|
135
|
+
* only the Antora-relative path ('modules/reference/pages/x.adoc') is
|
|
136
|
+
* ambiguous across the several repositories and many version branches an
|
|
137
|
+
* aggregated site builds from, which makes it unusable for anything that has
|
|
138
|
+
* to open a pull request against the right one.
|
|
139
|
+
*/
|
|
140
|
+
function fileReference (file) {
|
|
141
|
+
const src = file.src || {}
|
|
142
|
+
const origin = src.origin || {}
|
|
143
|
+
const repo = repoSlug(origin.url)
|
|
144
|
+
const startPath = origin.startPath || ''
|
|
145
|
+
const repoPath = startPath ? `${startPath}/${file.path}` : file.path
|
|
146
|
+
const refname = origin.refname || origin.branch || origin.tag || ''
|
|
147
|
+
const ref = {
|
|
148
|
+
repo,
|
|
149
|
+
refname,
|
|
150
|
+
component: src.component || '',
|
|
151
|
+
version: src.version || '',
|
|
152
|
+
path: repoPath,
|
|
153
|
+
}
|
|
154
|
+
// Without origin metadata (a synthetic catalog, or a local content source
|
|
155
|
+
// with no git remote) the path is all there is to report.
|
|
156
|
+
const label = repo ? `${repo}${refname ? `@${refname}` : ''}:${repoPath}` : repoPath
|
|
157
|
+
return { label, ref }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 'https://github.com/redpanda-data/docs.git' -> 'redpanda-data/docs' */
|
|
161
|
+
function repoSlug (url) {
|
|
162
|
+
if (!url || typeof url !== 'string') return ''
|
|
163
|
+
const match = url.match(/[/:]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/)
|
|
164
|
+
return match ? `${match[1]}/${match[2]}` : ''
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function formatRefs (refs) {
|
|
168
|
+
return [...refs.keys()].join(', ')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Maps each component to its latest version, so non-latest content can be
|
|
173
|
+
* skipped. Catalogs without getComponents (unit-test doubles) fall back to
|
|
174
|
+
* checking everything.
|
|
175
|
+
*/
|
|
176
|
+
function latestVersions (contentCatalog) {
|
|
177
|
+
if (typeof contentCatalog.getComponents !== 'function') return null
|
|
178
|
+
const latest = new Map()
|
|
179
|
+
for (const component of contentCatalog.getComponents()) {
|
|
180
|
+
latest.set(component.name, component.latest ? component.latest.version : '')
|
|
181
|
+
}
|
|
182
|
+
return latest
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function collectExternalUrls (contentCatalog, { internalHostnames, include, exclude, versions = 'all' }) {
|
|
72
186
|
const urlReferences = new Map()
|
|
187
|
+
const unresolved = new Map()
|
|
188
|
+
const latest = versions === 'latest' ? latestVersions(contentCatalog) : null
|
|
73
189
|
const files = contentCatalog
|
|
74
190
|
.getPages((page) => page.out)
|
|
75
191
|
.concat(contentCatalog.findBy({ family: 'partial' }))
|
|
76
192
|
for (const file of files) {
|
|
77
193
|
if (!file.contents) continue
|
|
194
|
+
const src = file.src || {}
|
|
195
|
+
// An unversioned component has an empty version and is always current.
|
|
196
|
+
if (latest && src.version && latest.has(src.component) && latest.get(src.component) !== src.version) continue
|
|
197
|
+
const { label, ref } = fileReference(file)
|
|
78
198
|
for (const match of scanContentUrls(file.contents.toString())) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
199
|
+
const target = match.hasAttributeReference ? unresolved : urlReferences
|
|
200
|
+
if (!match.hasAttributeReference) {
|
|
201
|
+
let url
|
|
202
|
+
try {
|
|
203
|
+
url = new URL(match.url)
|
|
204
|
+
} catch {
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
if (internalHostnames.has(url.hostname)) continue
|
|
208
|
+
if (include.length && !include.some((pattern) => pattern.test(match.url))) continue
|
|
209
|
+
if (exclude.some((pattern) => pattern.test(match.url))) continue
|
|
84
210
|
}
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
if (exclude.some((pattern) => pattern.test(match.url))) continue
|
|
88
|
-
if (!urlReferences.has(match.url)) urlReferences.set(match.url, new Set())
|
|
89
|
-
urlReferences.get(match.url).add(file.path)
|
|
211
|
+
if (!target.has(match.url)) target.set(match.url, new Map())
|
|
212
|
+
target.get(match.url).set(label, ref)
|
|
90
213
|
}
|
|
91
214
|
}
|
|
92
|
-
return urlReferences
|
|
215
|
+
return { urlReferences, unresolved }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function writeReport (reportFile, { results, unresolved, counts, logger }) {
|
|
219
|
+
const report = {
|
|
220
|
+
generatedAt: new Date().toISOString(),
|
|
221
|
+
counts,
|
|
222
|
+
// Broken first, then unverifiable, so a consumer reading the head of the
|
|
223
|
+
// list gets the actionable findings.
|
|
224
|
+
results: results.sort((a, b) => rank(a.classification) - rank(b.classification) || a.url.localeCompare(b.url)),
|
|
225
|
+
unresolvedAttributeReferences: [...unresolved].map(([url, refs]) => ({ url, refs: [...refs.values()] })),
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
const dir = path.dirname(reportFile)
|
|
229
|
+
if (dir && dir !== '.') fs.mkdirSync(dir, { recursive: true })
|
|
230
|
+
fs.writeFileSync(reportFile, `${JSON.stringify(report, null, 2)}\n`)
|
|
231
|
+
logger.info(`Wrote link check report to ${reportFile}`)
|
|
232
|
+
} catch (error) {
|
|
233
|
+
// The report is an aid for downstream automation; failing the build over
|
|
234
|
+
// it would hide the link findings the build just produced.
|
|
235
|
+
logger.warn(`Could not write link check report to ${reportFile}: ${error.message}`)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function rank (classification) {
|
|
240
|
+
return classification === 'broken' ? 0 : classification === 'unverifiable' ? 1 : 2
|
|
93
241
|
}
|
|
94
242
|
|
|
95
243
|
async function checkUrl (url, { timeout, fetchFn = module.exports._fetch }) {
|
|
@@ -130,3 +278,4 @@ async function runWithConcurrency (items, limit, task) {
|
|
|
130
278
|
module.exports._fetch = (...args) => fetch(...args)
|
|
131
279
|
module.exports.checkUrl = checkUrl
|
|
132
280
|
module.exports.collectExternalUrls = collectExternalUrls
|
|
281
|
+
module.exports.repoSlug = repoSlug
|
|
@@ -483,6 +483,11 @@ function convertContent (content, resolverContext) {
|
|
|
483
483
|
continue
|
|
484
484
|
}
|
|
485
485
|
if (!hostnames.has(url.hostname) || match.inAttributeEntry || match.inAttributeValue) continue
|
|
486
|
+
// The path still holds a literal {attribute} reference: attributes are
|
|
487
|
+
// substituted after this hook, so the real target is unknown here. It
|
|
488
|
+
// cannot be looked up in the catalog, and reporting it as unmatched would
|
|
489
|
+
// be wrong when the substituted URL resolves fine.
|
|
490
|
+
if (match.hasAttributeReference) continue
|
|
486
491
|
if (ignore.some((pattern) => pattern.test(url.pathname))) continue
|
|
487
492
|
// An xref target cannot carry a query string, and dropping one would
|
|
488
493
|
// change the link: the docs UI reads parameters such as
|
|
@@ -2,12 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Scans AsciiDoc content for http(s) URLs that appear in prose, skipping
|
|
5
|
-
* URLs
|
|
5
|
+
* URLs that never reach a rendered page, or that live in a code context where
|
|
6
|
+
* rewriting or checking them would be wrong:
|
|
6
7
|
*
|
|
7
|
-
* - delimited listing (----), literal (....),
|
|
8
|
-
* (
|
|
8
|
+
* - delimited listing (----), literal (....), passthrough (++++) and comment
|
|
9
|
+
* (////) blocks
|
|
10
|
+
* - fenced blocks (```), including a language info string (```bash, ```yml).
|
|
11
|
+
* Asciidoctor treats the info string as part of the delimiter, so a fence
|
|
12
|
+
* opened as ```bash still closes on a bare ``` line
|
|
13
|
+
*
|
|
14
|
+
* A block closes only on a delimiter of the same length as the one that opened
|
|
15
|
+
* it; a longer run, such as the row of dashes in a rendered table, is content.
|
|
16
|
+
* - line comments (//), which Asciidoctor drops entirely
|
|
9
17
|
* - inline code spans (`...`)
|
|
10
18
|
*
|
|
19
|
+
* URLs holding an unresolved attribute reference (https://github.com/{project-github}/...)
|
|
20
|
+
* are returned with hasAttributeReference set, because a caller cannot check
|
|
21
|
+
* or rewrite one: this scanner runs at contentClassified, before Asciidoctor
|
|
22
|
+
* substitutes attributes, so the braces are still literal here even though the
|
|
23
|
+
* published URL is fine.
|
|
24
|
+
*
|
|
11
25
|
* Each match records enough position information for a caller to splice a
|
|
12
26
|
* replacement into the original content.
|
|
13
27
|
*
|
|
@@ -26,6 +40,8 @@
|
|
|
26
40
|
* example image:d.png[alt,link=https://...]); rewriting one of these to an
|
|
27
41
|
* xref macro would corrupt the surrounding macro, but the URL is still a
|
|
28
42
|
* real link target worth checking
|
|
43
|
+
* - hasAttributeReference: true when the URL contains a {name} attribute
|
|
44
|
+
* reference that has not been substituted yet
|
|
29
45
|
*/
|
|
30
46
|
|
|
31
47
|
const URL_RX = /(link:)?(https?:\/\/[^\s\][)"'<>]+)(\[[^\]]*\])?/g
|
|
@@ -34,16 +50,62 @@ const URL_RX = /(link:)?(https?:\/\/[^\s\][)"'<>]+)(\[[^\]]*\])?/g
|
|
|
34
50
|
// is not part of the URL.
|
|
35
51
|
const TRAILING_PUNCT_RX = /[.,;:!?*_]+$/
|
|
36
52
|
const ATTRIBUTE_ENTRY_RX = /^:!?[a-zA-Z0-9_][a-zA-Z0-9_-]*!?:(?:\s|$)/
|
|
53
|
+
// A line comment. Asciidoctor drops these before rendering, so any URL on one
|
|
54
|
+
// is not a link on the published page: generator provenance notes, Doc
|
|
55
|
+
// Detective test steps, and writers' editorial asides all live here.
|
|
56
|
+
const LINE_COMMENT_RX = /^\s*\/\//
|
|
57
|
+
// An attribute reference that substitution has not resolved yet.
|
|
58
|
+
const ATTRIBUTE_REFERENCE_RX = /\{[a-zA-Z0-9_][a-zA-Z0-9_-]*\}/
|
|
37
59
|
// Cap on how far a wrapped label may run, so an unmatched bracket somewhere in
|
|
38
60
|
// prose cannot swallow the rest of the document.
|
|
39
61
|
const MAX_WRAPPED_LABEL_LENGTH = 500
|
|
40
62
|
|
|
63
|
+
// A delimiter run, plus the info string a fence may carry. Comment blocks
|
|
64
|
+
// (////) come first in the alternation so a //// line is not mistaken for the
|
|
65
|
+
// start of a line comment.
|
|
66
|
+
// A markdown-style fence is exactly three backticks: Asciidoctor does not
|
|
67
|
+
// recognise four or more, it reads them as a paragraph. The other
|
|
68
|
+
// delimiters take four or more.
|
|
69
|
+
const BLOCK_DELIMITER_RX = /^(\/{4,}|-{4,}|\.{4,}|`{3}(?!`)|\+{4,})(\S*)$/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parses a block delimiter line, or returns null when the line is not one.
|
|
73
|
+
*
|
|
74
|
+
* Two details of Asciidoctor's rules matter here, and both were verified
|
|
75
|
+
* against @asciidoctor/core rather than assumed:
|
|
76
|
+
*
|
|
77
|
+
* - A fenced block may carry a language info string (```bash, ```yml) on its
|
|
78
|
+
* opening line. Matching only bare backticks meant every annotated code
|
|
79
|
+
* block was scanned as prose, which is where the config defaults
|
|
80
|
+
* (https://api.openai.com/v1) and the truncated shell examples
|
|
81
|
+
* (.../releases/download/v, cut at its <version> placeholder) in the weekly
|
|
82
|
+
* report came from. No other delimiter takes an info string.
|
|
83
|
+
* - A block closes only on a delimiter of the SAME LENGTH. A longer run is
|
|
84
|
+
* content. Treating any run of four or more as a delimiter inverted the
|
|
85
|
+
* open/closed state for the rest of the file whenever a listing block held
|
|
86
|
+
* a line of dashes, which is how command output renders a table rule:
|
|
87
|
+
*
|
|
88
|
+
* [source,sql]
|
|
89
|
+
* ----
|
|
90
|
+
* SELECT ...
|
|
91
|
+
* ---------------------- <- content, not a close
|
|
92
|
+
* row
|
|
93
|
+
* ---- <- the actual close
|
|
94
|
+
*
|
|
95
|
+
* Everything after such a block was then read with the state flipped, so
|
|
96
|
+
* prose was skipped and code was scanned. That is why the example URL in
|
|
97
|
+
* cloud-docs' regexp_match reference was reported as a broken link.
|
|
98
|
+
*/
|
|
41
99
|
function blockDelimiter (line) {
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
return
|
|
100
|
+
const match = BLOCK_DELIMITER_RX.exec(line)
|
|
101
|
+
if (!match) return null
|
|
102
|
+
const [, run, info] = match
|
|
103
|
+
if (info && run[0] !== '`') return null
|
|
104
|
+
return { char: run[0], length: run.length, info }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function closesBlock (delimiter, open) {
|
|
108
|
+
return delimiter.char === open.char && delimiter.length === open.length && !delimiter.info
|
|
47
109
|
}
|
|
48
110
|
|
|
49
111
|
function scanContentUrls (content) {
|
|
@@ -54,10 +116,13 @@ function scanContentUrls (content) {
|
|
|
54
116
|
for (const rawLine of lines) {
|
|
55
117
|
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
|
|
56
118
|
const delimiter = blockDelimiter(line)
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
else
|
|
60
|
-
|
|
119
|
+
if (openDelimiter) {
|
|
120
|
+
// Inside a block, only its own matching delimiter gets out. Anything
|
|
121
|
+
// else on this line is content and is never scanned.
|
|
122
|
+
if (delimiter && closesBlock(delimiter, openDelimiter)) openDelimiter = null
|
|
123
|
+
} else if (delimiter) {
|
|
124
|
+
openDelimiter = delimiter
|
|
125
|
+
} else if (!LINE_COMMENT_RX.test(line)) {
|
|
61
126
|
scanLine(line, offset, matches, content)
|
|
62
127
|
}
|
|
63
128
|
offset += rawLine.length + 1
|
|
@@ -114,6 +179,7 @@ function scanLine (line, offset, matches, content) {
|
|
|
114
179
|
hasLinkPrefix: Boolean(linkPrefix),
|
|
115
180
|
inAttributeEntry,
|
|
116
181
|
inAttributeValue: /[\w-]=["']?$/.test(before),
|
|
182
|
+
hasAttributeReference: ATTRIBUTE_REFERENCE_RX.test(url),
|
|
117
183
|
})
|
|
118
184
|
}
|
|
119
185
|
}
|
|
@@ -218,10 +218,15 @@ module.exports.register = function ({ config }) {
|
|
|
218
218
|
return version.replace(/^v/, '');
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
// Helper function to update multiple attributes based on a list of mappings
|
|
221
|
+
// Helper function to update multiple attributes based on a list of mappings.
|
|
222
|
+
// Gated on the value, not just the condition: gating on the enclosing object
|
|
223
|
+
// alone assigned `undefined` whenever the fetch returned an object without
|
|
224
|
+
// the field, and that clobbered the antora.yml fallback instead of falling
|
|
225
|
+
// back to it. The visible symptom was a literal {latest-operator-version} in
|
|
226
|
+
// the published release-notes pages.
|
|
222
227
|
function updateAttributes(asciidoc, mappings) {
|
|
223
228
|
mappings.forEach(({ condition, key, value }) => {
|
|
224
|
-
if (condition) {
|
|
229
|
+
if (condition && value) {
|
|
225
230
|
asciidoc.attributes[key] = value;
|
|
226
231
|
setShortVersionAttribute(asciidoc, key, value);
|
|
227
232
|
}
|
package/package.json
CHANGED
|
@@ -50,6 +50,13 @@ function routeFile (file) {
|
|
|
50
50
|
* @returns {Map<string, Set<number>>}
|
|
51
51
|
*/
|
|
52
52
|
function getChangedLines (repo, base) {
|
|
53
|
+
return parseUnifiedDiff(runGitDiff(repo, base))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run `git diff <base>...HEAD` and return the raw text.
|
|
58
|
+
*/
|
|
59
|
+
function runGitDiff (repo, base) {
|
|
53
60
|
const result = spawnSync(
|
|
54
61
|
'git',
|
|
55
62
|
['diff', '--unified=0', '--no-color', `${base}...HEAD`],
|
|
@@ -59,7 +66,17 @@ function getChangedLines (repo, base) {
|
|
|
59
66
|
if (result.status !== 0) {
|
|
60
67
|
throw new Error(`git diff ${base}...HEAD failed in ${repo}: ${result.stderr || result.stdout}`)
|
|
61
68
|
}
|
|
62
|
-
return
|
|
69
|
+
return result.stdout
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Both sides of the diff from a single git invocation.
|
|
74
|
+
*
|
|
75
|
+
* @returns {{ changed: Map<string, Set<number>>, removed: Map<string, Set<number>> }}
|
|
76
|
+
*/
|
|
77
|
+
function getDiffLines (repo, base) {
|
|
78
|
+
const diffText = runGitDiff(repo, base)
|
|
79
|
+
return { changed: parseUnifiedDiff(diffText), removed: parseUnifiedDiffRemovals(diffText) }
|
|
63
80
|
}
|
|
64
81
|
|
|
65
82
|
/**
|
|
@@ -98,6 +115,53 @@ function parseUnifiedDiff (diffText) {
|
|
|
98
115
|
return changed
|
|
99
116
|
}
|
|
100
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Parse unified diff text into file -> Set(deleted PRE-image line numbers),
|
|
120
|
+
* keyed by the old path. This is the half parseUnifiedDiff deliberately throws
|
|
121
|
+
* away, and on its own it is what keeps a deletion-only PR visible.
|
|
122
|
+
*
|
|
123
|
+
* Why it is needed: declarations are extracted from HEAD, so a removed
|
|
124
|
+
* property, metric or command cannot be extracted at all, and a pure-deletion
|
|
125
|
+
* hunk contributes no post-image lines to anchor on. Without the old side, a
|
|
126
|
+
* PR that only deletes doc-string declarations reports zero declarations and
|
|
127
|
+
* every downstream review step skips, precisely when a removed public surface
|
|
128
|
+
* is the most doc-relevant change a PR can make.
|
|
129
|
+
*
|
|
130
|
+
* These line numbers are a signal that a doc-string surface lost content, not
|
|
131
|
+
* an extraction: naming them as declarations would overstate what a diff alone
|
|
132
|
+
* can tell us.
|
|
133
|
+
*/
|
|
134
|
+
function parseUnifiedDiffRemovals (diffText) {
|
|
135
|
+
const removed = new Map()
|
|
136
|
+
const lines = diffText.split('\n')
|
|
137
|
+
let oldFile = null
|
|
138
|
+
|
|
139
|
+
for (let i = 0; i < lines.length; i++) {
|
|
140
|
+
const line = lines[i]
|
|
141
|
+
// git always emits the ---/+++ header pair on consecutive lines, so
|
|
142
|
+
// requiring the partner keeps a deleted source line that happens to start
|
|
143
|
+
// with "-- " from being misread as a header (the mirror of the "++ "
|
|
144
|
+
// hazard noted in parseUnifiedDiff).
|
|
145
|
+
if (line.startsWith('--- ') && (lines[i + 1] || '').startsWith('+++ ')) {
|
|
146
|
+
oldFile = line.startsWith('--- a/') ? line.slice(6).trim() : null
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
if (!line.startsWith('@@') || oldFile === null) continue
|
|
150
|
+
|
|
151
|
+
const hunk = line.match(/^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@/)
|
|
152
|
+
if (!hunk) continue
|
|
153
|
+
const start = parseInt(hunk[1], 10)
|
|
154
|
+
const count = hunk[2] === undefined ? 1 : parseInt(hunk[2], 10)
|
|
155
|
+
if (count === 0) continue // pure addition: nothing removed
|
|
156
|
+
|
|
157
|
+
if (!removed.has(oldFile)) removed.set(oldFile, new Set())
|
|
158
|
+
const lineSet = removed.get(oldFile)
|
|
159
|
+
for (let i2 = 0; i2 < count; i2++) lineSet.add(start + i2)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return removed
|
|
163
|
+
}
|
|
164
|
+
|
|
101
165
|
/**
|
|
102
166
|
* Group changed files by surface.
|
|
103
167
|
*
|
|
@@ -131,7 +195,9 @@ module.exports = {
|
|
|
131
195
|
SURFACE_ROUTES,
|
|
132
196
|
routeFile,
|
|
133
197
|
getChangedLines,
|
|
198
|
+
getDiffLines,
|
|
134
199
|
parseUnifiedDiff,
|
|
200
|
+
parseUnifiedDiffRemovals,
|
|
135
201
|
classifyDiff,
|
|
136
202
|
spanIntersects
|
|
137
203
|
}
|
|
@@ -99,6 +99,12 @@ function emptySummary () {
|
|
|
99
99
|
return {
|
|
100
100
|
totalDeclarations: 0,
|
|
101
101
|
flaggedDeclarations: 0,
|
|
102
|
+
// Deletions on the old side of the diff, in files that route to a
|
|
103
|
+
// doc-string surface. Only populated in diff mode. See
|
|
104
|
+
// parseUnifiedDiffRemovals in ./diff.js for why a removed declaration
|
|
105
|
+
// cannot be counted in totalDeclarations.
|
|
106
|
+
removedSurfaceLines: 0,
|
|
107
|
+
removedSurfaceFiles: [],
|
|
102
108
|
errors: 0,
|
|
103
109
|
warnings: 0,
|
|
104
110
|
info: 0,
|
|
@@ -4,7 +4,7 @@ const fs = require('fs')
|
|
|
4
4
|
const path = require('path')
|
|
5
5
|
|
|
6
6
|
const { runRules, mergeResults } = require('./engine')
|
|
7
|
-
const {
|
|
7
|
+
const { getDiffLines, classifyDiff, spanIntersects, SURFACE_ROUTES } = require('./diff')
|
|
8
8
|
const { COMMON_RULES } = require('./rules/common')
|
|
9
9
|
const { VERBATIM_ASCIIDOC_RULES } = require('./rules/verbatim-asciidoc')
|
|
10
10
|
|
|
@@ -82,10 +82,12 @@ function lintStrings (options) {
|
|
|
82
82
|
|
|
83
83
|
const results = []
|
|
84
84
|
const unsupportedSurfaces = []
|
|
85
|
+
let removedSurfaceFiles = []
|
|
85
86
|
|
|
86
87
|
if (diffBase) {
|
|
87
|
-
const changed =
|
|
88
|
+
const { changed, removed } = getDiffLines(repoPath, diffBase)
|
|
88
89
|
const classified = classifyDiff(changed)
|
|
90
|
+
removedSurfaceFiles = collectRemovals(removed, surfaces)
|
|
89
91
|
|
|
90
92
|
for (const [surfaceName, files] of Object.entries(classified)) {
|
|
91
93
|
if (!SURFACES[surfaceName]) {
|
|
@@ -119,9 +121,33 @@ function lintStrings (options) {
|
|
|
119
121
|
merged.findings.sort((a, b) =>
|
|
120
122
|
a.surface.localeCompare(b.surface) || a.file.localeCompare(b.file) || (a.line_start || 0) - (b.line_start || 0))
|
|
121
123
|
merged.unsupported_surfaces = unsupportedSurfaces
|
|
124
|
+
merged.summary.removedSurfaceFiles = removedSurfaceFiles
|
|
125
|
+
merged.summary.removedSurfaceLines = removedSurfaceFiles.reduce((sum, entry) => sum + entry.lines, 0)
|
|
122
126
|
return merged
|
|
123
127
|
}
|
|
124
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Deletions in files that route to a doc-string surface, so a deletion-only PR
|
|
131
|
+
* still registers as touching a surface. Reported separately from
|
|
132
|
+
* totalDeclarations because a removed declaration is absent from HEAD and
|
|
133
|
+
* therefore cannot be extracted or linted.
|
|
134
|
+
*
|
|
135
|
+
* Surfaces routed without a registered extractor are included: the gate cares
|
|
136
|
+
* that documented content went away, not whether we can lint what replaced it.
|
|
137
|
+
*
|
|
138
|
+
* @param {Map<string, Set<number>>} removed - From getDiffLines().removed
|
|
139
|
+
* @param {string[]|null} surfaces - Explicit --surface narrowing, if any
|
|
140
|
+
*/
|
|
141
|
+
function collectRemovals (removed, surfaces) {
|
|
142
|
+
const only = surfaces && surfaces.length > 0 ? new Set(surfaces) : null
|
|
143
|
+
const entries = []
|
|
144
|
+
for (const [surface, files] of Object.entries(classifyDiff(removed))) {
|
|
145
|
+
if (only && !only.has(surface)) continue
|
|
146
|
+
for (const [file, lines] of files) entries.push({ surface, file, lines: lines.size })
|
|
147
|
+
}
|
|
148
|
+
return entries.sort((a, b) => a.surface.localeCompare(b.surface) || a.file.localeCompare(b.file))
|
|
149
|
+
}
|
|
150
|
+
|
|
125
151
|
/**
|
|
126
152
|
* Human-readable report (the default --format).
|
|
127
153
|
*/
|
|
@@ -148,6 +174,10 @@ function formatHuman (result) {
|
|
|
148
174
|
lines.push('='.repeat(60))
|
|
149
175
|
lines.push(`Declarations checked: ${summary.totalDeclarations}`)
|
|
150
176
|
lines.push(`Declarations flagged: ${summary.flaggedDeclarations}`)
|
|
177
|
+
if (summary.removedSurfaceLines) {
|
|
178
|
+
lines.push(`Lines deleted from doc-string surfaces: ${summary.removedSurfaceLines} ` +
|
|
179
|
+
`(${summary.removedSurfaceFiles.length} file(s); removed declarations cannot be extracted from HEAD)`)
|
|
180
|
+
}
|
|
151
181
|
lines.push(`Errors: ${summary.errors} Warnings: ${summary.warnings} Info: ${summary.info}`)
|
|
152
182
|
if (Object.keys(summary.byRule).length > 0) {
|
|
153
183
|
lines.push('\nBy rule:')
|