@redpanda-data/docs-extensions-and-macros 5.9.0 → 5.10.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 +4 -62
- package/cli-utils/format-helm-spec.js +58 -0
- package/extensions/README.adoc +4 -0
- package/extensions/REFERENCE.adoc +91 -0
- package/extensions/external-link-checker.js +132 -0
- package/extensions/url-to-xref.js +515 -0
- package/extensions/util/scan-content-urls.js +121 -0
- package/package.json +7 -1
- package/tools/rpk-docs/validate-output.js +4 -3
- package/cli-utils/convert-doc-links.js +0 -58
package/bin/doc-tools.js
CHANGED
|
@@ -34,7 +34,6 @@ const {
|
|
|
34
34
|
// Import other utilities
|
|
35
35
|
const { determineDocsBranch } = require('../cli-utils/self-managed-docs-branch.js')
|
|
36
36
|
const fetchFromGithub = require('../tools/fetch-from-github.js')
|
|
37
|
-
const { urlToXref } = require('../cli-utils/convert-doc-links.js')
|
|
38
37
|
const { getAntoraValue, setAntoraValue } = require('../cli-utils/antora-utils')
|
|
39
38
|
|
|
40
39
|
// --------------------------------------------------------------------
|
|
@@ -1677,47 +1676,8 @@ automation
|
|
|
1677
1676
|
r = spawnSync('pandoc', [md, '-t', 'asciidoc', '-o', outFile], { stdio: 'inherit' })
|
|
1678
1677
|
if (r.status !== 0) process.exit(r.status)
|
|
1679
1678
|
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
doc = doc
|
|
1683
|
-
.replace(/(\[\d+\])\]\./g, '$1\\].')
|
|
1684
|
-
.replace(/(\[\d+\])\]\]/g, '$1\\]\\]')
|
|
1685
|
-
.replace(/^=== +(https?:\/\/[^\[]*)\[([^\]]*)\]/gm, '=== link:++$1++[$2]')
|
|
1686
|
-
.replace(/^== # (.*)$/gm, '= $1')
|
|
1687
|
-
.replace(/^== description: (.*)$/gm, ':description: $1')
|
|
1688
|
-
.replace(xrefRe, (match) => {
|
|
1689
|
-
let urlPart = match
|
|
1690
|
-
let bracketPart = ''
|
|
1691
|
-
const m = match.match(/^([^\[]+)(\[[^\]]*\])$/)
|
|
1692
|
-
if (m) {
|
|
1693
|
-
urlPart = m[1]
|
|
1694
|
-
bracketPart = m[2]
|
|
1695
|
-
}
|
|
1696
|
-
if (urlPart.endsWith('#')) return match
|
|
1697
|
-
try {
|
|
1698
|
-
const xref = urlToXref(urlPart)
|
|
1699
|
-
return bracketPart ? `${xref}${bracketPart}` : `${xref}[]`
|
|
1700
|
-
} catch (err) {
|
|
1701
|
-
console.warn(`⚠️ urlToXref failed on ${urlPart}: ${err.message}`)
|
|
1702
|
-
return match
|
|
1703
|
-
}
|
|
1704
|
-
})
|
|
1705
|
-
// Antora resolves page metadata with a header-only parse that stops at
|
|
1706
|
-
// the first blank line. The chart README templates emit :description:
|
|
1707
|
-
// below the title's blank line, so the page shipped the generic site
|
|
1708
|
-
// meta description despite carrying one (found on k-connect-helm-spec,
|
|
1709
|
-
// DOC-2414 investigation). Relocate the first :description: into the
|
|
1710
|
-
// header, directly under the title.
|
|
1711
|
-
const descMatch = doc.match(/^:description:[^\n]*$/m)
|
|
1712
|
-
if (descMatch) {
|
|
1713
|
-
const headerEnd = doc.indexOf('\n\n')
|
|
1714
|
-
if (headerEnd !== -1 && doc.indexOf(descMatch[0]) > headerEnd) {
|
|
1715
|
-
doc = doc.replace(descMatch[0] + '\n', '')
|
|
1716
|
-
doc = doc.replace(/^(= .+)$/m, `$1\n${descMatch[0]}`)
|
|
1717
|
-
doc = doc.replace(/\n{3,}/g, '\n\n')
|
|
1718
|
-
}
|
|
1719
|
-
}
|
|
1720
|
-
fs.writeFileSync(outFile, doc, 'utf8')
|
|
1679
|
+
const { formatHelmSpec } = require('../cli-utils/format-helm-spec')
|
|
1680
|
+
fs.writeFileSync(outFile, formatHelmSpec(fs.readFileSync(outFile, 'utf8')), 'utf8')
|
|
1721
1681
|
|
|
1722
1682
|
console.log(`Done: Wrote ${outFile}`)
|
|
1723
1683
|
}
|
|
@@ -1989,26 +1949,8 @@ automation
|
|
|
1989
1949
|
process.exit(1)
|
|
1990
1950
|
}
|
|
1991
1951
|
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
doc = doc.replace(xrefRe, (match) => {
|
|
1995
|
-
let urlPart = match
|
|
1996
|
-
let bracketPart = ''
|
|
1997
|
-
const m = match.match(/^([^\[]+)(\[[^\]]*\])$/)
|
|
1998
|
-
if (m) {
|
|
1999
|
-
urlPart = m[1]
|
|
2000
|
-
bracketPart = m[2]
|
|
2001
|
-
}
|
|
2002
|
-
if (urlPart.endsWith('#')) return match
|
|
2003
|
-
try {
|
|
2004
|
-
const xref = urlToXref(urlPart)
|
|
2005
|
-
return bracketPart ? `${xref}${bracketPart}` : `${xref}[]`
|
|
2006
|
-
} catch (err) {
|
|
2007
|
-
console.warn(`⚠️ urlToXref failed on ${urlPart}: ${err.message}`)
|
|
2008
|
-
return match
|
|
2009
|
-
}
|
|
2010
|
-
})
|
|
2011
|
-
fs.writeFileSync(opts.output, doc, 'utf8')
|
|
1952
|
+
// docs.redpanda.com URLs are left as-is: the url-to-xref Antora
|
|
1953
|
+
// extension converts them to validated xrefs at site build time.
|
|
2012
1954
|
|
|
2013
1955
|
if (tmpSrc) fs.rmSync(tmpSrc, { recursive: true, force: true })
|
|
2014
1956
|
fs.rmSync(configTmp, { recursive: true, force: true })
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cleans up the AsciiDoc that pandoc produces from a chart's helm-docs README
|
|
5
|
+
* so that it renders correctly as a docs page.
|
|
6
|
+
*
|
|
7
|
+
* docs.redpanda.com URLs are deliberately left as they are: the url-to-xref
|
|
8
|
+
* Antora extension converts them to validated xrefs at site build time.
|
|
9
|
+
*/
|
|
10
|
+
function formatHelmSpec (doc) {
|
|
11
|
+
const formatted = doc
|
|
12
|
+
// pandoc escapes footnote-style references inconsistently
|
|
13
|
+
.replace(/(\[\d+\])\]\./g, '$1\\].')
|
|
14
|
+
.replace(/(\[\d+\])\]\]/g, '$1\\]\\]')
|
|
15
|
+
// a section title that is a bare URL has to be a link macro to render
|
|
16
|
+
.replace(/^=== +(https?:\/\/[^[]*)\[([^\]]*)\]/gm, '=== link:++$1++[$2]')
|
|
17
|
+
.replace(/^== # (.*)$/gm, '= $1')
|
|
18
|
+
.replace(/^== description: (.*)$/gm, ':description: $1')
|
|
19
|
+
// helm-docs prints each chart dependency's repository verbatim into the
|
|
20
|
+
// Requirements table, and charts in the redpanda-operator monorepo refer
|
|
21
|
+
// to their subcharts by local path (for example the Redpanda chart's
|
|
22
|
+
// console dependency, repository: file://../../console/chart).
|
|
23
|
+
// Asciidoctor auto-links a bare file:// URL, which publishes a link that
|
|
24
|
+
// can never resolve for a reader, so render these as literal code.
|
|
25
|
+
.replace(/(^|[^`])(file:\/\/[^\s|\]`]+)/gm, '$1`$2`')
|
|
26
|
+
|
|
27
|
+
return relocateDescriptionIntoHeader(formatted)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Moves the first `:description:` attribute up into the document header.
|
|
32
|
+
*
|
|
33
|
+
* Antora resolves page metadata with a header-only parse that stops at the
|
|
34
|
+
* first blank line. The chart README templates emit `:description:` below the
|
|
35
|
+
* title's blank line, so the page shipped the generic site meta description
|
|
36
|
+
* despite carrying one of its own (found on k-connect-helm-spec, DOC-2414).
|
|
37
|
+
*
|
|
38
|
+
* The conversion above turns `== description:` into the attribute but leaves it
|
|
39
|
+
* where pandoc put it, so the move has to happen after it.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} doc - AsciiDoc source.
|
|
42
|
+
* @returns {string} Source with the description in the header.
|
|
43
|
+
*/
|
|
44
|
+
function relocateDescriptionIntoHeader (doc) {
|
|
45
|
+
const descMatch = doc.match(/^:description:[^\n]*$/m)
|
|
46
|
+
if (!descMatch) return doc
|
|
47
|
+
|
|
48
|
+
const headerEnd = doc.indexOf('\n\n')
|
|
49
|
+
// Already inside the header, so there is nothing to move.
|
|
50
|
+
if (headerEnd === -1 || doc.indexOf(descMatch[0]) < headerEnd) return doc
|
|
51
|
+
|
|
52
|
+
return doc
|
|
53
|
+
.replace(descMatch[0] + '\n', '')
|
|
54
|
+
.replace(/^(= .+)$/m, `$1\n${descMatch[0]}`)
|
|
55
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { formatHelmSpec }
|
package/extensions/README.adoc
CHANGED
|
@@ -89,6 +89,10 @@ IMPORTANT: Extensions must be registered under the `antora.extensions` key in yo
|
|
|
89
89
|
* **set-available-attachment-versions** - Point UI templates at the newest generated JSON attachments that exist in the catalog
|
|
90
90
|
* **validate-attributes** - Validate AsciiDoc attributes
|
|
91
91
|
|
|
92
|
+
=== Link health
|
|
93
|
+
* **url-to-xref** - Convert docs.redpanda.com URLs in content into validated xrefs and warn about URLs that match no published page
|
|
94
|
+
* **external-link-checker** - Verify external URLs respond and report dead links in the build log
|
|
95
|
+
|
|
92
96
|
=== AI-friendly documentation
|
|
93
97
|
* **convert-to-markdown** - Export documentation pages as markdown with frontmatter
|
|
94
98
|
* **convert-llms-to-txt** - Generate llms.txt and component-specific exports for AI agents
|
|
@@ -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
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { scanContentUrls } = require('./util/scan-content-urls')
|
|
4
|
+
const { raiseListenerLimit } = require('./util/raise-listener-limit')
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* URL to Xref Extension
|
|
8
|
+
*
|
|
9
|
+
* Converts absolute docs-site URLs (for example https://docs.redpanda.com/...)
|
|
10
|
+
* found in page and partial content into Antora xrefs, using the content
|
|
11
|
+
* catalog as the single source of truth. A URL is only converted when it maps
|
|
12
|
+
* to a page that is actually published in this build — the extension never
|
|
13
|
+
* guesses at URL structure. Converted links get Antora's build-time xref
|
|
14
|
+
* validation; internal URLs that match no published page are reported as
|
|
15
|
+
* warnings, which makes this extension a broken-internal-link detector.
|
|
16
|
+
*
|
|
17
|
+
* Registration (docs-site playbook):
|
|
18
|
+
*
|
|
19
|
+
* antora:
|
|
20
|
+
* extensions:
|
|
21
|
+
* - require: '@redpanda-data/docs-extensions-and-macros/extensions/url-to-xref'
|
|
22
|
+
* # hostnames: ['docs.redpanda.com'] (default)
|
|
23
|
+
* # log_unconverted: true (default)
|
|
24
|
+
*
|
|
25
|
+
* Behavior details:
|
|
26
|
+
* - Runs at contentClassified, before AsciiDoc conversion, so emitted xrefs
|
|
27
|
+
* flow through Antora's normal resolution and broken-xref logging.
|
|
28
|
+
* - URLs inside listing/literal/fenced/passthrough blocks, inline code spans,
|
|
29
|
+
* attribute entry lines, and macro attribute values (link=...) are left
|
|
30
|
+
* untouched.
|
|
31
|
+
* - Legacy URL shapes (/docs/..., /current/..., /vX.Y/..., pre-rename
|
|
32
|
+
* component slugs) are rewritten to candidate paths, but a candidate is
|
|
33
|
+
* only used when it matches a published page.
|
|
34
|
+
* - Fragments (#anchor) and link labels are preserved, including labels that a
|
|
35
|
+
* generator wrapped across a line break. An unlabeled URL becomes xref:...[]
|
|
36
|
+
* so Antora fills in the target page title, except when it carries a
|
|
37
|
+
* fragment, where link text has to be supplied (see linkTextFor).
|
|
38
|
+
* - Targets in the latest version of a component emit an unversioned xref;
|
|
39
|
+
* targets pinned to an older version emit version@component:module:page.
|
|
40
|
+
* - URLs pointing at a page's former path resolve through the page-aliases the
|
|
41
|
+
* page declares (see buildUrlMap).
|
|
42
|
+
* - URLs carrying a query string are left raw, because an xref cannot express
|
|
43
|
+
* one; they are reported at info level rather than as warnings.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
// Rewrites for URL shapes that predate the current docs.redpanda.com site
|
|
47
|
+
// structure. Every produced candidate is verified against the catalog before
|
|
48
|
+
// use, so an entry that no longer applies is harmless. Slugs mirror the live
|
|
49
|
+
// site's 301 redirects.
|
|
50
|
+
const LEGACY_SLUG_REWRITES = {
|
|
51
|
+
'redpanda-connect': 'connect',
|
|
52
|
+
'redpanda-cloud': 'cloud-data-platform',
|
|
53
|
+
'redpanda-labs': 'labs',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The component that serves the pre-umbrella site's unprefixed URLs
|
|
57
|
+
// (/docs/..., /current/..., /vX.Y/...). The live site 301s those to
|
|
58
|
+
// /streaming/....
|
|
59
|
+
const LEGACY_UNPREFIXED_COMPONENT = 'streaming'
|
|
60
|
+
|
|
61
|
+
module.exports.register = function ({ config = {} }) {
|
|
62
|
+
raiseListenerLimit(this)
|
|
63
|
+
const logger = this.getLogger('url-to-xref-extension')
|
|
64
|
+
const hostnames = new Set(config.hostnames || ['docs.redpanda.com'])
|
|
65
|
+
const logUnconverted = config.logUnconverted !== false
|
|
66
|
+
// URL paths that live on the docs domain but outside the Antora catalog: the
|
|
67
|
+
// API reference is hosted by Bump.sh, and /mcp is the docs MCP server
|
|
68
|
+
// endpoint. Left untouched, no warning.
|
|
69
|
+
const ignore = (config.ignore || ['^/api/', '^/mcp(/|$)']).map((pattern) => new RegExp(pattern))
|
|
70
|
+
|
|
71
|
+
this.on('contentClassified', ({ playbook, contentCatalog }) => {
|
|
72
|
+
const resolverContext = Object.assign(buildUrlMap(contentCatalog), {
|
|
73
|
+
hostnames,
|
|
74
|
+
ignore,
|
|
75
|
+
latestVersionSegment: (playbook.urls || {}).latestVersionSegment || 'current',
|
|
76
|
+
// Used to read heading ids out of the partials a target page includes.
|
|
77
|
+
resolveInclude: (spec, page) => contentCatalog.resolveResource(spec, page.src, 'partial', ['partial', 'page']),
|
|
78
|
+
})
|
|
79
|
+
let convertedCount = 0
|
|
80
|
+
const unmapped = new Map()
|
|
81
|
+
const withQueryString = new Set()
|
|
82
|
+
const withoutLinkText = new Set()
|
|
83
|
+
const files = contentCatalog
|
|
84
|
+
.getPages((page) => page.out)
|
|
85
|
+
.concat(contentCatalog.findBy({ family: 'partial' }))
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
if (!file.contents) continue
|
|
88
|
+
const result = convertContent(file.contents.toString(), resolverContext)
|
|
89
|
+
if (result.converted) {
|
|
90
|
+
file.contents = Buffer.from(result.content)
|
|
91
|
+
convertedCount += result.converted
|
|
92
|
+
}
|
|
93
|
+
for (const url of result.unmapped) {
|
|
94
|
+
if (!unmapped.has(url)) unmapped.set(url, new Set())
|
|
95
|
+
unmapped.get(url).add(file.path)
|
|
96
|
+
}
|
|
97
|
+
for (const url of result.withQueryString) withQueryString.add(url)
|
|
98
|
+
for (const url of result.withoutLinkText) withoutLinkText.add(url)
|
|
99
|
+
}
|
|
100
|
+
if (logUnconverted) {
|
|
101
|
+
for (const [url, pages] of unmapped) {
|
|
102
|
+
logger.warn(`No published page matches ${url} (found in: ${[...pages].join(', ')})`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (withQueryString.size) {
|
|
106
|
+
logger.info(
|
|
107
|
+
`Left ${withQueryString.size} docs URL${withQueryString.size === 1 ? '' : 's'} with a query string as raw ` +
|
|
108
|
+
`link${withQueryString.size === 1 ? '' : 's'} (an xref cannot carry one): ${[...withQueryString].join(', ')}`
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
if (withoutLinkText.size) {
|
|
112
|
+
logger.info(
|
|
113
|
+
`Left ${withoutLinkText.size} docs URL${withoutLinkText.size === 1 ? '' : 's'} with a fragment as raw ` +
|
|
114
|
+
`link${withoutLinkText.size === 1 ? '' : 's'} (no link text could be resolved from the target page): ` +
|
|
115
|
+
`${[...withoutLinkText].join(', ')}`
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
if (convertedCount || unmapped.size) {
|
|
119
|
+
logger.info(
|
|
120
|
+
`Converted ${convertedCount} docs URL${convertedCount === 1 ? '' : 's'} to xrefs; ` +
|
|
121
|
+
`${unmapped.size} URL${unmapped.size === 1 ? '' : 's'} matched no published page`
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Builds a lookup of published URL path -> resource coordinates from the
|
|
129
|
+
* content catalog. Covers regular pages, the synthetic alias files Antora
|
|
130
|
+
* registers during classification for component start pages and the site start
|
|
131
|
+
* page (so component landing URLs like /connect/ and the site root resolve),
|
|
132
|
+
* and the page-aliases each page declares in its header.
|
|
133
|
+
*
|
|
134
|
+
* page-aliases have to be read from the page header here because Antora does
|
|
135
|
+
* not register them as catalog alias files until it converts documents, which
|
|
136
|
+
* is after this extension runs. Without them, a URL that points at a page's
|
|
137
|
+
* former path (the majority of the raw URLs in generated Helm and CRD specs)
|
|
138
|
+
* would be reported as broken even though the site redirects it.
|
|
139
|
+
*/
|
|
140
|
+
function buildUrlMap (contentCatalog) {
|
|
141
|
+
const components = {}
|
|
142
|
+
for (const component of contentCatalog.getComponents()) {
|
|
143
|
+
components[component.name] = { latestVersion: component.latest ? component.latest.version : '' }
|
|
144
|
+
}
|
|
145
|
+
const isLatest = (name, version) => name in components && components[name].latestVersion === version
|
|
146
|
+
// The target file travels with the entry so that link text can be resolved
|
|
147
|
+
// from it later, which an xref with a fragment needs (see linkTextFor).
|
|
148
|
+
const toEntry = ({ component, version, module: module_, relative }, page) => ({
|
|
149
|
+
component,
|
|
150
|
+
version,
|
|
151
|
+
module: module_,
|
|
152
|
+
relative,
|
|
153
|
+
latest: isLatest(component, version),
|
|
154
|
+
page,
|
|
155
|
+
})
|
|
156
|
+
const urls = new Map()
|
|
157
|
+
const pages = contentCatalog.getPages((p) => p.out && p.pub && p.pub.url)
|
|
158
|
+
for (const page of pages) {
|
|
159
|
+
urls.set(normalizeUrlPath(page.pub.url), toEntry(page.src, page))
|
|
160
|
+
}
|
|
161
|
+
for (const alias of contentCatalog.findBy({ family: 'alias' })) {
|
|
162
|
+
if (!alias.pub || !alias.pub.url || alias.pub.splat) continue
|
|
163
|
+
let target = alias.rel
|
|
164
|
+
let depth = 0
|
|
165
|
+
while (target && target.src && target.src.family === 'alias' && depth++ < 5) target = target.rel
|
|
166
|
+
if (!target || !target.src || target.src.family !== 'page' || !target.pub) continue
|
|
167
|
+
const key = normalizeUrlPath(alias.pub.url)
|
|
168
|
+
if (!urls.has(key)) urls.set(key, toEntry(target.src, target))
|
|
169
|
+
}
|
|
170
|
+
for (const page of pages) {
|
|
171
|
+
for (const key of pageAliasUrls(page)) {
|
|
172
|
+
if (!urls.has(key)) urls.set(key, toEntry(page.src, page))
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return { urls, components }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const PAGE_ALIASES_ATTRIBUTE = ':page-aliases:'
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Returns the raw value of a page's page-aliases attribute, joining the
|
|
182
|
+
* continuation lines that AsciiDoc allows a long value to span.
|
|
183
|
+
*/
|
|
184
|
+
function extractPageAliases (header) {
|
|
185
|
+
const lines = header.split('\n')
|
|
186
|
+
const start = lines.findIndex((line) => line.startsWith(PAGE_ALIASES_ATTRIBUTE))
|
|
187
|
+
if (start === -1) return
|
|
188
|
+
let value = lines[start].slice(PAGE_ALIASES_ATTRIBUTE.length)
|
|
189
|
+
for (let index = start; value.trimEnd().endsWith('\\') && index + 1 < lines.length; index++) {
|
|
190
|
+
value = `${value.trimEnd().slice(0, -1)} ${lines[index + 1]}`
|
|
191
|
+
}
|
|
192
|
+
return value
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Returns the normalized URL paths that a page's page-aliases attribute makes
|
|
197
|
+
* available. Only aliases in the same component and version as the target page
|
|
198
|
+
* are mapped, because their URL is derived from the target page's own
|
|
199
|
+
* published URL; a cross-component or cross-version alias is left to Antora's
|
|
200
|
+
* own redirect handling.
|
|
201
|
+
*/
|
|
202
|
+
function pageAliasUrls (page) {
|
|
203
|
+
const contents = page.contents && page.contents.toString()
|
|
204
|
+
if (!contents) return []
|
|
205
|
+
const aliases = extractPageAliases(contents.slice(0, 4096))
|
|
206
|
+
if (!aliases) return []
|
|
207
|
+
const targetUrl = normalizeUrlPath(page.pub.url)
|
|
208
|
+
const targetSuffix = pageUrlSuffix(page.src)
|
|
209
|
+
// The alias URL differs from the target URL only in the module and page
|
|
210
|
+
// segments, so everything before them is a shared prefix.
|
|
211
|
+
if (targetSuffix && !targetUrl.endsWith(`/${targetSuffix}`)) return []
|
|
212
|
+
const base = targetSuffix ? targetUrl.slice(0, -(targetSuffix.length + 1)) : targetUrl
|
|
213
|
+
const keys = []
|
|
214
|
+
for (const spec of aliases.split(',')) {
|
|
215
|
+
const aliasSrc = parsePageAliasSpec(spec, page.src)
|
|
216
|
+
if (!aliasSrc) continue
|
|
217
|
+
const suffix = pageUrlSuffix(aliasSrc)
|
|
218
|
+
keys.push(normalizeUrlPath(suffix ? `${base}/${suffix}` : base || '/'))
|
|
219
|
+
}
|
|
220
|
+
return keys
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Returns the module and page portion of a page's URL, which is the part that
|
|
225
|
+
* follows the component and version segments. A module named ROOT and an
|
|
226
|
+
* index page contribute no segment of their own.
|
|
227
|
+
*/
|
|
228
|
+
function pageUrlSuffix ({ module: module_, relative }) {
|
|
229
|
+
let stem = relative.replace(/\.adoc$/, '')
|
|
230
|
+
if (stem === 'index') stem = ''
|
|
231
|
+
else if (stem.endsWith('/index')) stem = stem.slice(0, -'/index'.length)
|
|
232
|
+
const segments = []
|
|
233
|
+
if (module_ && module_ !== 'ROOT') segments.push(module_)
|
|
234
|
+
if (stem) segments.push(stem)
|
|
235
|
+
return segments.join('/')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Parses one page-aliases entry ([version@][component:][module:]relative) into
|
|
240
|
+
* a resource id, inheriting anything it leaves out from the page that declares
|
|
241
|
+
* it. Returns undefined for an entry that names another component or version.
|
|
242
|
+
*/
|
|
243
|
+
function parsePageAliasSpec (spec, targetSrc) {
|
|
244
|
+
let rest = spec.trim()
|
|
245
|
+
if (!rest) return
|
|
246
|
+
const versionSeparator = rest.indexOf('@')
|
|
247
|
+
if (versionSeparator !== -1) {
|
|
248
|
+
if (rest.slice(0, versionSeparator) !== targetSrc.version) return
|
|
249
|
+
rest = rest.slice(versionSeparator + 1)
|
|
250
|
+
}
|
|
251
|
+
const parts = rest.split(':')
|
|
252
|
+
if (parts.length > 3) return
|
|
253
|
+
const relative = parts.pop()
|
|
254
|
+
if (!relative) return
|
|
255
|
+
const module_ = parts.length ? parts.pop() : targetSrc.module
|
|
256
|
+
if (parts.length && parts[0] !== targetSrc.component) return
|
|
257
|
+
// Antora still accepts an alias spec without the file extension.
|
|
258
|
+
return { module: module_ || targetSrc.module, relative: relative.endsWith('.adoc') ? relative : `${relative}.adoc` }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Normalizes a URL path for map lookup: drops the query string, an
|
|
262
|
+
* index.html or .html suffix, and any trailing slash (except the bare root).
|
|
263
|
+
*/
|
|
264
|
+
function normalizeUrlPath (urlPath) {
|
|
265
|
+
let result = urlPath.split('?')[0]
|
|
266
|
+
if (result.endsWith('/index.html')) result = result.slice(0, -'index.html'.length)
|
|
267
|
+
else if (result.endsWith('.html')) result = result.slice(0, -'.html'.length)
|
|
268
|
+
if (result.length > 1 && result.endsWith('/')) result = result.slice(0, -1)
|
|
269
|
+
return result || '/'
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Produces lookup candidates for a normalized path: the path itself, legacy
|
|
274
|
+
* rewrites, and an explicit-latest-version -> symbolic-segment swap. Callers
|
|
275
|
+
* must verify each candidate against the URL map.
|
|
276
|
+
*/
|
|
277
|
+
function candidatePaths (normalizedPath, { components, latestVersionSegment }) {
|
|
278
|
+
const candidates = [normalizedPath]
|
|
279
|
+
let unprefixed = normalizedPath
|
|
280
|
+
const docsPrefix = unprefixed.match(/^\/docs(\/.*)?$/)
|
|
281
|
+
if (docsPrefix) unprefixed = docsPrefix[1] || '/'
|
|
282
|
+
const versionPrefix = unprefixed.match(/^\/(?:current|beta|v?(\d+\.\d+(?:\.\d+)?))(\/.*)?$/)
|
|
283
|
+
if (versionPrefix) {
|
|
284
|
+
const [, version, rest = ''] = versionPrefix
|
|
285
|
+
candidates.push(`/${LEGACY_UNPREFIXED_COMPONENT}/${version || latestVersionSegment}${rest}`)
|
|
286
|
+
if (version) candidates.push(`/${LEGACY_UNPREFIXED_COMPONENT}/${latestVersionSegment}${rest}`)
|
|
287
|
+
// Builds without a symbolic latest-version segment (for example preview
|
|
288
|
+
// builds) publish the latest version under its real version number, and
|
|
289
|
+
// may drop the component segment entirely (ROOT component). Substitute
|
|
290
|
+
// each component's latest version; only verified candidates are used.
|
|
291
|
+
if (!version) {
|
|
292
|
+
for (const [name, { latestVersion }] of Object.entries(components)) {
|
|
293
|
+
if (!latestVersion) continue
|
|
294
|
+
candidates.push(`/${latestVersion}${rest}`)
|
|
295
|
+
candidates.push(`/${name}/${latestVersion}${rest}`)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
} else if (docsPrefix) {
|
|
299
|
+
const rest = unprefixed === '/' ? '' : unprefixed
|
|
300
|
+
candidates.push(`/${LEGACY_UNPREFIXED_COMPONENT}/${latestVersionSegment}${rest}`)
|
|
301
|
+
for (const [name, { latestVersion }] of Object.entries(components)) {
|
|
302
|
+
if (!latestVersion) continue
|
|
303
|
+
candidates.push(`/${latestVersion}${rest}`)
|
|
304
|
+
candidates.push(`/${name}/${latestVersion}${rest}`)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
for (const [slug, componentName] of Object.entries(LEGACY_SLUG_REWRITES)) {
|
|
308
|
+
if (normalizedPath === `/${slug}` || normalizedPath.startsWith(`/${slug}/`)) {
|
|
309
|
+
candidates.push(`/${componentName}${normalizedPath.slice(slug.length + 1)}`)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const segments = normalizedPath.split('/')
|
|
313
|
+
const component = components[segments[1]]
|
|
314
|
+
if (component && segments[2]) {
|
|
315
|
+
const version = segments[2].replace(/^v/, '')
|
|
316
|
+
if (version === component.latestVersion) {
|
|
317
|
+
// An explicitly versioned URL whose version is the component's latest is
|
|
318
|
+
// published under the symbolic segment instead (redirect:to strategy).
|
|
319
|
+
candidates.push(['', segments[1], latestVersionSegment, ...segments.slice(3)].join('/'))
|
|
320
|
+
} else if (segments[2] === latestVersionSegment && component.latestVersion) {
|
|
321
|
+
// The reverse: a symbolic latest-version URL in a build that publishes
|
|
322
|
+
// the latest version under its real number (for example the preview
|
|
323
|
+
// site, which sets no latest-version segment).
|
|
324
|
+
candidates.push(['', segments[1], component.latestVersion, ...segments.slice(3)].join('/'))
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return candidates
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function resolveUrlPath (urlPath, resolverContext) {
|
|
331
|
+
for (const candidate of candidatePaths(normalizeUrlPath(urlPath), resolverContext)) {
|
|
332
|
+
const entry = resolverContext.urls.get(candidate)
|
|
333
|
+
if (entry) return entry
|
|
334
|
+
}
|
|
335
|
+
return undefined
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function entryToXref (entry, fragment, label) {
|
|
339
|
+
const versionPrefix = entry.latest || !entry.version ? '' : `${entry.version}@`
|
|
340
|
+
return `xref:${versionPrefix}${entry.component}:${entry.module}:${entry.relative}${fragment || ''}[${label || ''}]`
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Parsed heading id -> heading text, cached per target file.
|
|
344
|
+
const headingsByFile = new WeakMap()
|
|
345
|
+
const EXPLICIT_ANCHOR_PATTERNS = [
|
|
346
|
+
/^\[\[([\w:.-]+)(?:,.*)?\]\]$/,
|
|
347
|
+
/^\[#([\w:.-]+)(?:[,.].*)?\]$/,
|
|
348
|
+
/^\[id="?([\w:.-]+)"?(?:,.*)?\]$/,
|
|
349
|
+
]
|
|
350
|
+
const HEADING_RX = /^(={1,6})\s+(\S.*)$/
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Returns the link text for a converted URL, or undefined when the URL should
|
|
354
|
+
* be left alone.
|
|
355
|
+
*
|
|
356
|
+
* A labeled URL keeps its label, and an unlabeled URL without a fragment gets
|
|
357
|
+
* empty text so that Antora fills in the target page title. An unlabeled URL
|
|
358
|
+
* *with* a fragment has to be given text explicitly: Antora cannot resolve a
|
|
359
|
+
* section title, so `xref:page.adoc#anchor[]` renders the raw resource id as
|
|
360
|
+
* the link text. Prefer the heading the fragment points at, then the page
|
|
361
|
+
* title; if neither can be read, the caller leaves the URL as a raw link,
|
|
362
|
+
* which still displays sensibly.
|
|
363
|
+
*/
|
|
364
|
+
function linkTextFor (label, fragment, page, resolveInclude) {
|
|
365
|
+
if (label) return label
|
|
366
|
+
if (!fragment) return ''
|
|
367
|
+
const contents = page && page.contents && page.contents.toString()
|
|
368
|
+
if (!contents) return undefined
|
|
369
|
+
let headings = headingsByFile.get(page)
|
|
370
|
+
if (!headings) headingsByFile.set(page, (headings = pageHeadingIds(contents, page, resolveInclude)))
|
|
371
|
+
return headings.get(fragment.slice(1)) || documentTitle(contents)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Only the first level of includes is followed, and only this many per page:
|
|
375
|
+
// enough for the reference pages that assemble a partial per category, without
|
|
376
|
+
// walking an include tree of unknown depth.
|
|
377
|
+
const MAX_INCLUDES_SEARCHED = 50
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Collects the heading ids of a page plus those of the partials it includes.
|
|
381
|
+
* The reference pages that carry most anchor targets are thin wrappers around
|
|
382
|
+
* generated partials — every property heading on the cluster and broker
|
|
383
|
+
* property pages lives in one — and Antora has not resolved includes yet at
|
|
384
|
+
* contentClassified, so they are resolved here.
|
|
385
|
+
*/
|
|
386
|
+
function pageHeadingIds (contents, page, resolveInclude) {
|
|
387
|
+
const ids = parseHeadingIds(contents)
|
|
388
|
+
if (!resolveInclude) return ids
|
|
389
|
+
let searched = 0
|
|
390
|
+
for (const match of contents.matchAll(/^include::([^[\s]+)\[/gm)) {
|
|
391
|
+
if (++searched > MAX_INCLUDES_SEARCHED) break
|
|
392
|
+
let included
|
|
393
|
+
try {
|
|
394
|
+
included = resolveInclude(match[1], page)
|
|
395
|
+
} catch {
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
if (!included || !included.contents) continue
|
|
399
|
+
for (const [id, text] of parseHeadingIds(included.contents.toString())) {
|
|
400
|
+
if (!ids.has(id)) ids.set(id, text)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return ids
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Maps every heading id in a page to its text, covering explicit anchors
|
|
408
|
+
* ([[id]], [#id], [id=...]) and the ids Asciidoctor generates from heading
|
|
409
|
+
* text. Id generation is approximated: a mismatch only costs the page-title
|
|
410
|
+
* fallback.
|
|
411
|
+
*/
|
|
412
|
+
function parseHeadingIds (contents) {
|
|
413
|
+
const ids = new Map()
|
|
414
|
+
let pendingId
|
|
415
|
+
for (const rawLine of contents.split('\n')) {
|
|
416
|
+
const line = rawLine.trim()
|
|
417
|
+
const anchor = EXPLICIT_ANCHOR_PATTERNS.reduce((found, pattern) => found || pattern.exec(line), null)
|
|
418
|
+
if (anchor) {
|
|
419
|
+
pendingId = anchor[1]
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
422
|
+
const heading = HEADING_RX.exec(line)
|
|
423
|
+
if (heading) {
|
|
424
|
+
const text = sanitizeLinkText(heading[2])
|
|
425
|
+
const id = pendingId || autoId(text)
|
|
426
|
+
if (id && text && !ids.has(id)) ids.set(id, text)
|
|
427
|
+
}
|
|
428
|
+
if (line) pendingId = undefined
|
|
429
|
+
}
|
|
430
|
+
return ids
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Returns a page's document title (its level-0 heading), sanitized. */
|
|
434
|
+
function documentTitle (contents) {
|
|
435
|
+
const match = /^=\s+(\S.*)$/m.exec(contents)
|
|
436
|
+
return match ? sanitizeLinkText(match[1]) : undefined
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Makes text safe to use as an xref label: an unbalanced bracket would end the
|
|
441
|
+
* macro early, and a nested link macro cannot appear inside one.
|
|
442
|
+
*/
|
|
443
|
+
function sanitizeLinkText (text) {
|
|
444
|
+
return text
|
|
445
|
+
.replace(/(?:xref|link|image):[^[\s]*\[([^\]]*)\]/g, '$1')
|
|
446
|
+
.replace(/[[\]]/g, '')
|
|
447
|
+
.replace(/\s+/g, ' ')
|
|
448
|
+
.trim()
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Approximates Asciidoctor id generation with idprefix='' and idseparator='-'. */
|
|
452
|
+
function autoId (text) {
|
|
453
|
+
return text
|
|
454
|
+
.replace(/[`*+#^~]/g, '')
|
|
455
|
+
.toLowerCase()
|
|
456
|
+
.replace(/[^\w\- .]/g, '')
|
|
457
|
+
.trim()
|
|
458
|
+
.replace(/[ .]+/g, '-')
|
|
459
|
+
.replace(/-{2,}/g, '-')
|
|
460
|
+
.replace(/^-+|-+$/g, '')
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Rewrites every mappable docs URL in content. Returns the updated content,
|
|
465
|
+
* the number of conversions, the internal URLs that matched no published page,
|
|
466
|
+
* the internal URLs that carry a query string (which an xref cannot express),
|
|
467
|
+
* and the internal URLs left alone because no link text could be resolved for
|
|
468
|
+
* their fragment.
|
|
469
|
+
*/
|
|
470
|
+
function convertContent (content, resolverContext) {
|
|
471
|
+
const { hostnames, ignore = [] } = resolverContext
|
|
472
|
+
let result = ''
|
|
473
|
+
let cursor = 0
|
|
474
|
+
let converted = 0
|
|
475
|
+
const unmapped = []
|
|
476
|
+
const withQueryString = []
|
|
477
|
+
const withoutLinkText = []
|
|
478
|
+
for (const match of scanContentUrls(content)) {
|
|
479
|
+
let url
|
|
480
|
+
try {
|
|
481
|
+
url = new URL(match.url)
|
|
482
|
+
} catch {
|
|
483
|
+
continue
|
|
484
|
+
}
|
|
485
|
+
if (!hostnames.has(url.hostname) || match.inAttributeEntry || match.inAttributeValue) continue
|
|
486
|
+
if (ignore.some((pattern) => pattern.test(url.pathname))) continue
|
|
487
|
+
// An xref target cannot carry a query string, and dropping one would
|
|
488
|
+
// change the link: the docs UI reads parameters such as
|
|
489
|
+
// ?platform=kubernetes to preselect a tab. Leave the raw URL, which still
|
|
490
|
+
// works, rather than converting it lossily.
|
|
491
|
+
if (url.search) {
|
|
492
|
+
withQueryString.push(match.url)
|
|
493
|
+
continue
|
|
494
|
+
}
|
|
495
|
+
const entry = resolveUrlPath(url.pathname, resolverContext)
|
|
496
|
+
if (!entry) {
|
|
497
|
+
unmapped.push(match.url)
|
|
498
|
+
continue
|
|
499
|
+
}
|
|
500
|
+
const label = linkTextFor(match.label, url.hash, entry.page, resolverContext.resolveInclude)
|
|
501
|
+
if (label === undefined) {
|
|
502
|
+
withoutLinkText.push(match.url)
|
|
503
|
+
continue
|
|
504
|
+
}
|
|
505
|
+
result += content.slice(cursor, match.start) + entryToXref(entry, url.hash, label)
|
|
506
|
+
cursor = match.end
|
|
507
|
+
converted++
|
|
508
|
+
}
|
|
509
|
+
result += content.slice(cursor)
|
|
510
|
+
return { content: result, converted, unmapped, withQueryString, withoutLinkText }
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
module.exports.buildUrlMap = buildUrlMap
|
|
514
|
+
module.exports.convertContent = convertContent
|
|
515
|
+
module.exports.normalizeUrlPath = normalizeUrlPath
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
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:
|
|
6
|
+
*
|
|
7
|
+
* - delimited listing (----), literal (....), fenced (```), and passthrough
|
|
8
|
+
* (++++) blocks
|
|
9
|
+
* - inline code spans (`...`)
|
|
10
|
+
*
|
|
11
|
+
* Each match records enough position information for a caller to splice a
|
|
12
|
+
* replacement into the original content.
|
|
13
|
+
*
|
|
14
|
+
* Match shape:
|
|
15
|
+
* - url: the URL with any trailing sentence punctuation trimmed
|
|
16
|
+
* - label: text of a directly attached AsciiDoc link label ([...]), or null.
|
|
17
|
+
* A label may span line breaks, which is common in generated content where
|
|
18
|
+
* long lines are wrapped; its newlines are collapsed to single spaces
|
|
19
|
+
* - start/end: absolute offsets in content covering the whole replaceable
|
|
20
|
+
* span (including a leading link: macro prefix and the label, if present)
|
|
21
|
+
* - hasLinkPrefix: true when the URL was written as link:https://...[...]
|
|
22
|
+
* - inAttributeEntry: true when the URL appears in an attribute entry line
|
|
23
|
+
* (for example ":url-docs: https://..."), where a caller may want to leave
|
|
24
|
+
* the value untouched
|
|
25
|
+
* - inAttributeValue: true when the URL is a macro attribute value (for
|
|
26
|
+
* example image:d.png[alt,link=https://...]); rewriting one of these to an
|
|
27
|
+
* xref macro would corrupt the surrounding macro, but the URL is still a
|
|
28
|
+
* real link target worth checking
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const URL_RX = /(link:)?(https?:\/\/[^\s\][)"'<>]+)(\[[^\]]*\])?/g
|
|
32
|
+
// Sentence punctuation, and the AsciiDoc formatting markup that closes around a
|
|
33
|
+
// URL written as *emphasis* or **bold** (`**https://example.com/page**`), which
|
|
34
|
+
// is not part of the URL.
|
|
35
|
+
const TRAILING_PUNCT_RX = /[.,;:!?*_]+$/
|
|
36
|
+
const ATTRIBUTE_ENTRY_RX = /^:!?[a-zA-Z0-9_][a-zA-Z0-9_-]*!?:(?:\s|$)/
|
|
37
|
+
// Cap on how far a wrapped label may run, so an unmatched bracket somewhere in
|
|
38
|
+
// prose cannot swallow the rest of the document.
|
|
39
|
+
const MAX_WRAPPED_LABEL_LENGTH = 500
|
|
40
|
+
|
|
41
|
+
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
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function scanContentUrls (content) {
|
|
50
|
+
const matches = []
|
|
51
|
+
const lines = content.split('\n')
|
|
52
|
+
let offset = 0
|
|
53
|
+
let openDelimiter = null
|
|
54
|
+
for (const rawLine of lines) {
|
|
55
|
+
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
|
|
56
|
+
const delimiter = blockDelimiter(line)
|
|
57
|
+
if (delimiter) {
|
|
58
|
+
if (openDelimiter === delimiter) openDelimiter = null
|
|
59
|
+
else if (!openDelimiter) openDelimiter = delimiter
|
|
60
|
+
} else if (!openDelimiter) {
|
|
61
|
+
scanLine(line, offset, matches, content)
|
|
62
|
+
}
|
|
63
|
+
offset += rawLine.length + 1
|
|
64
|
+
}
|
|
65
|
+
return matches
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reads a link label that opens on this line but closes on a later one, which
|
|
70
|
+
* happens whenever a generator wraps long lines. Returns the label text with
|
|
71
|
+
* its line breaks collapsed, plus the offset just past the closing bracket.
|
|
72
|
+
* A blank line ends the search: that is a paragraph break, so the bracket
|
|
73
|
+
* belongs to something else.
|
|
74
|
+
*/
|
|
75
|
+
function readWrappedLabel (content, openIndex) {
|
|
76
|
+
if (content[openIndex] !== '[') return
|
|
77
|
+
const limit = Math.min(content.length, openIndex + MAX_WRAPPED_LABEL_LENGTH)
|
|
78
|
+
const closeIndex = content.indexOf(']', openIndex + 1)
|
|
79
|
+
if (closeIndex === -1 || closeIndex > limit) return
|
|
80
|
+
const raw = content.slice(openIndex + 1, closeIndex)
|
|
81
|
+
if (/\n[ \t]*\n/.test(raw)) return
|
|
82
|
+
return { label: raw.replace(/\s*\n\s*/g, ' '), end: closeIndex + 1 }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function scanLine (line, offset, matches, content) {
|
|
86
|
+
const inAttributeEntry = ATTRIBUTE_ENTRY_RX.test(line)
|
|
87
|
+
for (const match of line.matchAll(URL_RX)) {
|
|
88
|
+
const [, linkPrefix, rawUrl, rawLabel] = match
|
|
89
|
+
const backticksBefore = (line.slice(0, match.index).match(/`/g) || []).length
|
|
90
|
+
if (backticksBefore % 2 === 1) continue
|
|
91
|
+
const prefixLength = linkPrefix ? linkPrefix.length : 0
|
|
92
|
+
const start = offset + match.index
|
|
93
|
+
let url = rawUrl
|
|
94
|
+
let label = rawLabel ? rawLabel.slice(1, -1) : null
|
|
95
|
+
let end = start + prefixLength + url.length + (rawLabel ? rawLabel.length : 0)
|
|
96
|
+
if (!rawLabel) {
|
|
97
|
+
const wrapped = readWrappedLabel(content, start + prefixLength + url.length)
|
|
98
|
+
if (wrapped) {
|
|
99
|
+
label = wrapped.label
|
|
100
|
+
end = wrapped.end
|
|
101
|
+
} else {
|
|
102
|
+
url = url.replace(TRAILING_PUNCT_RX, '')
|
|
103
|
+
end = start + prefixLength + url.length
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// A URL directly after an attribute assignment (link=, window=, ...) is a
|
|
107
|
+
// macro attribute value, not a standalone link.
|
|
108
|
+
const before = line.slice(0, match.index)
|
|
109
|
+
matches.push({
|
|
110
|
+
url,
|
|
111
|
+
label,
|
|
112
|
+
start,
|
|
113
|
+
end,
|
|
114
|
+
hasLinkPrefix: Boolean(linkPrefix),
|
|
115
|
+
inAttributeEntry,
|
|
116
|
+
inAttributeValue: /[\w-]=["']?$/.test(before),
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { scanContentUrls }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redpanda-data/docs-extensions-and-macros",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.10.0",
|
|
4
4
|
"description": "Antora extensions and macros developed for Redpanda documentation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"antora",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"build": "antora --to-dir docs --fetch local-antora-playbook.yml",
|
|
25
25
|
"serve": "wds --node-resolve --open preview/test/ --watch --root-dir docs",
|
|
26
26
|
"test": "jest",
|
|
27
|
+
"test:e2e": "jest __tests__/e2e/",
|
|
27
28
|
"test:mcp": "jest __tests__/mcp/",
|
|
28
29
|
"test:mcp:integration": "jest __tests__/mcp/integration.test.js",
|
|
29
30
|
"test:mcp:contract": "jest __tests__/mcp/cli-contract.test.js",
|
|
@@ -80,6 +81,8 @@
|
|
|
80
81
|
"./extensions/resolve-xrefs-in-attachments": "./extensions/resolve-xrefs-in-attachments.js",
|
|
81
82
|
"./extensions/unified-navigation": "./extensions/unified-navigation.js",
|
|
82
83
|
"./extensions/conditional-home-redirect": "./extensions/conditional-home-redirect.js",
|
|
84
|
+
"./extensions/url-to-xref": "./extensions/url-to-xref.js",
|
|
85
|
+
"./extensions/external-link-checker": "./extensions/external-link-checker.js",
|
|
83
86
|
"./macros/glossary": "./macros/glossary.js",
|
|
84
87
|
"./macros/rp-connect-components": "./macros/rp-connect-components.js",
|
|
85
88
|
"./macros/config-ref": "./macros/config-ref.js",
|
|
@@ -101,6 +104,9 @@
|
|
|
101
104
|
"mcp"
|
|
102
105
|
],
|
|
103
106
|
"license": "ISC",
|
|
107
|
+
"engines": {
|
|
108
|
+
"node": ">=18"
|
|
109
|
+
},
|
|
104
110
|
"repository": {
|
|
105
111
|
"type": "git",
|
|
106
112
|
"url": "git+https://github.com/redpanda-data/docs-extensions-and-macros"
|
|
@@ -23,12 +23,13 @@ const VALIDATION_RULES = [
|
|
|
23
23
|
let match
|
|
24
24
|
while ((match = urlPattern.exec(content)) !== null) {
|
|
25
25
|
const url = match[1]
|
|
26
|
-
//
|
|
26
|
+
// docs.redpanda.com URLs are converted to xrefs at site build time
|
|
27
|
+
// by the url-to-xref Antora extension, so a raw one is informational
|
|
27
28
|
if (url.includes('docs.redpanda.com')) {
|
|
28
29
|
issues.push({
|
|
29
30
|
line: getLineNumber(content, match.index),
|
|
30
|
-
message: `Raw docs.redpanda.com URL
|
|
31
|
-
severity: '
|
|
31
|
+
message: `Raw docs.redpanda.com URL (converted to an xref by the url-to-xref extension at build time): ${url}`,
|
|
32
|
+
severity: 'info'
|
|
32
33
|
})
|
|
33
34
|
}
|
|
34
35
|
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
const { URL } = require('url');
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Converts a docs.redpanda.com URL, optionally suffixed with a label in brackets, into an Antora xref resource ID string.
|
|
5
|
-
*
|
|
6
|
-
* If the input includes a label in square brackets (for example, `[Label]`), the label is preserved and appended to the resulting xref.
|
|
7
|
-
*
|
|
8
|
-
* @param {string} input - A docs.redpanda.com URL, optionally followed by a label in square brackets.
|
|
9
|
-
* @returns {string} The corresponding Antora xref resource ID, with the label preserved if present.
|
|
10
|
-
*
|
|
11
|
-
* @throws {Error} If the input is not a valid URL or does not belong to docs.redpanda.com.
|
|
12
|
-
*/
|
|
13
|
-
function urlToXref(input) {
|
|
14
|
-
// Peel off an optional “[label]”
|
|
15
|
-
let urlPart = input;
|
|
16
|
-
let label = '';
|
|
17
|
-
const mLabel = input.match(/^(.*)\[([^\]]+)\]$/);
|
|
18
|
-
if (mLabel) {
|
|
19
|
-
urlPart = mLabel[1];
|
|
20
|
-
label = mLabel[2];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
//Parse & validate
|
|
24
|
-
let url;
|
|
25
|
-
try {
|
|
26
|
-
url = new URL(urlPart);
|
|
27
|
-
} catch {
|
|
28
|
-
throw new Error(`Invalid URL: ${input}`);
|
|
29
|
-
}
|
|
30
|
-
if (!/docs\.redpanda\.com$/.test(url.hostname)) {
|
|
31
|
-
throw new Error(`Not a docs.redpanda.com URL: ${input}`);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Strip any leading “/docs”, “/docs/vX.Y”, “/docs/current”, “/vX.Y” or “/current”
|
|
35
|
-
let p = url.pathname.replace(
|
|
36
|
-
/^\/(?:docs(?:\/(?:v?\d+\.\d+|current))?|v?\d+\.\d+|current)\/?/,
|
|
37
|
-
''
|
|
38
|
-
);
|
|
39
|
-
// Drop trailing slash
|
|
40
|
-
p = p.replace(/\/$/, '');
|
|
41
|
-
const segments = p.split('/').filter(Boolean);
|
|
42
|
-
|
|
43
|
-
// Build module + path + .adoc
|
|
44
|
-
let xref;
|
|
45
|
-
if (segments.length === 0) {
|
|
46
|
-
xref = 'xref:index.adoc';
|
|
47
|
-
} else {
|
|
48
|
-
const moduleName = segments.shift();
|
|
49
|
-
const pagePath = segments.join('/');
|
|
50
|
-
const fileName = (pagePath || moduleName) + '.adoc';
|
|
51
|
-
xref = `xref:${moduleName}:${fileName}`;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Re-attach label if there was one
|
|
55
|
-
return label ? `${xref}[${label}]` : xref;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
module.exports = { urlToXref };
|