@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 +52 -19
- package/bin/mcp-tools/versions.js +1 -1
- package/cli-utils/enterprise-sources.js +6 -6
- package/cli-utils/install-test-dependencies.sh +58 -6
- package/extensions/REFERENCE.adoc +44 -1
- package/extensions/external-link-checker.js +181 -32
- package/extensions/url-to-xref.js +5 -0
- package/extensions/util/scan-content-urls.js +78 -12
- package/extensions/version-fetcher/set-latest-version.js +18 -4
- package/mcp/CLI_INTERFACE.adoc +1 -1
- package/mcp/USER_GUIDE.adoc +4 -4
- package/package.json +1 -1
- package/tools/bundle-openapi.js +28 -10
- package/tools/get-redpanda-version.js +14 -1
- package/tools/lint-strings/diff.js +67 -1
- package/tools/lint-strings/engine.js +6 -0
- package/tools/lint-strings/index.js +32 -2
- package/tools/property-extractor/Makefile +88 -42
- package/tools/property-extractor/README.adoc +1 -1
- package/tools/property-extractor/cloud_config.py +11 -7
- package/tools/rpk-docs/rpk-docs-handler.js +98 -16
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
|
|
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
|
-
* -
|
|
1061
|
-
*
|
|
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.
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
-
|
|
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
|
|
1294
|
-
*
|
|
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
|
|
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
|
|
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/
|
|
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/
|
|
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 (
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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/
|
|
224
|
-
const configurationHeader = await fetchText(`${RAW}/redpanda-data/
|
|
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
|
-
#
|
|
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
|
|
468
|
-
|
|
469
|
-
log_info "Detected ${os_name} ${arch_name},
|
|
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
|
-
-
|
|
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
|
-
|
|
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)
|
|
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, {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 (
|
|
86
|
-
|
|
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
|