@redpanda-data/docs-extensions-and-macros 5.23.0 → 5.30.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 CHANGED
@@ -119,7 +119,12 @@ programCli
119
119
  *
120
120
  * @requirements
121
121
  * - Internet connection to access GitHub API
122
- * - GitHub API rate limits apply (60 requests/hour unauthenticated)
122
+ * - A GitHub token with access to redpanda-data/streaming-enterprise, which is
123
+ * private. Redpanda releases are published there now, and the old public repo
124
+ * is frozen, so this command exits rather than report a stale version when it
125
+ * has no token. Resolved from GIT_CREDENTIALS, REDPANDA_GITHUB_TOKEN,
126
+ * ACTIONS_BOT_TOKEN, GITHUB_TOKEN, VBOT_GITHUB_API_TOKEN, or GH_TOKEN, in
127
+ * that priority order
123
128
  */
124
129
  programCli
125
130
  .command('get-redpanda-version')
@@ -1057,8 +1062,10 @@ automation
1057
1062
  * @requirements
1058
1063
  * - Python 3.9 or higher
1059
1064
  * - Git
1060
- * - Internet connection to clone Redpanda repository
1061
- * - For --cloud-support: GitHub token with repo permissions (GITHUB_TOKEN env var)
1065
+ * - A GitHub token (resolved from GIT_CREDENTIALS, REDPANDA_GITHUB_TOKEN, ACTIONS_BOT_TOKEN, GITHUB_TOKEN, VBOT_GITHUB_API_TOKEN, or GH_TOKEN, in that priority order) with
1066
+ * access to redpanda-data/streaming-enterprise, which is private
1067
+ * - Internet connection to clone the streaming-enterprise repository
1068
+ * - For --cloud-support: GitHub token with repo permissions (same resolution chain)
1062
1069
  * - For --cloud-support: Python packages pyyaml and requests
1063
1070
  */
1064
1071
  automation
@@ -1073,7 +1080,8 @@ automation
1073
1080
  .option('--regenerate-old-baseline', 'Re-extract the --diff tag from source instead of using the committed attachments/redpanda-properties-<oldTag>.json baseline')
1074
1081
  .option('--overrides <path>', 'Optional JSON file with property description overrides', 'docs-data/property-overrides.json')
1075
1082
  .option('--output-dir <dir>', 'Where to write all generated files', 'modules/reference')
1076
- .option('--cloud-support', 'Add AsciiDoc tags to generated property docs to indicate which ones are supported in Redpanda Cloud. This data is fetched from the cloudv2 repository so requires a GitHub token with repo permissions. Set the token as an environment variable using GITHUB_TOKEN, GH_TOKEN, or REDPANDA_GITHUB_TOKEN', true)
1083
+ .option('--cloud-support', 'Add AsciiDoc tags to generated property docs to indicate which ones are supported in Redpanda Cloud. This data is fetched from the cloudv2 repository so requires a GitHub token with repo permissions. The token is resolved from GIT_CREDENTIALS, REDPANDA_GITHUB_TOKEN, ACTIONS_BOT_TOKEN, GITHUB_TOKEN, VBOT_GITHUB_API_TOKEN, or GH_TOKEN, in that priority order', true)
1084
+ .option('--no-cloud-support', 'Skip Cloud support tags, and the cloudv2 repository access they need. A GitHub token is still required either way: the extractor clones the private streaming-enterprise repo to read the source')
1077
1085
  .option('--template-property <path>', 'Custom Handlebars template for individual property sections')
1078
1086
  .option('--template-topic-property <path>', 'Custom Handlebars template for topic property sections')
1079
1087
  .option('--template-topic-property-mappings <path>', 'Custom Handlebars template for topic property mappings table')
@@ -1091,17 +1099,35 @@ automation
1091
1099
 
1092
1100
  const newTag = options.tag || options.branch || 'dev'
1093
1101
 
