@redpanda-data/docs-extensions-and-macros 5.8.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.
@@ -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,44 @@
1
+ 'use strict'
2
+
3
+ /* Antora extension that warms the prop macro's property-page index before
4
+ * document conversion starts.
5
+ *
6
+ * The prop macro discovers which reference page documents each property by
7
+ * reading page sources for include:: lines and partials for property
8
+ * headings. Antora replaces each page's contents with converted HTML as
9
+ * conversion proceeds, so an index built lazily by the first macro call can
10
+ * miss includes on pages that were converted earlier — the result silently
11
+ * depends on conversion order. Building the index at contentClassified,
12
+ * while every page still holds its AsciiDoc source, removes the ordering
13
+ * hazard entirely. The macro finds the warmed cache and never rebuilds it.
14
+ *
15
+ * Register under antora.extensions (NOT asciidoc.extensions):
16
+ *
17
+ * antora:
18
+ * extensions:
19
+ * - require: '@redpanda-data/docs-extensions-and-macros/extensions/property-page-index'
20
+ */
21
+
22
+ const { buildPageIndex, loadPropertiesFor } = require('../macros/prop')
23
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
24
+
25
+ module.exports.register = function () {
26
+ raiseListenerLimit(this)
27
+ const logger = this.getLogger('property-page-index-extension')
28
+
29
+ this.once('contentClassified', ({ contentCatalog }) => {
30
+ let indexed = 0
31
+ for (const component of contentCatalog.getComponents()) {
32
+ for (const componentVersion of component.versions) {
33
+ const registry = loadPropertiesFor(contentCatalog, component.name, componentVersion.version)
34
+ if (!registry) continue
35
+ const index = buildPageIndex(contentCatalog, component.name, registry.properties, componentVersion.version)
36
+ if (index.size > 0) {
37
+ indexed++
38
+ logger.debug(`Indexed ${index.size} properties for ${component.name}@${componentVersion.version || 'default'}`)
39
+ }
40
+ }
41
+ }
42
+ if (indexed > 0) logger.info(`Property page index warmed for ${indexed} component version(s)`)
43
+ })
44
+ }
@@ -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