@redpanda-data/docs-extensions-and-macros 5.22.4 → 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.
@@ -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
- This extension does not require any environment variables.
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) — bot-walled or rate-limited, not broken
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, { internalHostnames, include, exclude })
49
- if (!urlReferences.size) return
50
- const counts = { ok: 0, broken: 0, unverifiable: 0 }
51
- await runWithConcurrency([...urlReferences.keys()], concurrency, async (url) => {
52
- const verdict = await checkUrl(url, { timeout })
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
- logger.info(
65
- `Checked ${urlReferences.size} external links: ${counts.ok} ok, ${counts.broken} broken, ` +
66
- `${counts.unverifiable} unverifiable`
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
- function collectExternalUrls (contentCatalog, { internalHostnames, include, exclude }) {
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
- let url
80
- try {
81
- url = new URL(match.url)
82
- } catch {
83
- continue
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 (internalHostnames.has(url.hostname)) continue
86
- if (include.length && !include.some((pattern) => pattern.test(match.url))) continue
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 inside code contexts where rewriting or checking them would be wrong:
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 (....), fenced (```), and passthrough
8
- * (++++) blocks
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
- if (/^-{4,}$/.test(line)) return '-'
43
- if (/^\.{4,}$/.test(line)) return '.'
44
- if (/^`{3,}$/.test(line)) return '`'
45
- if (/^\+{4,}$/.test(line)) return '+'
46
- return null
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 (delimiter) {
58
- if (openDelimiter === delimiter) openDelimiter = null
59
- else if (!openDelimiter) openDelimiter = delimiter
60
- } else if (!openDelimiter) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.22.4",
3
+ "version": "5.29.0",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -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 parseUnifiedDiff(result.stdout)
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 { getChangedLines, classifyDiff, spanIntersects, SURFACE_ROUTES } = require('./diff')
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 = getChangedLines(repoPath, diffBase)
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:')
@@ -1117,6 +1117,49 @@ def apply_property_overrides(properties, overrides, overrides_file_path=None):
1117
1117
  return properties
1118
1118
 
1119
1119
 
1120
+ ADMONITION_TYPES = {"note", "tip", "important", "warning", "caution"}
1121
+
1122
+
1123
+ def _normalize_admonitions(admonitions):
1124
+ """Validate an admonitions override and normalize each entry's `type`
1125
+ to the uppercase form AsciiDoc's admonition block syntax expects
1126
+ (`[NOTE]`, not `[note]`). Returns None (logging a warning) if the
1127
+ override isn't shaped as a list of {type, text} entries, so a malformed
1128
+ override doesn't silently render as nothing instead of surfacing the
1129
+ mistake.
1130
+ """
1131
+ if not isinstance(admonitions, list):
1132
+ logger.warning("admonitions for property must be an array")
1133
+ return None
1134
+ normalized = []
1135
+ for entry in admonitions:
1136
+ if (
1137
+ not isinstance(entry, dict)
1138
+ or not isinstance(entry.get("type"), str)
1139
+ or not isinstance(entry.get("text"), str)
1140
+ ):
1141
+ logger.warning(
1142
+ f"admonitions entry must contain string type and text values, got: {entry!r}"
1143
+ )
1144
+ continue
1145
+ entry_type = str(entry["type"]).strip().lower()
1146
+ if entry_type not in ADMONITION_TYPES:
1147
+ logger.warning(
1148
+ f"admonitions entry has unknown type '{entry['type']}', "
1149
+ f"expected one of {sorted(ADMONITION_TYPES)}"
1150
+ )
1151
+ continue
1152
+ normalized_entry = {"type": entry_type.upper(), "text": entry["text"]}
1153
+ if "title" in entry:
1154
+ if not isinstance(entry["title"], str):
1155
+ logger.warning(f"admonitions entry title must be a string, got: {entry!r}")
1156
+ continue
1157
+ if entry["title"]:
1158
+ normalized_entry["title"] = entry["title"]
1159
+ normalized.append(normalized_entry)
1160
+ return normalized
1161
+
1162
+
1120
1163
  def _apply_override_to_existing_property(property_dict, override, overrides_file_path):
1121
1164
  """Apply overrides to an existing property."""
1122
1165
  # Apply description override
@@ -1178,6 +1221,16 @@ def _apply_override_to_existing_property(property_dict, override, overrides_file
1178
1221
  else:
1179
1222
  logger.warning(f"accepted_values for property must be an array")
1180
1223
 
1224
+ # Apply admonitions override - structured NOTE/TIP/IMPORTANT/WARNING/CAUTION
1225
+ # callouts, rendered as real AsciiDoc admonition blocks by property.hbs/
1226
+ # topic-property.hbs. Exists so a callout never has to be hand-written as
1227
+ # markup mixed into the description text -- see minimize-overrides
1228
+ # guidance: an admonition is structured data, not embedded prose markup.
1229
+ if "admonitions" in override:
1230
+ normalized = _normalize_admonitions(override["admonitions"])
1231
+ if normalized is not None:
1232
+ property_dict["admonitions"] = normalized
1233
+
1181
1234
 
1182
1235
  def _create_property_from_override(prop_name, override, overrides_file_path):
1183
1236
  """Create a new property from override specification."""
@@ -1222,7 +1275,7 @@ def _create_property_from_override(prop_name, override, overrides_file_path):
1222
1275
  if key not in ["description", "type", "default", "config_scope", "version",
1223
1276
  "example", "example_file", "example_yaml", "related_topics",
1224
1277
  "see_also", "is_deprecated", "visibility", "exclude_from_docs",
1225
- "category", "accepted_values", "_comment"]:
1278
+ "category", "accepted_values", "admonitions", "_comment"]:
1226
1279
  new_property[key] = value
1227
1280
 
1228
1281
  # Add exclude_from_docs if specified
@@ -1240,6 +1293,14 @@ def _create_property_from_override(prop_name, override, overrides_file_path):
1240
1293
  else:
1241
1294
  logger.warning(f"accepted_values for property '{prop_name}' must be an array")
1242
1295
 
1296
+ # Add admonitions if specified - run through the same validation/normalization
1297
+ # as the existing-property path so a malformed entry is dropped with a warning
1298
+ # rather than silently rendering as an unstyled, lowercase-typed block.
1299
+ if "admonitions" in override:
1300
+ normalized = _normalize_admonitions(override["admonitions"])
1301
+ if normalized is not None:
1302
+ new_property["admonitions"] = normalized
1303
+
1243
1304
  return new_property
1244
1305
 
1245
1306
 
@@ -27,6 +27,18 @@ endif::[]
27
27
 
28
28
  No description available.
29
29
 
30
+ {{/if}}
31
+ {{#if admonitions}}
32
+ {{#each admonitions}}
33
+
34
+ {{#if title}}
35
+ .{{{title}}}
36
+ {{/if}}
37
+ [{{type}}]
38
+ ====
39
+ {{{text}}}
40
+ ====
41
+ {{/each}}
30
42
  {{/if}}
31
43
  {{#if is_enterprise}}
32
44
 
@@ -25,6 +25,18 @@
25
25
 
26
26
  No description available.
27
27
 
28
+ {{/if}}
29
+ {{#if admonitions}}
30
+ {{#each admonitions}}
31
+
32
+ {{#if title}}
33
+ .{{{title}}}
34
+ {{/if}}
35
+ [{{type}}]
36
+ ====
37
+ {{{text}}}
38
+ ====
39
+ {{/each}}
28
40
  {{/if}}
29
41
  {{#if is_enterprise}}
30
42