1094
- if (options.cloudSupport) {
1095
- console.log('Validating cloud support dependencies...')
1096
- const { getGitHubToken } = require('../cli-utils/github-token')
1097
- const token = getGitHubToken()
1098
- if (!token) {
1099
- console.error('Error: Cloud support requires a GitHub token')
1100
- console.error(' Set: export GITHUB_TOKEN=your_token_here')
1101
- console.error(' Or disable cloud support with: --no-cloud-support')
1102
- process.exit(1)
1102
+ // Resolved once, via the same priority chain every other GitHub-fetching
1103
+ // command in this CLI already uses (cli-utils/github-token.js) --
1104
+ // notably GIT_CREDENTIALS, which is the token Antora/Netlify builds
1105
+ // actually populate, and which the Makefile's own narrower shell
1106
+ // fallback (REDPANDA_GITHUB_TOKEN/GITHUB_TOKEN/GH_TOKEN only) can't see.
1107
+ // Passed through as GH_TOKEN so the Makefile's clone step picks up
1108
+ // whatever this resolved, private-repo access included, without having
1109
+ // to duplicate the GIT_CREDENTIALS parsing logic in shell.
1110
+ const { getGitHubToken } = require('../cli-utils/github-token')
1111
+ const githubToken = getGitHubToken()
1112
+
1113
+ // A token is required whether or not Cloud metadata is: the extractor
1114
+ // clones the private streaming-enterprise repo to read the source either
1115
+ // way. --no-cloud-support only drops the extra cloudv2 read, so it is not
1116
+ // a remedy for having no token, and pointing at it as one sent people
1117
+ // into a run that got as far as the Makefile's clone step and died there.
1118
+ if (!githubToken) {
1119
+ console.error('Error: Generating property docs requires a GitHub token')
1120
+ console.error(' The extractor reads Redpanda source from the private')
1121
+ console.error(' redpanda-data/streaming-enterprise repository.')
1122
+ console.error(' Set GH_TOKEN, REDPANDA_GITHUB_TOKEN, or GITHUB_TOKEN to a token with access.')
1123
+ if (options.cloudSupport) {
1124
+ console.error(' Cloud support tags (on by default) additionally read the cloudv2 repository;')
1125
+ console.error(' --no-cloud-support skips those, but not the token requirement above.')
1103
1126
  }
1104
- console.log('Done: GitHub token validated')
1127
+ process.exit(1)
1128
+ }
1129
+ if (options.cloudSupport) {
1130
+ console.log('Done: GitHub token validated for source and cloudv2 access')
1105
1131
  }
1106
1132
 
1107
1133
  let oldTag = options.diff
@@ -1127,6 +1153,7 @@ automation
1127
1153
  console.log(`Building property docs for ${tag}…`)
1128
1154
  const args = ['build', `TAG=${tag}`]
1129
1155
  const env = { ...process.env }
1156
+ if (githubToken) env.GH_TOKEN = githubToken
1130
1157
  if (overrides) env.OVERRIDES = path.resolve(overrides)
1131
1158
  if (options.cloudSupport) env.CLOUD_SUPPORT = '1'
1132
1159
  if (templates.property) env.TEMPLATE_PROPERTY = path.resolve(templates.property)
@@ -1290,8 +1317,9 @@ automation
1290
1317
  *
1291
1318
  * @description
1292
1319
  * Generates comprehensive CLI reference documentation for rpk (Redpanda Keeper).
1293
- * Clones the Redpanda source, builds rpk with Go, and parses `rpk --print-tree` JSON output.
1294
- * Detects Linux-only commands by analyzing Go build tags in the source code.
1320
+ * Clones the Redpanda source from streaming-enterprise (private), builds rpk
1321
+ * with Go, and parses `rpk --print-tree` JSON output. Detects Linux-only
1322
+ * commands by analyzing Go build tags in the source code.
1295
1323
  *
1296
1324
  * Key features:
1297
1325
  * - Clones source from GitHub (sparse checkout for speed)
@@ -1325,6 +1353,9 @@ automation
1325
1353
  * @requirements
1326
1354
  * - Go must be installed (https://go.dev/)
1327
1355
  * - Git must be installed (for cloning source)
1356
+ * - A GitHub token (resolved from GIT_CREDENTIALS, REDPANDA_GITHUB_TOKEN, ACTIONS_BOT_TOKEN, GITHUB_TOKEN, VBOT_GITHUB_API_TOKEN, or GH_TOKEN, in that priority order) with
1357
+ * access to redpanda-data/streaming-enterprise, which is private (not
1358
+ * needed when --from-source points at an existing local checkout)
1328
1359
  */
1329
1360
  automation
1330
1361
  .command('rpk-docs')
@@ -1875,7 +1906,7 @@ automation
1875
1906
  *
1876
1907
  * @requirements
1877
1908
  * - GitHub token with access to redpanda-data/cloudv2-infra repository
1878
- * - Token must be set via GITHUB_TOKEN, GH_TOKEN, or REDPANDA_GITHUB_TOKEN environment variable
1909
+ * - Token is resolved from GIT_CREDENTIALS, REDPANDA_GITHUB_TOKEN, ACTIONS_BOT_TOKEN, GITHUB_TOKEN, VBOT_GITHUB_API_TOKEN, or GH_TOKEN, in that priority order
1879
1910
  * - Internet connection to access GitHub API
1880
1911
  */
1881
1912
  automation
@@ -2169,7 +2200,9 @@ automation
2169
2200
  * npx doc-tools generate bundle-openapi --tag $VERSION --surface both
2170
2201
  *
2171
2202
  * @requirements
2172
- * - Git to clone Redpanda repository
2203
+ * - Git to clone the streaming-enterprise repository
2204
+ * - A GitHub token (resolved from GIT_CREDENTIALS, REDPANDA_GITHUB_TOKEN, ACTIONS_BOT_TOKEN, GITHUB_TOKEN, VBOT_GITHUB_API_TOKEN, or GH_TOKEN, in that priority order) with
2205
+ * access to redpanda-data/streaming-enterprise, which is private
2173
2206
  * - Buf tool (automatically installed via npm)
2174
2207
  * - Redocly CLI or vacuum for OpenAPI bundling (automatically detected)
2175
2208
  * - Internet connection to clone repository
@@ -2180,7 +2213,7 @@ automation
2180
2213
  .description('Bundle Redpanda OpenAPI fragments for admin and connect APIs. Requires either --tag or --branch.')
2181
2214
  .option('-t, --tag <tag>', 'Git tag for released content')
2182
2215
  .option('-b, --branch <branch>', 'Branch name for in-progress content')
2183
- .option('--repo <url>', 'Repository URL', 'https://github.com/redpanda-data/redpanda.git')
2216
+ .option('--repo <url>', 'Repository URL. The default is a private repo, so requires a GitHub token (resolved from GIT_CREDENTIALS, REDPANDA_GITHUB_TOKEN, ACTIONS_BOT_TOKEN, GITHUB_TOKEN, VBOT_GITHUB_API_TOKEN, or GH_TOKEN, in that priority order)', 'https://github.com/redpanda-data/streaming-enterprise.git')
2184
2217
  .addOption(new Option('-s, --surface <surface>', 'Which API surfaces to bundle').choices(['admin', 'connect', 'both']).makeOptionMandatory())
2185
2218
  .option('--out-admin <path>', 'Output path for admin API', 'admin/redpanda-admin-api.yaml')
2186
2219
  .option('--out-connect <path>', 'Output path for connect API', 'connect/redpanda-connect-api.yaml')
@@ -74,7 +74,7 @@ function getRedpandaVersion(args = {}) {
74
74
  version,
75
75
  docker_tag: `docker.redpanda.com/redpandadata/${dockerRepo}:${version}`,
76
76
  is_beta: args.beta || false,
77
- notes_url: `https://github.com/redpanda-data/redpanda/releases/tag/${version}`,
77
+ notes_url: `https://github.com/redpanda-data/streaming-enterprise/releases/tag/${version}`,
78
78
  _modelRecommendation: 'haiku'
79
79
  };
80
80
 
@@ -17,13 +17,13 @@ const { getGitHubToken } = require('./github-token')
17
17
  const RAW = 'https://raw.githubusercontent.com'
18
18
 
19
19
  // Repos known to be private, so a 404 can legitimately mean "needs auth".
20
- // Public sources (redpanda core headers, connect info.csv) 404 only when the
21
- // file is genuinely missing, and suggesting credentials there sends the
22
- // reader after a problem that does not exist (e.g. during a transient
23
- // GitHub blip).
20
+ // Public sources (connect info.csv) 404 only when the file is genuinely
21
+ // missing, and suggesting credentials there sends the reader after a
22
+ // problem that does not exist (e.g. during a transient GitHub blip).
24
23
  const PRIVATE_REPO_PREFIXES = [
25
24
  `${RAW}/redpanda-data/docs/`,
26
25
  `${RAW}/redpanda-data/rp-connect-docs/`,
26
+ `${RAW}/redpanda-data/streaming-enterprise/`,
27
27
  ]
28
28
 
29
29
  const TOKEN_HINT = ' This source is in a private repository: set GIT_CREDENTIALS (or GITHUB_TOKEN / REDPANDA_GITHUB_TOKEN / ACTIONS_BOT_TOKEN) so the fetch can authenticate.'
@@ -220,8 +220,8 @@ async function loadEnterpriseSources (options, deps = {}) {
220
220
  const registryYaml = options.registry
221
221
  ? readLocal(options.registry)
222
222
  : await fetchText(`${RAW}/redpanda-data/docs/${options.docsRef}/shared/modules/ROOT/partials/enterprise-features.yml`)
223
- const coreHeader = await fetchText(`${RAW}/redpanda-data/redpanda/${options.tag}/src/v/features/enterprise_features.h`, 'core enterprise_features.h')
224
- const configurationHeader = await fetchText(`${RAW}/redpanda-data/redpanda/${options.tag}/src/v/config/configuration.h`, 'core configuration.h')
223
+ const coreHeader = await fetchText(`${RAW}/redpanda-data/streaming-enterprise/${options.tag}/src/v/features/enterprise_features.h`, 'core enterprise_features.h')
224
+ const configurationHeader = await fetchText(`${RAW}/redpanda-data/streaming-enterprise/${options.tag}/src/v/config/configuration.h`, 'core configuration.h')
225
225
 
226
226
  let connectRef = options.connectRef
227
227
  let infoCsv
@@ -462,21 +462,73 @@ install_rpk() {
462
462
  ;;
463
463
  esac
464
464
 
465
- # Construct download URL and filename
465
+ # rpk releases now publish to the private redpanda-data/streaming-enterprise,
466
+ # not the public redpanda-data/redpanda, so a token is required (same
467
+ # priority order as cli-utils/github-token.js's getGitHubToken(), minus
468
+ # GIT_CREDENTIALS, which is Antora's own credential store and doesn't
469
+ # apply to this standalone installer script).
470
+ local gh_token="${REDPANDA_GITHUB_TOKEN:-${ACTIONS_BOT_TOKEN:-${GITHUB_TOKEN:-${VBOT_GITHUB_API_TOKEN:-${GH_TOKEN:-}}}}}"
471
+ if [ -z "$gh_token" ]; then
472
+ log_warn "No GitHub token found (checked REDPANDA_GITHUB_TOKEN, ACTIONS_BOT_TOKEN, GITHUB_TOKEN, VBOT_GITHUB_API_TOKEN, GH_TOKEN)."
473
+ log_warn "rpk releases are now published to the private redpanda-data/streaming-enterprise repo and require authentication."
474
+ log_warn "Please install rpk manually:"
475
+ log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
476
+ return 1
477
+ fi
478
+
466
479
  local rpk_filename="rpk-${rpk_os}-${rpk_arch}.zip"
467
- local rpk_url="https://github.com/redpanda-data/redpanda/releases/latest/download/${rpk_filename}"
468
-
469
- log_info "Detected ${os_name} ${arch_name}, downloading ${rpk_filename}..."
470
-
480
+ local releases_api="https://api.github.com/repos/redpanda-data/streaming-enterprise/releases/latest"
481
+
482
+ log_info "Detected ${os_name} ${arch_name}, looking up the latest rpk release..."
483
+
484
+ # The Authorization header reaches curl through a config file on stdin
485
+ # (--config -), never as an -H argument: an argv header is readable by
486
+ # any process on the host (ps, /proc) for as long as the transfer runs.
487
+ local release_json
488
+ if ! release_json=$(curl -fsSL --retry 5 --retry-all-errors \
489
+ --connect-timeout 30 --max-time 60 --retry-max-time 120 \
490
+ --config - \
491
+ -H "Accept: application/vnd.github+json" \
492
+ "$releases_api" <<EOF
493
+ header = "Authorization: token ${gh_token}"
494
+ EOF
495
+ ); then
496
+ log_warn "Failed to query the latest release from streaming-enterprise"
497
+ log_warn "Please install rpk manually:"
498
+ log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
499
+ return 1
500
+ fi
501
+
502
+ # Resolve the asset's API url (not browser_download_url): only the API
503
+ # asset endpoint accepts the Authorization header this private repo needs.
504
+ local rpk_url
505
+ rpk_url=$(printf '%s' "$release_json" | jq -r --arg name "$rpk_filename" '.assets[] | select(.name == $name) | .url')
506
+ if [ -z "$rpk_url" ] || [ "$rpk_url" = "null" ]; then
507
+ log_warn "Latest streaming-enterprise release has no asset named ${rpk_filename}"
508
+ log_warn "Please install rpk manually:"
509
+ log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
510
+ return 1
511
+ fi
512
+
513
+ log_info "Downloading ${rpk_filename}..."
514
+
471
515
  # Try to download and install rpk.
472
516
  # Use -f so HTTP errors (e.g. 403/404/429/503) fail instead of writing an
473
517
  # error page into the zip, and retry with curl's default exponential
474
518
  # backoff (1s, 2s, 4s, ...) to ride out transient GitHub/CDN and network
475
519
  # errors, including rate limits that need time to clear. --retry-max-time
476
520
  # caps the whole retry window so CI fails fast on persistent errors.
521
+ # Accept: application/octet-stream is required on the API asset url to
522
+ # receive the binary itself instead of its JSON metadata.
523
+ # Same as the release lookup above: the token travels on stdin, not argv.
477
524
  if curl -fL --retry 5 --retry-all-errors \
478
525
  --connect-timeout 30 --max-time 300 --retry-max-time 600 \
479
- -o "$rpk_filename" "$rpk_url"; then
526
+ --config - \
527
+ -H "Accept: application/octet-stream" \
528
+ -o "$rpk_filename" "$rpk_url" <<EOF
529
+ header = "Authorization: token ${gh_token}"
530
+ EOF
531
+ then
480
532
  if unzip "$rpk_filename" 2>/dev/null; then
481
533
  mkdir -p ~/.local/bin
482
534
  if mv rpk ~/.local/bin/ 2>/dev/null; then
@@ -574,9 +574,35 @@ Responses are classified as follows:
574
574
  * 401/403/429: unverifiable — logged at info level, because bot-walled or rate-limited sites are not broken
575
575
  * Other statuses, network errors, and timeouts (after one retry): unverifiable — logged as a warning
576
576
 
577
+ === What is not checked
578
+
579
+ The extension runs at `contentClassified`, on raw AsciiDoc, before Asciidoctor converts anything. Three kinds of URL are therefore left alone, because checking them reports a broken link where the published page has no link at all:
580
+
581
+ Code blocks::
582
+ URLs inside a listing (`----`), literal (`....`), passthrough (`++++`) or fenced (`+```+`) block. A fenced block counts as one even when the opening delimiter carries a language info string, such as `+```bash+` or `+```yml+`.
583
+
584
+ Comments::
585
+ URLs on a line comment (`//`) or inside a comment block (`////`). Asciidoctor drops these before rendering, so a generator's provenance note, a Doc Detective test step, or a writer's editorial aside never becomes a link.
586
+
587
+ Unresolved attribute references::
588
+ A URL still holding a literal `{name}` reference, such as `+https://github.com/{project-github}/issues+`. Attributes are substituted after this hook runs, so the real target is unknown here while the published URL is usually fine. These are logged at info level with the pages that reference them, and counted separately, so a genuinely undefined attribute is still visible.
589
+
590
+ === Report attribution
591
+
592
+ Each finding names the repository, branch, and path the URL was found in, for example:
593
+
594
+ ----
595
+ Broken external link https://example.com/gone (HTTP 404) referenced in: redpanda-data/docs@main:modules/manage/pages/x.adoc
596
+ ----
597
+
598
+ An aggregated site builds the same Antora-relative path from several repositories and many version branches, so a path on its own does not identify a file well enough to act on.
599
+
577
600
  === Environment variables
578
601
 
579
- This extension does not require any environment variables.
602
+ LINK_CHECK::
603
+ Set to `true` to check links. Without it the extension registers and does nothing, so it can live in a playbook that preview builds also use without those builds making network requests.
604
+ +
605
+ This is an environment variable rather than a configuration option for two reasons: Antora does not interpolate environment variables into playbook values, and Antora consumes a playbook entry's own `enabled` key itself, before the config reaches the extension, so that name is not available.
580
606
 
581
607
  === Configuration options
582
608
 
@@ -589,6 +615,11 @@ List of regular expressions. When set, only URLs matching at least one pattern a
589
615
  exclude (optional)::
590
616
  List of regular expressions. URLs matching any pattern are skipped.
591
617
 
618
+ versions (optional)::
619
+ Which component versions to check. `latest` checks only each component's latest version, plus every unversioned component; `all` checks every version in the build. Default: `latest`.
620
+ +
621
+ A site that publishes many versions of a component republishes the same dead link once per branch, and those older branches are frozen content that nobody edits, so checking them fills the report with findings that cannot be acted on.
622
+
592
623
  concurrency (optional)::
593
624
  Number of simultaneous requests. Default: `10`.
594
625
 
@@ -598,6 +629,9 @@ Per-request timeout in milliseconds. Default: `10000`.
598
629
  fail_on_broken (optional)::
599
630
  Log broken links at error level so a build run with `--log-failure-level=error` fails. Default: `false`.
600
631
 
632
+ report_file (optional)::
633
+ Path to write a machine-readable JSON report to. Unset by default, in which case no report is written. Written every time the checker runs, including when there is nothing to say, so a consumer polling for this file can tell a clean run apart from one where the checker never ran at all. The report holds `counts`, a `results` array sorted broken-first, and `unresolvedAttributeReferences`; each result carries the URL, its classification and status, and a `refs` array of `{repo, refname, component, version, path}`. Intended for automation that has to act on the findings rather than read them.
634
+
601
635
  === Registration
602
636
 
603
637
  Register the `external-link-checker` extension in the Antora playbook under the `antora.extensions` key like so:
@@ -607,9 +641,18 @@ Register the `external-link-checker` extension in the Antora playbook under the
607
641
  antora:
608
642
  extensions:
609
643
  - require: '@redpanda-data/docs-extensions-and-macros/extensions/external-link-checker'
644
+ versions: latest
645
+ report_file: link-check.json
610
646
  fail_on_broken: false
611
647
  ----
612
648
 
649
+ That registration is inert on every build except one run with `LINK_CHECK=true`:
650
+
651
+ [source,bash]
652
+ ----
653
+ LINK_CHECK=true npx antora --fetch antora-playbook.yml
654
+ ----
655
+
613
656
  == Validate attributes
614
657
 
615
658
  This extension ensures the consistency and validity of page attributes, focusing on validating page categories against a predefined list of valid categories and subcategories. It automatically adds missing parent categories for any specified subcategories and removes any specified categories that are invalid. Additionally, it processes specific environment attributes, setting corresponding page-level attributes when environment conditions are met.
@@ -1,5 +1,8 @@
1
1
  'use strict'
2
2
 
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
3
6
  const { scanContentUrls } = require('./util/scan-content-urls')
4
7
  const { raiseListenerLimit } = require('./util/raise-listener-limit')
5
8
 
@@ -20,16 +23,34 @@ const { raiseListenerLimit } = require('./util/raise-listener-limit')
20
23
  * # internal_hostnames: ['docs.redpanda.com'] hosts to skip (default)
21
24
  * # include: [] regex allowlist; when set, only matching URLs are checked
22
25
  * # exclude: [] regex denylist
26
+ * # versions: 'latest' 'latest' checks only each component's latest
27
+ * # version (plus unversioned components); 'all'
28
+ * # checks every version in the build
23
29
  * # concurrency: 10 simultaneous requests
24
30
  * # timeout: 10000 per-request timeout in ms
25
31
  * # fail_on_broken: false log broken links at error level so a build
26
32
  * # run with --log-failure-level=error fails
33
+ * # report_file: '' path to write a machine-readable JSON report to
34
+ *
35
+ * Checking only happens when LINK_CHECK=true is set in the environment, so one
36
+ * playbook can serve both a scheduled link-check build and ordinary preview
37
+ * builds, which must not make network requests. It is an environment variable
38
+ * rather than a config option because Antora does not interpolate environment
39
+ * variables into playbook values, and because Antora consumes the playbook's
40
+ * own `enabled` key itself (generator-context.js strips it before the config
41
+ * reaches an extension), so that name is not available here.
27
42
  *
28
43
  * Classification:
29
44
  * - 2xx/3xx: ok
30
45
  * - 404/410: broken (warn, or error with fail_on_broken)
31
- * - 401/403/429: unverifiable (info) — bot-walled or rate-limited, not broken
46
+ * - 401/403/429: unverifiable (info) - bot-walled or rate-limited, not broken
32
47
  * - other statuses, network errors, timeouts (after one retry): unverifiable (warn)
48
+ *
49
+ * Why 'latest' is the default for versions: a site that publishes many
50
+ * versions of the same component republishes the same dead link once per
51
+ * branch, and the older branches are frozen content that nobody is going to
52
+ * edit. Checking them turns the report into a list of findings that cannot be
53
+ * acted on. Set versions: 'all' to see them anyway.
33
54
  */
34
55
 
35
56
  const USER_AGENT = 'redpanda-docs-link-checker'
@@ -37,59 +58,186 @@ const USER_AGENT = 'redpanda-docs-link-checker'
37
58
  module.exports.register = function ({ config = {} }) {
38
59
  raiseListenerLimit(this)
39
60
  const logger = this.getLogger('external-link-checker-extension')
61
+ // Inert unless asked for, so this can be registered in a playbook that
62
+ // preview builds also use. See the note above on why this is an environment
63
+ // variable and not a config option.
64
+ if (process.env.LINK_CHECK !== 'true') return
40
65
  const internalHostnames = new Set(config.internalHostnames || ['docs.redpanda.com'])
41
66
  const include = (config.include || []).map((pattern) => new RegExp(pattern))
42
67
  const exclude = (config.exclude || []).map((pattern) => new RegExp(pattern))
68
+ const versions = config.versions || 'latest'
43
69
  const concurrency = config.concurrency || 10
44
70
  const timeout = config.timeout || 10000
45
71
  const failOnBroken = Boolean(config.failOnBroken)
72
+ const reportFile = config.reportFile || null
46
73
 
47
74
  this.on('contentClassified', async ({ contentCatalog }) => {
48
- const urlReferences = collectExternalUrls(contentCatalog, { internalHostnames, include, exclude })
49
- if (!urlReferences.size) return
50
- const counts = { ok: 0, broken: 0, unverifiable: 0 }
51
- await runWithConcurrency([...urlReferences.keys()], concurrency, async (url) => {
52
- const verdict = await checkUrl(url, { timeout })
53
- counts[verdict.classification]++
54
- const pages = [...urlReferences.get(url)].join(', ')
55
- if (verdict.classification === 'broken') {
56
- const message = `Broken external link ${url} (HTTP ${verdict.status}) referenced in: ${pages}`
57
- failOnBroken ? logger.error(message) : logger.warn(message)
58
- } else if (verdict.classification === 'unverifiable') {
59
- const reason = verdict.status ? `HTTP ${verdict.status}` : verdict.reason
60
- const log = verdict.status && [401, 403, 429].includes(verdict.status) ? 'info' : 'warn'
61
- logger[log](`Could not verify external link ${url} (${reason}) referenced in: ${pages}`)
62
- }
75
+ const { urlReferences, unresolved } = collectExternalUrls(contentCatalog, {
76
+ internalHostnames,
77
+ include,
78
+ exclude,
79
+ versions,
63
80
  })
64
- logger.info(
65
- `Checked ${urlReferences.size} external links: ${counts.ok} ok, ${counts.broken} broken, ` +
66
- `${counts.unverifiable} unverifiable`
67
- )
81
+ // An unresolved attribute reference is not a broken link. This hook runs
82
+ // before Asciidoctor substitutes attributes, so {latest-operator-version}
83
+ // is still literal here while the published URL is fine. Report the count
84
+ // so a genuinely undefined attribute is still visible, but never fetch.
85
+ if (unresolved.size) {
86
+ logger.info(
87
+ `Skipped ${unresolved.size} URL(s) holding an unresolved attribute reference ` +
88
+ '(attributes are substituted after this hook runs)'
89
+ )
90
+ for (const [url, refs] of unresolved) {
91
+ logger.info(`Unchecked external link ${url} (unresolved attribute) referenced in: ${formatRefs(refs)}`)
92
+ }
93
+ }
94
+ const counts = { ok: 0, broken: 0, unverifiable: 0 }
95
+ const results = []
96
+ // Nothing to fetch when every URL was internal, excluded, or an
97
+ // unresolved attribute reference. Still fall through to writeReport
98
+ // below: a consumer polling for report_file (the fixer workflow) cannot
99
+ // tell a clean, nothing-to-check build apart from one where the checker
100
+ // never ran unless the file always exists when configured.
101
+ if (urlReferences.size) {
102
+ await runWithConcurrency([...urlReferences.keys()], concurrency, async (url) => {
103
+ const verdict = await checkUrl(url, { timeout })
104
+ counts[verdict.classification]++
105
+ const refs = urlReferences.get(url)
106
+ results.push({
107
+ url,
108
+ classification: verdict.classification,
109
+ status: verdict.status ?? null,
110
+ reason: verdict.reason ?? null,
111
+ refs: [...refs.values()],
112
+ })
113
+ const pages = formatRefs(refs)
114
+ if (verdict.classification === 'broken') {
115
+ const message = `Broken external link ${url} (HTTP ${verdict.status}) referenced in: ${pages}`
116
+ failOnBroken ? logger.error(message) : logger.warn(message)
117
+ } else if (verdict.classification === 'unverifiable') {
118
+ const reason = verdict.status ? `HTTP ${verdict.status}` : verdict.reason
119
+ const log = verdict.status && [401, 403, 429].includes(verdict.status) ? 'info' : 'warn'
120
+ logger[log](`Could not verify external link ${url} (${reason}) referenced in: ${pages}`)
121
+ }
122
+ })
123
+ logger.info(
124
+ `Checked ${urlReferences.size} external links: ${counts.ok} ok, ${counts.broken} broken, ` +
125
+ `${counts.unverifiable} unverifiable`
126
+ )
127
+ }
128
+ if (reportFile) writeReport(reportFile, { results, unresolved, counts, logger })
68
129
  })
69
130
  }
70
131
 
71
- function collectExternalUrls (contentCatalog, { internalHostnames, include, exclude }) {
132
+ /**
133
+ * Where a URL was found, in enough detail to act on it: which repository,
134
+ * which branch, and the path within that repository. A report line carrying
135
+ * only the Antora-relative path ('modules/reference/pages/x.adoc') is
136
+ * ambiguous across the several repositories and many version branches an
137
+ * aggregated site builds from, which makes it unusable for anything that has
138
+ * to open a pull request against the right one.
139
+ */
140
+ function fileReference (file) {
141
+ const src = file.src || {}
142
+ const origin = src.origin || {}
143
+ const repo = repoSlug(origin.url)
144
+ const startPath = origin.startPath || ''
145
+ const repoPath = startPath ? `${startPath}/${file.path}` : file.path
146
+ const refname = origin.refname || origin.branch || origin.tag || ''
147
+ const ref = {
148
+ repo,
149
+ refname,
150
+ component: src.component || '',
151
+ version: src.version || '',
152
+ path: repoPath,
153
+ }
154
+ // Without origin metadata (a synthetic catalog, or a local content source
155
+ // with no git remote) the path is all there is to report.
156
+ const label = repo ? `${repo}${refname ? `@${refname}` : ''}:${repoPath}` : repoPath
157
+ return { label, ref }
158
+ }
159
+
160
+ /** 'https://github.com/redpanda-data/docs.git' -> 'redpanda-data/docs' */
161
+ function repoSlug (url) {
162
+ if (!url || typeof url !== 'string') return ''
163
+ const match = url.match(/[/:]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/)
164
+ return match ? `${match[1]}/${match[2]}` : ''
165
+ }
166
+
167
+ function formatRefs (refs) {
168
+ return [...refs.keys()].join(', ')
169
+ }
170
+
171
+ /**
172
+ * Maps each component to its latest version, so non-latest content can be
173
+ * skipped. Catalogs without getComponents (unit-test doubles) fall back to
174
+ * checking everything.
175
+ */
176
+ function latestVersions (contentCatalog) {
177
+ if (typeof contentCatalog.getComponents !== 'function') return null
178
+ const latest = new Map()
179
+ for (const component of contentCatalog.getComponents()) {
180
+ latest.set(component.name, component.latest ? component.latest.version : '')
181
+ }
182
+ return latest
183
+ }
184
+
185
+ function collectExternalUrls (contentCatalog, { internalHostnames, include, exclude, versions = 'all' }) {
72
186
  const urlReferences = new Map()
187
+ const unresolved = new Map()
188
+ const latest = versions === 'latest' ? latestVersions(contentCatalog) : null
73
189
  const files = contentCatalog
74
190
  .getPages((page) => page.out)
75
191
  .concat(contentCatalog.findBy({ family: 'partial' }))
76
192
  for (const file of files) {
77
193
  if (!file.contents) continue
194
+ const src = file.src || {}
195
+ // An unversioned component has an empty version and is always current.
196
+ if (latest && src.version && latest.has(src.component) && latest.get(src.component) !== src.version) continue
197
+ const { label, ref } = fileReference(file)
78
198
  for (const match of scanContentUrls(file.contents.toString())) {
79
- let url
80
- try {
81
- url = new URL(match.url)
82
- } catch {
83
- continue
199
+ const target = match.hasAttributeReference ? unresolved : urlReferences
200
+ if (!match.hasAttributeReference) {
201
+ let url
202
+ try {
203
+ url = new URL(match.url)
204
+ } catch {
205
+ continue
206
+ }
207
+ if (internalHostnames.has(url.hostname)) continue
208
+ if (include.length && !include.some((pattern) => pattern.test(match.url))) continue
209
+ if (exclude.some((pattern) => pattern.test(match.url))) continue
84
210
  }
85
- if (internalHostnames.has(url.hostname)) continue
86
- if (include.length && !include.some((pattern) => pattern.test(match.url))) continue
87
- if (exclude.some((pattern) => pattern.test(match.url))) continue
88
- if (!urlReferences.has(match.url)) urlReferences.set(match.url, new Set())
89
- urlReferences.get(match.url).add(file.path)
211
+ if (!target.has(match.url)) target.set(match.url, new Map())
212
+ target.get(match.url).set(label, ref)
90
213
  }
91
214
  }
92
- return urlReferences
215
+ return { urlReferences, unresolved }
216
+ }
217
+
218
+ function writeReport (reportFile, { results, unresolved, counts, logger }) {
219
+ const report = {
220
+ generatedAt: new Date().toISOString(),
221
+ counts,
222
+ // Broken first, then unverifiable, so a consumer reading the head of the
223
+ // list gets the actionable findings.
224
+ results: results.sort((a, b) => rank(a.classification) - rank(b.classification) || a.url.localeCompare(b.url)),
225
+ unresolvedAttributeReferences: [...unresolved].map(([url, refs]) => ({ url, refs: [...refs.values()] })),
226
+ }
227
+ try {
228
+ const dir = path.dirname(reportFile)
229
+ if (dir && dir !== '.') fs.mkdirSync(dir, { recursive: true })
230
+ fs.writeFileSync(reportFile, `${JSON.stringify(report, null, 2)}\n`)
231
+ logger.info(`Wrote link check report to ${reportFile}`)
232
+ } catch (error) {
233
+ // The report is an aid for downstream automation; failing the build over
234
+ // it would hide the link findings the build just produced.
235
+ logger.warn(`Could not write link check report to ${reportFile}: ${error.message}`)
236
+ }
237
+ }
238
+
239
+ function rank (classification) {
240
+ return classification === 'broken' ? 0 : classification === 'unverifiable' ? 1 : 2
93
241
  }
94
242
 
95
243
  async function checkUrl (url, { timeout, fetchFn = module.exports._fetch }) {
@@ -130,3 +278,4 @@ async function runWithConcurrency (items, limit, task) {
130
278
  module.exports._fetch = (...args) => fetch(...args)
131
279
  module.exports.checkUrl = checkUrl
132
280
  module.exports.collectExternalUrls = collectExternalUrls
281
+ module.exports.repoSlug = repoSlug
@@ -483,6 +483,11 @@ function convertContent (content, resolverContext) {
483
483
  continue
484
484
  }
485
485
  if (!hostnames.has(url.hostname) || match.inAttributeEntry || match.inAttributeValue) continue
486
+ // The path still holds a literal {attribute} reference: attributes are
487
+ // substituted after this hook, so the real target is unknown here. It
488
+ // cannot be looked up in the catalog, and reporting it as unmatched would
489
+ // be wrong when the substituted URL resolves fine.
490
+ if (match.hasAttributeReference) continue
486
491
  if (ignore.some((pattern) => pattern.test(url.pathname))) continue
487
492
  // An xref target cannot carry a query string, and dropping one would
488
493
  // change the link: the docs UI reads parameters such as