@redpanda-data/docs-extensions-and-macros 5.9.0 → 5.11.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.
@@ -485,6 +485,97 @@ antora:
485
485
  - require: '@redpanda-data/docs-extensions-and-macros/extensions/set-available-attachment-versions'
486
486
  ----
487
487
 
488
+ == URL to xref
489
+
490
+ This extension converts absolute docs.redpanda.com URLs found in page and partial content into Antora xrefs, using the content catalog as the single source of truth. A URL is converted only when it maps to a page that is actually published in the current build, so the extension never guesses at URL structure. Converted links are validated by Antora's own xref resolution, and internal URLs that match no published page are logged as warnings — which makes this extension a broken-internal-link detector for the whole site.
491
+
492
+ Behavior details:
493
+
494
+ * Runs at `contentClassified`, before AsciiDoc conversion, so emitted xrefs flow through Antora's normal resolution and broken-xref logging.
495
+ * URLs inside listing, literal, fenced, and passthrough blocks, inline code spans, attribute entry lines, and macro attribute values (such as `link=` on an image macro) are left untouched.
496
+ * Legacy URL shapes (`/docs/...`, `/current/...`, `/vX.Y/...`, and pre-rename component slugs such as `/redpanda-connect/`) are rewritten to candidate paths, but a candidate is only used when it matches a published page.
497
+ * Fragments (`#anchor`) and link labels are preserved, including a label that a generator wrapped across a line break, whose line breaks are collapsed to single spaces.
498
+ * An unlabeled URL becomes `xref:...[]` so that Antora fills in the target page title. An unlabeled URL that carries a fragment is given link text explicitly, because Antora cannot resolve a section title and would render the raw resource ID as the link text: the extension uses the heading the fragment points at, falls back to the target page title, and leaves the URL as a raw link if it can read neither. Heading ids are also read from the partials a target page includes, because the reference pages that hold most anchor targets are thin wrappers around generated partials.
499
+ * Targets in the latest version of a component emit an unversioned xref; targets pinned to an older version emit `version@component:module:page`.
500
+ * A URL that points at a page's former path resolves through the `page-aliases` attribute that the page declares. Antora does not register those aliases as catalog files until it converts documents, which is after this extension runs, so the extension reads them from the page header. Only aliases in the same component and version as the page that declares them are mapped.
501
+ * A URL that carries a query string is left as a raw link, because an xref cannot express one and dropping it would change the link (for example `?platform=kubernetes`, which the UI reads to preselect a tab). These are reported at info level, not as warnings.
502
+ * Builds that publish the latest version under its real version number instead of a symbolic segment (such as this repository's preview build) still resolve `/current/...` URLs, so the same content works in both.
503
+
504
+ === Environment variables
505
+
506
+ This extension does not require any environment variables.
507
+
508
+ === Configuration options
509
+
510
+ hostnames (optional)::
511
+ List of hostnames treated as internal documentation URLs. Default: `['docs.redpanda.com']`.
512
+
513
+ ignore (optional)::
514
+ List of regular expressions matched against URL paths. Matching URLs are left untouched with no warning. Use this for paths on the docs domain that live outside the Antora catalog. Default: `['^/api/', '^/mcp(/|$)']` (the API reference is hosted by Bump.sh, and `/mcp` is the docs MCP server endpoint).
515
+
516
+ log_unconverted (optional)::
517
+ Whether to log a warning for each internal URL that matches no published page. Default: `true`.
518
+
519
+ === Registration
520
+
521
+ Register the `url-to-xref` extension in the Antora playbook under the `antora.extensions` key like so:
522
+
523
+ [source,yaml]
524
+ ----
525
+ antora:
526
+ extensions:
527
+ - require: '@redpanda-data/docs-extensions-and-macros/extensions/url-to-xref'
528
+ ----
529
+
530
+ == External link checker
531
+
532
+ This extension collects every external http(s) URL referenced in page and partial content and verifies that it responds, reporting dead links in the build log. Internal links are covered by the url-to-xref extension plus Antora's own xref validation; this extension covers everything that points off-site.
533
+
534
+ Because it performs network requests, register it only in builds where that belongs (for example a scheduled nightly build), not necessarily on every preview build.
535
+
536
+ Responses are classified as follows:
537
+
538
+ * 2xx/3xx: ok
539
+ * 404/410: broken — logged as a warning, or at error level with `fail_on_broken`
540
+ * 401/403/429: unverifiable — logged at info level, because bot-walled or rate-limited sites are not broken
541
+ * Other statuses, network errors, and timeouts (after one retry): unverifiable — logged as a warning
542
+
543
+ === Environment variables
544
+
545
+ This extension does not require any environment variables.
546
+
547
+ === Configuration options
548
+
549
+ internal_hostnames (optional)::
550
+ Hostnames to skip because they are internal documentation URLs. Default: `['docs.redpanda.com']`.
551
+
552
+ include (optional)::
553
+ List of regular expressions. When set, only URLs matching at least one pattern are checked.
554
+
555
+ exclude (optional)::
556
+ List of regular expressions. URLs matching any pattern are skipped.
557
+
558
+ concurrency (optional)::
559
+ Number of simultaneous requests. Default: `10`.
560
+
561
+ timeout (optional)::
562
+ Per-request timeout in milliseconds. Default: `10000`.
563
+
564
+ fail_on_broken (optional)::
565
+ Log broken links at error level so a build run with `--log-failure-level=error` fails. Default: `false`.
566
+
567
+ === Registration
568
+
569
+ Register the `external-link-checker` extension in the Antora playbook under the `antora.extensions` key like so:
570
+
571
+ [source,yaml]
572
+ ----
573
+ antora:
574
+ extensions:
575
+ - require: '@redpanda-data/docs-extensions-and-macros/extensions/external-link-checker'
576
+ fail_on_broken: false
577
+ ----
578
+
488
579
  == Validate attributes
489
580
 
490
581
  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.
@@ -0,0 +1,132 @@
1
+ 'use strict'
2
+
3
+ const { scanContentUrls } = require('./util/scan-content-urls')
4
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
5
+
6
+ /**
7
+ * External Link Checker Extension
8
+ *
9
+ * Collects every external http(s) URL referenced in page and partial content
10
+ * and verifies it responds, reporting dead links in the build log. Internal
11
+ * links are covered by the url-to-xref extension plus Antora's own xref
12
+ * validation; this extension covers everything that points off-site.
13
+ *
14
+ * Because it performs network requests, register it only where that belongs
15
+ * (for example a nightly build), not necessarily on every preview build:
16
+ *
17
+ * antora:
18
+ * extensions:
19
+ * - require: '@redpanda-data/docs-extensions-and-macros/extensions/external-link-checker'
20
+ * # internal_hostnames: ['docs.redpanda.com'] hosts to skip (default)
21
+ * # include: [] regex allowlist; when set, only matching URLs are checked
22
+ * # exclude: [] regex denylist
23
+ * # concurrency: 10 simultaneous requests
24
+ * # timeout: 10000 per-request timeout in ms
25
+ * # fail_on_broken: false log broken links at error level so a build
26
+ * # run with --log-failure-level=error fails
27
+ *
28
+ * Classification:
29
+ * - 2xx/3xx: ok
30
+ * - 404/410: broken (warn, or error with fail_on_broken)
31
+ * - 401/403/429: unverifiable (info) — bot-walled or rate-limited, not broken
32
+ * - other statuses, network errors, timeouts (after one retry): unverifiable (warn)
33
+ */
34
+
35
+ const USER_AGENT = 'redpanda-docs-link-checker'
36
+
37
+ module.exports.register = function ({ config = {} }) {
38
+ raiseListenerLimit(this)
39
+ const logger = this.getLogger('external-link-checker-extension')
40
+ const internalHostnames = new Set(config.internalHostnames || ['docs.redpanda.com'])
41
+ const include = (config.include || []).map((pattern) => new RegExp(pattern))
42
+ const exclude = (config.exclude || []).map((pattern) => new RegExp(pattern))
43
+ const concurrency = config.concurrency || 10
44
+ const timeout = config.timeout || 10000
45
+ const failOnBroken = Boolean(config.failOnBroken)
46
+
47
+ 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
+ }
63
+ })
64
+ logger.info(
65
+ `Checked ${urlReferences.size} external links: ${counts.ok} ok, ${counts.broken} broken, ` +
66
+ `${counts.unverifiable} unverifiable`
67
+ )
68
+ })
69
+ }
70
+
71
+ function collectExternalUrls (contentCatalog, { internalHostnames, include, exclude }) {
72
+ const urlReferences = new Map()
73
+ const files = contentCatalog
74
+ .getPages((page) => page.out)
75
+ .concat(contentCatalog.findBy({ family: 'partial' }))
76
+ for (const file of files) {
77
+ if (!file.contents) continue
78
+ for (const match of scanContentUrls(file.contents.toString())) {
79
+ let url
80
+ try {
81
+ url = new URL(match.url)
82
+ } catch {
83
+ continue
84
+ }
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)
90
+ }
91
+ }
92
+ return urlReferences
93
+ }
94
+
95
+ async function checkUrl (url, { timeout, fetchFn = module.exports._fetch }) {
96
+ let lastError
97
+ for (const attempt of [{ method: 'HEAD' }, { method: 'GET' }, { method: 'GET', retry: true }]) {
98
+ try {
99
+ const response = await fetchFn(url, {
100
+ method: attempt.method,
101
+ redirect: 'follow',
102
+ headers: { 'user-agent': USER_AGENT },
103
+ signal: AbortSignal.timeout(timeout),
104
+ })
105
+ const status = response.status
106
+ // HEAD rejected or unsupported: many CDNs and bot walls refuse HEAD
107
+ // (403, 404, ...) but answer GET normally, so confirm any HEAD failure
108
+ // with GET before classifying
109
+ if (attempt.method === 'HEAD' && status >= 400) continue
110
+ if (status < 400) return { classification: 'ok', status }
111
+ if (status === 404 || status === 410) return { classification: 'broken', status }
112
+ return { classification: 'unverifiable', status }
113
+ } catch (error) {
114
+ lastError = error
115
+ if (attempt.retry) break
116
+ }
117
+ }
118
+ return { classification: 'unverifiable', status: null, reason: lastError ? lastError.message : 'request failed' }
119
+ }
120
+
121
+ async function runWithConcurrency (items, limit, task) {
122
+ const queue = [...items]
123
+ const workers = Array.from({ length: Math.min(limit, queue.length) }, async () => {
124
+ while (queue.length) await task(queue.shift())
125
+ })
126
+ await Promise.all(workers)
127
+ }
128
+
129
+ // Indirection so tests can stub the HTTP layer without a network.
130
+ module.exports._fetch = (...args) => fetch(...args)
131
+ module.exports.checkUrl = checkUrl
132
+ module.exports.collectExternalUrls = collectExternalUrls