@redpanda-data/docs-extensions-and-macros 5.55.0 → 5.56.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.
Files changed (53) hide show
  1. package/bin/doc-tools.js +315 -7
  2. package/cli-utils/docs-links-to-xrefs.js +174 -0
  3. package/cli-utils/generated-marker.js +43 -0
  4. package/cli-utils/helm-commented-values.js +37 -3
  5. package/extension-utils/llms-utils.js +6 -2
  6. package/extension-utils/metadata-candidates.js +304 -0
  7. package/extension-utils/metadata-check.js +174 -0
  8. package/extension-utils/metadata-suggest.js +573 -0
  9. package/extension-utils/taxonomy-categories.js +106 -0
  10. package/extension-utils/taxonomy.js +566 -0
  11. package/extensions/REFERENCE.adoc +90 -4
  12. package/extensions/algolia-indexer/generate-index.js +13 -1
  13. package/extensions/algolia-indexer/index.js +4 -2
  14. package/extensions/convert-sitemap-to-markdown.js +47 -12
  15. package/extensions/convert-to-markdown.js +176 -0
  16. package/extensions/generate-fields-only-pages.js +14 -0
  17. package/extensions/modify-connect-tag-playbook.js +2 -2
  18. package/extensions/url-to-xref.js +92 -22
  19. package/extensions/validate-attributes.js +10 -1
  20. package/package.json +2 -1
  21. package/tools/check-metadata/index.js +523 -0
  22. package/tools/cloud-regions/cloud-regions-table-adoc.hbs +1 -2
  23. package/tools/cloud-regions/cloud-regions-table-md.hbs +2 -2
  24. package/tools/cloud-regions/render-cloud-regions.js +3 -0
  25. package/tools/dispatch-graph/check-dispatch-graph.js +790 -0
  26. package/tools/dispatch-graph/classify-runs.js +128 -0
  27. package/tools/dispatch-graph/parse-workflows.js +499 -0
  28. package/tools/enterprise-features/detect-gating-changes.js +393 -0
  29. package/tools/enterprise-features/verify.js +2 -1
  30. package/tools/gen-rpk-ascii.py +7 -1
  31. package/tools/lint-strings/fixtures/connect/lint_connect.go +8 -0
  32. package/tools/lint-strings/fixtures/crd/lint_types.go +4 -1
  33. package/tools/lint-strings/fixtures/rpk-pr461/delete.base.go +42 -0
  34. package/tools/lint-strings/fixtures/rpk-pr461/delete.head.go +45 -0
  35. package/tools/lint-strings/fixtures/rpk-pr461/reviewer-comments.json +1 -0
  36. package/tools/lint-strings/index.js +133 -3
  37. package/tools/lint-strings/rules/verbatim-asciidoc.js +166 -1
  38. package/tools/lint-strings/surfaces/connect.js +72 -12
  39. package/tools/lint-strings/surfaces/crd.js +10 -6
  40. package/tools/metrics/metrics.py +11 -0
  41. package/tools/property-extractor/generate-handlebars-docs.js +9 -1
  42. package/tools/property-extractor/helpers/applyPropertyLinks.js +35 -0
  43. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +29 -8
  44. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +19 -24
  45. package/tools/redpanda-connect/templates/bloblang-function.hbs +1 -1
  46. package/tools/redpanda-connect/templates/descriptions-partials.hbs +1 -1
  47. package/tools/redpanda-connect/templates/examples-partials.hbs +1 -1
  48. package/tools/redpanda-connect/templates/fields-partials.hbs +2 -2
  49. package/tools/redpanda-connect/templates/metadata-partials.hbs +1 -1
  50. package/tools/rpk-docs/generate-rpk-docs.js +2 -0
  51. package/tools/rpk-docs/generate-x-env-partial.js +7 -4
  52. package/tools/rpk-docs/helpers/index.js +4 -1
  53. package/tools/rpk-docs/templates/command.hbs +1 -1
package/bin/doc-tools.js CHANGED
@@ -43,6 +43,7 @@ const { getAntoraValue, setAntoraValue } = require('../cli-utils/antora-utils')
43
43
  const programCli = new Command()
44
44
 
45
45
  const pkg = require('../package.json')
46
+ const { generatedMarker } = require('../cli-utils/generated-marker')
46
47
  programCli
47
48
  .name('doc-tools')
48
49
  .description('Redpanda Document Automation CLI')
@@ -755,6 +756,111 @@ programCli
755
756
  console.log(' mcp/README.adoc')
756
757
  })
757
758
 
759
+ /**
760
+ * check-dispatch-graph
761
+ *
762
+ * @description
763
+ * Health check for the repository_dispatch graph that connects Redpanda's
764
+ * engineering repos to its docs repos. Reads every workflow file on each
765
+ * configured repo's default branch, pairs the senders (peter-evans/repository-dispatch,
766
+ * `gh api .../dispatches`, curl, github-script) with the listeners
767
+ * (`on.repository_dispatch.types`), and reads each workflow's recent runs from
768
+ * the Actions API. Reports senders with no listener, docs listeners with no
769
+ * sender (informational when a cron backstops them), senders that have never
770
+ * fired, receivers failing more than the threshold, receiver payload variants
771
+ * no sender dispatches, and edges dispatched from more than one repo.
772
+ *
773
+ * @why
774
+ * A repository_dispatch is accepted with HTTP 204 whether or not a listener
775
+ * exists, and a receiver that fails every run looks, from the sender's side,
776
+ * exactly like one that works. Breaks in this graph went unnoticed for weeks
777
+ * until someone read the receivers' run history by hand. This command reads it
778
+ * by script, and .github/workflows/check-dispatch-graph.yml runs it weekly,
779
+ * files one issue, and closes it on the first run that finds the graph healthy.
780
+ *
781
+ * Exit codes: 0 clean, 1 findings (the output ends with the line
782
+ * DISPATCH_GRAPH_FINDINGS_CONFIRMED), 2 could not run (a repo or runs query
783
+ * could not be read, so the graph is incomplete; never reported as clean).
784
+ *
785
+ * @example
786
+ * # Check the default repo set against the live org
787
+ * export REDPANDA_GITHUB_TOKEN=... # or GH_TOKEN; must read the private docs repos
788
+ * npx doc-tools check-dispatch-graph
789
+ *
790
+ * # Machine-readable
791
+ * npx doc-tools check-dispatch-graph --json > dispatch-graph.json
792
+ *
793
+ * # A narrower set of repos (role decides which side a missing counterpart is reported on)
794
+ * npx doc-tools check-dispatch-graph --repos redpanda-data/helm-charts:engineering,redpanda-data/docs:docs
795
+ *
796
+ * @requirements
797
+ * - A GitHub token that can read every configured repo and its Actions runs
798
+ * (REDPANDA_GITHUB_TOKEN, ACTIONS_BOT_TOKEN, GITHUB_TOKEN or GH_TOKEN). The
799
+ * docs repos are private, so an unauthenticated run exits 2.
800
+ */
801
+ programCli
802
+ .command('check-dispatch-graph')
803
+ .description('Check the repository_dispatch graph between engineering and docs repos: senders without listeners, listeners without senders, never-fired senders, failing receivers (exit 0 clean, 1 findings, 2 could not run)')
804
+ .option('--repos <list>', 'Comma-separated owner/name[:role] entries (role engineering or docs) replacing the default repo set')
805
+ .option('--fail-threshold <fraction>', 'Flag a receiver whose fail rate over the window is above this fraction', '0.5')
806
+ .option('--window <n>', 'How many of the newest runs the fail rate is computed over', '20')
807
+ .option('--per-page <n>', 'How many runs to fetch per workflow (max 100)', '50')
808
+ .option('--json', 'Print the full result as JSON instead of the table')
809
+ .action(async (options) => {
810
+ const { runCheck, formatReport, createGitHubApi, DEFAULT_REPOS, SENTINEL } = require('../tools/dispatch-graph/check-dispatch-graph')
811
+
812
+ let repos = DEFAULT_REPOS
813
+ if (options.repos) {
814
+ repos = String(options.repos).split(',').map((entry) => entry.trim()).filter(Boolean).map((entry) => {
815
+ const [repo, role = 'docs'] = entry.split(':')
816
+ if (!/^[^/]+\/[^/]+$/.test(repo) || !['engineering', 'docs'].includes(role)) {
817
+ console.error(`Error: --repos entry "${entry}" must be owner/name or owner/name:engineering|docs`)
818
+ process.exit(2)
819
+ }
820
+ return { repo, role }
821
+ })
822
+ }
823
+ const threshold = Number(options.failThreshold)
824
+ const window = Number(options.window)
825
+ const perPage = Number(options.perPage)
826
+ if (!(threshold >= 0 && threshold <= 1) || !(window > 0) || !(perPage > 0 && perPage <= 100)) {
827
+ console.error('Error: --fail-threshold must be 0..1, --window > 0, --per-page 1..100')
828
+ process.exit(2)
829
+ }
830
+
831
+ // Exit 2 is reserved for "could not find out", so the weekly job can tell
832
+ // "an edge is broken" apart from "the API was down" and not file a false
833
+ // issue. An unauthenticated run cannot read the private docs repos and
834
+ // lands here too; it must never pass as clean.
835
+ let result
836
+ try {
837
+ const octokit = require('../cli-utils/octokit-client')
838
+ result = await runCheck({ repos, api: createGitHubApi(octokit), threshold, window, perPage })
839
+ } catch (err) {
840
+ console.error(`Error: Could not check the dispatch graph: ${err.message}`)
841
+ process.exit(2)
842
+ }
843
+
844
+ if (options.json) {
845
+ console.log(JSON.stringify(result, null, 2))
846
+ } else {
847
+ console.log(formatReport(result))
848
+ }
849
+
850
+ if (result.exitCode === 2) {
851
+ console.error(`\nError: ${result.unreadable.length} repo(s) or run record(s) could not be read, so the graph is incomplete. Not a clean result.`)
852
+ process.exit(2)
853
+ }
854
+ if (result.exitCode === 1) {
855
+ // Printed only where findings were actually established; the weekly
856
+ // script requires this line before filing an issue, so a usage error or
857
+ // a crash (both exit 1 under Commander) can never file one.
858
+ if (!options.json) console.log(`\n${SENTINEL}`)
859
+ process.exit(1)
860
+ }
861
+ process.exit(0)
862
+ })
863
+
758
864
  // ====================================================================
759
865
  // GENERATE SUBCOMMAND GROUP
760
866
  // ====================================================================
@@ -1824,17 +1930,25 @@ automation
1824
1930
  const outFile = path.join(outDir, `k-${name}${opts.outputSuffix}`)
1825
1931
  console.log(`pandoc ${md} → ${outFile}`)
1826
1932
  fs.mkdirSync(path.dirname(outFile), { recursive: true })
1827
- r = spawnSync('pandoc', [md, '-t', 'asciidoc', '-o', outFile], { stdio: 'inherit' })
1828
- if (r.status !== 0) process.exit(r.status)
1933
+ const { markdownToAsciiDoc } = require('../cli-utils/helm-commented-values')
1934
+ let converted
1935
+ try {
1936
+ converted = markdownToAsciiDoc(fs.readFileSync(md, 'utf8')) + '\n'
1937
+ } catch (err) {
1938
+ console.error(`Error: ${err.message}`)
1939
+ process.exit(1)
1940
+ }
1829
1941
 
1830
1942
  const { formatHelmSpec } = require('../cli-utils/format-helm-spec')
1831
- let doc = formatHelmSpec(fs.readFileSync(outFile, 'utf8'))
1943
+ let doc = formatHelmSpec(converted)
1832
1944
 
1833
1945
  // helm-docs only renders keys that exist in the YAML tree, so optional
1834
1946
  // values that ship commented out never appear in the reference. Pull
1835
1947
  // documented commented-out keys from values.yaml and inject them. This
1836
1948
  // runs after formatHelmSpec so the injected body sections are not affected
1837
- // by the blank-line normalization that its header fix performs.
1949
+ // by the blank-line normalization that its header fix performs. The
1950
+ // comments are Markdown, so each one goes through markdownToAsciiDoc on
1951
+ // the way in, like the README did.
1838
1952
  const valuesFile = path.join(chartPath, 'values.yaml')
1839
1953
  if (fs.existsSync(valuesFile)) {
1840
1954
  const { extractCommentedValueDocs, injectIntoAsciiDoc, filterEntriesBySchema } = require('../cli-utils/helm-commented-values')
@@ -1856,7 +1970,7 @@ automation
1856
1970
  }
1857
1971
 
1858
1972
  if (entries.length > 0) {
1859
- const { doc: withInjected, injected, sectionsFound } = injectIntoAsciiDoc(doc, entries)
1973
+ const { doc: withInjected, injected, sectionsFound } = injectIntoAsciiDoc(doc, entries, { toAsciiDoc: markdownToAsciiDoc })
1860
1974
  doc = withInjected
1861
1975
  if (injected.length > 0) {
1862
1976
  console.log(`Documented ${injected.length} commented-out value(s): ${injected.join(', ')}`)
@@ -1871,6 +1985,10 @@ automation
1871
1985
  }
1872
1986
  }
1873
1987
 
1988
+ // Same marker shape as every doc-tools generator; convert-to-markdown
1989
+ // reads it and reports the provenance in the Markdown twins.
1990
+ const helmMarker = generatedMarker('helm-docs', `the ${name} chart's values.yaml and README in redpanda-data/redpanda-operator`) + '\n\n'
1991
+ doc = helmMarker + doc
1874
1992
  fs.writeFileSync(outFile, doc, 'utf8')
1875
1993
 
1876
1994
  console.log(`Done: Wrote ${outFile}`)
@@ -2134,6 +2252,12 @@ automation
2134
2252
  * - Use --tag for released content (GA or beta releases). Tags reference specific release points.
2135
2253
  * - Use --branch for in-progress content (unreleased features). Branches track ongoing development.
2136
2254
  *
2255
+ * Links: the Go doc comments link to docs.redpanda.com with absolute URLs. When the command
2256
+ * runs inside a docs checkout (or is given --docs-root), every URL that matches a page of that
2257
+ * checkout, directly or through a page alias, is rewritten as an xref so the committed page
2258
+ * keeps one link form across regenerations. URLs that match no page are left as written and
2259
+ * listed in the output.
2260
+ *
2137
2261
  * @why
2138
2262
  * Kubernetes CRDs define complex APIs for deploying and managing Redpanda. The API schema
2139
2263
  * is defined in Go code with hundreds of fields across nested structures. Manual documentation
@@ -2168,6 +2292,7 @@ automation
2168
2292
  * - For GitHub URLs: Git and internet connection
2169
2293
  * - crd-ref-docs tool (automatically installed if missing)
2170
2294
  * - Go toolchain for running crd-ref-docs
2295
+ * - To convert links: a docs checkout with Antora installed (npm install in redpanda-data/docs)
2171
2296
  */
2172
2297
  automation
2173
2298
  .command('crd-spec')
@@ -2178,6 +2303,7 @@ automation
2178
2303
  .option('-d, --depth <n>', 'How many levels deep', '10')
2179
2304
  .option('--templates-dir <dir>', 'Asciidoctor templates dir', '.github/crd-config/templates/asciidoctor/operator')
2180
2305
  .option('--output <file>', 'Where to write the generated AsciiDoc file', 'modules/reference/pages/k-crd.adoc')
2306
+ .option('--docs-root <dir>', 'Docs checkout whose pages docs.redpanda.com URLs are converted into xrefs against (default: the current repo when run inside redpanda-data/docs)')
2181
2307
  .action(async (opts) => {
2182
2308
  verifyCrdDependencies()
2183
2309
 
@@ -2286,8 +2412,28 @@ automation
2286
2412
  process.exit(1)
2287
2413
  }
2288
2414
 
2289
- // docs.redpanda.com URLs are left as-is: the url-to-xref Antora
2290
- // extension converts them to validated xrefs at site build time.
2415
+ // Go doc comments carry absolute docs.redpanda.com URLs (a Go comment
2416
+ // cannot hold an xref). Convert them against the docs checkout so the
2417
+ // committed page keeps one link form across regenerations, instead of
2418
+ // flipping between xrefs and URLs and burying the real changes in the
2419
+ // review diff (docs 2256). A URL that matches no page in the checkout is
2420
+ // left as written and listed; the url-to-xref extension still converts
2421
+ // cross-component URLs at site build time and warns about broken ones.
2422
+ const docsRoot = opts.docsRoot || (inDocs ? repoRoot : null)
2423
+ if (docsRoot) {
2424
+ const { buildDocsResolverContext, convertDocsLinksInFile, formatDocsLinkReport } = require('../cli-utils/docs-links-to-xrefs.js')
2425
+ let linkContext
2426
+ try {
2427
+ linkContext = await buildDocsResolverContext(docsRoot)
2428
+ } catch (err) {
2429
+ console.error(`Error: Cannot map docs URLs to xrefs: ${err.message}`)
2430
+ process.exit(1)
2431
+ }
2432
+ console.log(`Mapping docs URLs against ${linkContext.pageCount} pages of the ${linkContext.component} component in ${docsRoot}`)
2433
+ console.log(formatDocsLinkReport(convertDocsLinksInFile(opts.output, linkContext), opts.output))
2434
+ } else {
2435
+ console.warn('⚠️ No docs checkout to map against (pass --docs-root); docs.redpanda.com URLs are left as written.')
2436
+ }
2291
2437
 
2292
2438
  if (tmpSrc) fs.rmSync(tmpSrc, { recursive: true, force: true })
2293
2439
  fs.rmSync(configTmp, { recursive: true, force: true })
@@ -2875,12 +3021,67 @@ programCli
2875
3021
  .option('--skip-rules <list>', 'Comma-separated rule ids to skip')
2876
3022
  .option('--only-rules <list>', 'Comma-separated rule ids to run exclusively')
2877
3023
  .option('--reviewed <file>', 'Diff mode: skip declarations whose fingerprint is listed in <file> (already reviewed on an earlier push)')
3024
+ .option('--reviewer-comments <file>', 'Diff mode: skip declarations whose text a human reviewer wrote in one of the comment bodies in <file> (a JSON array of strings)')
2878
3025
  .option('--strict', 'Exit 1 when any error-severity finding exists (default: always exit 0 - suggest, never block)')
2879
3026
  .action((options) => {
2880
3027
  const { runCli } = require('../tools/lint-strings')
2881
3028
  runCli(options)
2882
3029
  })
2883
3030
 
3031
+ /**
3032
+ * detect-enterprise-gating
3033
+ *
3034
+ * @description
3035
+ * Find enterprise gating changes in a Redpanda core diff that no doc string
3036
+ * announces: a value added to or removed from license_required_feature in
3037
+ * src/v/features/enterprise_features.h, a property wrapped in or unwrapped
3038
+ * from config::enterprise<> in src/v/config/configuration.h, or a changed
3039
+ * restricted value of an enterprise<> property in
3040
+ * src/v/config/configuration.cc. Prints them as a
3041
+ * doc-impact report, the JSON the doc-strings review dispatches to the docs
3042
+ * team's Jira router, so a licensing change reaches a DOC ticket even when
3043
+ * the review has no doc string to look at.
3044
+ *
3045
+ * @example
3046
+ * npx doc-tools detect-enterprise-gating --repo . --diff origin/dev
3047
+ *
3048
+ * @exit 0 always: `{"findings":[]}` means nothing to report. Errors from git
3049
+ * print to stderr and still exit 0 with an empty report, so a caller's
3050
+ * fail-open gate stays closed rather than red.
3051
+ */
3052
+ programCli
3053
+ .command('detect-enterprise-gating')
3054
+ .description('Find enterprise gating changes (license_required_feature values, config::enterprise<> wrappers and restricted values) in a core diff and print them as a doc-impact report')
3055
+ .requiredOption('--repo <path>', 'Path to a Redpanda core checkout. Nothing is cloned.')
3056
+ .requiredOption('--diff <base>', 'Base ref: changes in <base>...HEAD are inspected')
3057
+ .option('--format <format>', 'Output format: json or human', 'json')
3058
+ .action((options) => {
3059
+ const { spawnSync } = require('child_process')
3060
+ const { detectGatingChanges, buildDocImpact, enterprisePropertyNames, ENUM_HEADER, CONFIG_HEADER, CONFIG_SOURCE } = require('../tools/enterprise-features/detect-gating-changes')
3061
+ const empty = { findings: [] }
3062
+ const result = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', `${options.diff}...HEAD`, '--', ENUM_HEADER, CONFIG_HEADER, CONFIG_SOURCE], {
3063
+ cwd: options.repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024
3064
+ })
3065
+ if (result.status !== 0) {
3066
+ console.error(`git diff ${options.diff}...HEAD failed in ${options.repo}: ${(result.stderr || '').trim()}`)
3067
+ console.log(JSON.stringify(empty))
3068
+ return
3069
+ }
3070
+ // A configuration.cc change is tied to an enterprise property through
3071
+ // the header at HEAD. Without it, header and enum changes still report.
3072
+ const header = spawnSync('git', ['show', `HEAD:${CONFIG_HEADER}`], { cwd: options.repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
3073
+ if (header.status !== 0) console.error(`git show HEAD:${CONFIG_HEADER} failed in ${options.repo}; configuration.cc changes are not checked: ${(header.stderr || '').trim()}`)
3074
+ const enterpriseNames = header.status === 0 ? enterprisePropertyNames(header.stdout) : []
3075
+ const findings = detectGatingChanges(result.stdout, { enterpriseNames })
3076
+ const report = buildDocImpact(findings) || empty
3077
+ if (options.format === 'human') {
3078
+ if (findings.length === 0) { console.log('No enterprise gating changes.'); return }
3079
+ for (const f of findings) console.log(`${f.change_kind}: ${f.name}`)
3080
+ return
3081
+ }
3082
+ console.log(JSON.stringify(report, null, 2))
3083
+ })
3084
+
2884
3085
  /**
2885
3086
  * doc-impact-recording
2886
3087
  *
@@ -3008,6 +3209,113 @@ programCli
3008
3209
  runCli(log, options)
3009
3210
  })
3010
3211
 
3212
+ /**
3213
+ * check-metadata
3214
+ *
3215
+ * @description
3216
+ * Check the taxonomy metadata in AsciiDoc page headers against the docs
3217
+ * taxonomy (taxonomy.yml in the docs repository). On every page,
3218
+ * `:page-categories:` values must resolve to a concept: an unknown value, a
3219
+ * deprecated value, or two values that are the same concept warn, and each
3220
+ * message names the replacement. On a solution overview
3221
+ * (`:page-layout: solution`), `:page-solution-use-cases:`,
3222
+ * `:page-solution-industries:`, `:page-solution-technologies:`, and
3223
+ * `:page-solution-platforms:` must resolve within their own facet, and an
3224
+ * unknown value or a duplicate concept is an error. `:page-topic-type:` is
3225
+ * checked against the content types, as a warning. --strict turns every
3226
+ * warning into an error. The taxonomy comes from --taxonomy (a path or URL),
3227
+ * else the docs checkout at the current directory, else $REDPANDA_DOCS_PATH,
3228
+ * else the published file. The build runs the same rules
3229
+ * (extension-utils/metadata-check.js), so the two never disagree.
3230
+ * --fix first rewrites `:page-categories:` in place: each value becomes its
3231
+ * concept's preferred label, a deprecated value becomes its replacements, and
3232
+ * a replacement that is a task is dropped (the task comes from the module).
3233
+ * Values that need a judgment call (no replacement, or a content type) are
3234
+ * left as they are and still warn.
3235
+ * --suggest also lists, for each page, the topics, use cases, technologies,
3236
+ * and industries its text names that its header does not carry yet, so
3237
+ * authors and agents choose values from the taxonomy instead of inventing
3238
+ * them. Matching is deterministic, with no model: every live concept's
3239
+ * prefLabel and altLabels are matched case-insensitively on whole words
3240
+ * against the title, `:description:`, the page path (module and directories),
3241
+ * section headings, and body, code included and included partials followed.
3242
+ * Words split on anything that is not a letter or digit, so the code literal
3243
+ * `postgres_cdc` names PostgreSQL (altLabel Postgres) and Change data capture
3244
+ * (altLabel CDC). A two-letter label such as Go matches only in its exact
3245
+ * case and never as the first word of a sentence. Hits are weighted title 8,
3246
+ * path 6, description 5, heading 1.5 each (up to 3), and body 1 + log2(n)
3247
+ * (at most 3). A suggestion never names a deprecated concept, never takes a
3248
+ * facet past its cardinality, and skips what the page carries or what that
3249
+ * implies; on a solution overview a technology that a topic implies (Iceberg
3250
+ * Topics implies Apache Iceberg) is suggested in page-solution-technologies.
3251
+ * Each suggestion has a score, a reason, and evidence (the matched term,
3252
+ * where, and the line). --min-score defaults to 5 for Product Docs pages and
3253
+ * 3 for solution overviews; --limit (per facet) to 3 and 6. Suggestions do
3254
+ * not change the exit code.
3255
+ * --suggest also lists candidates: frequent, distinctive terms on a page that
3256
+ * no concept's label or altLabel matches, in any scheme, for proposing new
3257
+ * concepts or altLabels. They are repeated capitalized terms in prose (Amazon
3258
+ * S3, Debezium) and component identifiers (`aws_s3` output, xrefs to Connect
3259
+ * component pages), each with a count, the lines, and a likely facet from a
3260
+ * cue word after it (a connector, database, format, or product word). Terms
3261
+ * in the terminology lists (--terminology, default the docs glossary in the
3262
+ * checkout), common English, and words the page also writes in lowercase are
3263
+ * dropped. --candidates-only skips the check and the suggestions and
3264
+ * aggregates candidates across the pages: one entry per term, with the pages
3265
+ * it is on, leaving out terms on more than a fifth of the pages
3266
+ * (boilerplate).
3267
+ * Exit 0 without errors, 1 with errors, 2 when the check could not run.
3268
+ *
3269
+ * @why
3270
+ * Categories, use cases, and technologies drive search facets and solution
3271
+ * recommendations, and a free-text value becomes a near-duplicate the moment
3272
+ * a second author writes one. This catches it on the PR, without a model, in
3273
+ * a form the docs agents and the PR review can read.
3274
+ *
3275
+ * @example
3276
+ * # Pages changed on this branch (what the docs PR check runs)
3277
+ * npx doc-tools check-metadata --changed origin/main
3278
+ *
3279
+ * # Specific pages or directories, JSON for a grader
3280
+ * npx doc-tools check-metadata modules/manage/pages/iceberg --format json
3281
+ *
3282
+ * # Against a local taxonomy, failing on warnings
3283
+ * npx doc-tools check-metadata --taxonomy ../docs/shared/modules/ROOT/attachments/taxonomy.yml --strict
3284
+ *
3285
+ * # Rewrite deprecated categories to their replacements, then check
3286
+ * npx doc-tools check-metadata --fix modules
3287
+ *
3288
+ * # Suggest taxonomy values for new pages, as JSON for an agent
3289
+ * npx doc-tools check-metadata --suggest --format json modules/manage/pages/new-page.adoc
3290
+ *
3291
+ * # Suggest only for changed pages that carry no categories, with a lower bar
3292
+ * npx doc-tools check-metadata --changed origin/main --suggest --suggest-empty-only --min-score 3
3293
+ *
3294
+ * # Terms across a repository that no concept covers, filtered by the style guide's word lists
3295
+ * npx doc-tools check-metadata --candidates-only --terminology shared/modules/terms/partials,../docs-team-standards/resources/writing-style/terminology.md --format json modules
3296
+ */
3297
+ programCli
3298
+ .command('check-metadata')
3299
+ .description('Check page-categories and solution facet values in AsciiDoc page headers against the docs taxonomy')
3300
+ .argument('[paths...]', 'Files, directories, or globs to check, relative to --root (default: modules/**/*.adoc). With --changed, filters the changed files')
3301
+ .option('--changed <base>', 'Check only .adoc files changed between the merge base with <base> and HEAD, for example origin/main')
3302
+ .option('--taxonomy <path|url>', 'taxonomy.yml to use (default: the docs checkout, $REDPANDA_DOCS_PATH, then https://docs.redpanda.com/shared/_attachments/taxonomy.yml)')
3303
+ .option('--format <format>', 'Output format: text or json', 'text')
3304
+ .option('--strict', 'Treat warnings as errors')
3305
+ .option('--fix', 'Rewrite page-categories in place to current taxonomy labels, then check')
3306
+ .option('--root <path>', 'Repository root (default: current directory)')
3307
+ .option('--output <path>', 'Also write the JSON result to this file')
3308
+ .option('--suggest', 'Also suggest taxonomy values each page does not carry yet, matched from its title, description, path, headings, and body')
3309
+ .option('--min-score <n>', 'With --suggest, the lowest score to suggest (default: 5 on Product Docs pages, 3 on solution overviews)')
3310
+ .option('--limit <n>', 'With --suggest, the most suggestions per facet and page (default: 3 on Product Docs pages, 6 on solution overviews; never past the facet cardinality)')
3311
+ .option('--suggest-empty-only', 'With --suggest, suggest only for pages without :page-categories:')
3312
+ .option('--candidates-only', 'List only candidate terms, frequent distinctive terms that no concept matches, aggregated across the pages with the pages each appears in')
3313
+ .option('--terminology <paths>', 'With --suggest or --candidates-only, comma-separated terminology lists whose terms are never candidates: a glossary partials directory, a Markdown word list, or a text file (default: the docs glossary, shared/modules/terms/partials, in the checkout or $REDPANDA_DOCS_PATH)')
3314
+ .action(async (paths, options) => {
3315
+ const { runCli } = require('../tools/check-metadata')
3316
+ await runCli(paths, options)
3317
+ })
3318
+
3011
3319
  /**
3012
3320
  * check-rendered-html
3013
3321
  *
@@ -0,0 +1,174 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Converts docs.redpanda.com URLs in a generated page into xrefs before the
5
+ * page is committed to a docs repository.
6
+ *
7
+ * Reference pages generated from engineering sources (the CRD reference from
8
+ * Go doc comments, for example) arrive with absolute docs URLs, because a Go
9
+ * comment cannot carry an AsciiDoc xref. The url-to-xref Antora extension
10
+ * rewrites those at site build time, but the committed source then flips
11
+ * between xrefs and URLs on every regeneration, which buries the real content
12
+ * changes in a review diff. Converting here keeps the committed page stable.
13
+ *
14
+ * The URL-to-resource mapping is the one the extension uses: this module
15
+ * builds a real Antora content catalog from the docs checkout (aggregator plus
16
+ * classifier, worktree files, HEAD only) and hands it to the extension's
17
+ * buildUrlMap and convertContent. Nothing about the URL structure is guessed:
18
+ * a URL is converted only when it matches a page the checkout publishes,
19
+ * directly or through a page alias, and its fragment, if any, matches an
20
+ * anchor on that page; everything else is left as written and reported to the
21
+ * caller.
22
+ *
23
+ * Targets in the checkout's own component are written as module:page xrefs,
24
+ * the form the docs repos use by hand for same-component links. Pages of other
25
+ * components (Connect, Cloud) are not in a single-repo catalog, so URLs that
26
+ * point at them stay raw and are listed in the run output; the build-time
27
+ * extension still converts them on the site.
28
+ */
29
+
30
+ const fs = require('fs')
31
+ const os = require('os')
32
+ const path = require('path')
33
+ const yaml = require('js-yaml')
34
+
35
+ const { buildUrlMap, convertContent } = require('../extensions/url-to-xref')
36
+
37
+ const DEFAULT_HOSTNAMES = ['docs.redpanda.com']
38
+ // Mirrors the extension's default ignore list: docs-domain paths that Antora
39
+ // does not publish (the Bump.sh API reference and the MCP endpoint).
40
+ const DEFAULT_IGNORE = ['^/api/', '^/mcp(/|$)']
41
+ const LATEST_VERSION_SEGMENT = 'current'
42
+
43
+ /**
44
+ * Loads an Antora package, preferring the copy installed in the docs checkout
45
+ * (the site's own Antora version) over the one this package ships for its own
46
+ * preview build. The aggregator and classifier are dependencies of
47
+ * @antora/site-generator, so they are resolved through it.
48
+ */
49
+ function requireAntora (name, searchPaths) {
50
+ const errors = []
51
+ for (const searchPath of searchPaths) {
52
+ try {
53
+ const siteGenerator = path.dirname(require.resolve('@antora/site-generator/package.json', { paths: [searchPath] }))
54
+ return require(require.resolve(name, { paths: [siteGenerator, searchPath] }))
55
+ } catch (err) {
56
+ errors.push(`${searchPath}: ${err.message.split('\n')[0]}`)
57
+ }
58
+ }
59
+ throw new Error(`Cannot load ${name}; install Antora in the docs repository (npm install) so generated links can be converted.\n${errors.join('\n')}`)
60
+ }
61
+
62
+ /**
63
+ * Reads the component name a docs checkout declares in antora.yml. A checkout
64
+ * without one is not an Antora component and cannot be mapped.
65
+ */
66
+ function readComponentName (docsRoot) {
67
+ const descriptor = path.join(docsRoot, 'antora.yml')
68
+ if (!fs.existsSync(descriptor)) {
69
+ throw new Error(`No antora.yml in ${docsRoot}; a docs checkout is needed to map docs URLs to pages`)
70
+ }
71
+ const { name } = yaml.load(fs.readFileSync(descriptor, 'utf8')) || {}
72
+ if (!name) throw new Error(`${descriptor} declares no component name`)
73
+ return name
74
+ }
75
+
76
+ /**
77
+ * Builds the resolver context for a docs checkout: the extension's URL map,
78
+ * computed from an Antora content catalog of the checkout's worktree.
79
+ *
80
+ * @param {string} docsRoot - Root of a docs repository checkout (holds antora.yml).
81
+ * @param {Object} [options]
82
+ * @param {string[]} [options.hostnames] - Docs hostnames to convert.
83
+ * @param {string[]} [options.ignore] - URL path patterns to leave alone silently.
84
+ * @param {string} [options.cacheDir] - Antora cache directory.
85
+ * @returns {Promise<Object>} A context for convertContent, plus `component`
86
+ * (the checkout's component name) and `pageCount`.
87
+ */
88
+ async function buildDocsResolverContext (docsRoot, options = {}) {
89
+ const root = path.resolve(docsRoot)
90
+ const component = readComponentName(root)
91
+ const searchPaths = [root, path.join(__dirname, '..')]
92
+ const aggregateContent = requireAntora('@antora/content-aggregator', searchPaths)
93
+ const classifyContent = requireAntora('@antora/content-classifier', searchPaths)
94
+ const playbook = {
95
+ runtime: {
96
+ quiet: true,
97
+ fetch: false,
98
+ cacheDir: options.cacheDir || fs.mkdtempSync(path.join(os.tmpdir(), 'docs-links-antora-cache-')),
99
+ log: { level: 'silent' },
100
+ },
101
+ site: {},
102
+ // The published shape of the live site; normalizeUrlPath in the extension
103
+ // makes the extension style irrelevant to matching, and candidatePaths
104
+ // covers both the symbolic and the real version segment.
105
+ urls: { htmlExtensionStyle: 'indexify', latestVersionSegment: LATEST_VERSION_SEGMENT },
106
+ // HEAD of the local checkout, read from the worktree, so an unpushed
107
+ // branch and uncommitted pages count as published.
108
+ content: { sources: [{ url: root, branches: 'HEAD' }] },
109
+ }
110
+ const contentCatalog = classifyContent(playbook, await aggregateContent(playbook))
111
+ const pageCount = contentCatalog.getPages((page) => page.out).length
112
+ return Object.assign(buildUrlMap(contentCatalog), {
113
+ hostnames: new Set(options.hostnames || DEFAULT_HOSTNAMES),
114
+ ignore: (options.ignore || DEFAULT_IGNORE).map((pattern) => new RegExp(pattern)),
115
+ latestVersionSegment: LATEST_VERSION_SEGMENT,
116
+ localComponent: component,
117
+ resolveInclude: (spec, page) => contentCatalog.resolveResource(spec, page.src, 'partial', ['partial', 'page']),
118
+ component,
119
+ pageCount,
120
+ })
121
+ }
122
+
123
+ /**
124
+ * Converts the docs URLs in a string. Thin wrapper over the extension's
125
+ * convertContent so callers do not depend on the extension module directly.
126
+ */
127
+ function convertDocsLinks (content, context) {
128
+ return convertContent(content, context)
129
+ }
130
+
131
+ /**
132
+ * Converts the docs URLs in a file in place. The file is rewritten only when
133
+ * something changed.
134
+ *
135
+ * @returns {Object} `{ converted, unmapped, withQueryString, withoutLinkText, missingAnchor }`
136
+ * with the raw URLs in each list deduplicated.
137
+ */
138
+ function convertDocsLinksInFile (filePath, context) {
139
+ const before = fs.readFileSync(filePath, 'utf8')
140
+ const result = convertDocsLinks(before, context)
141
+ if (result.converted) fs.writeFileSync(filePath, result.content, 'utf8')
142
+ return {
143
+ converted: result.converted,
144
+ unmapped: [...new Set(result.unmapped)],
145
+ withQueryString: [...new Set(result.withQueryString)],
146
+ withoutLinkText: [...new Set(result.withoutLinkText)],
147
+ missingAnchor: [...new Set(result.missingAnchor)],
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Formats the outcome of a file conversion for the run output: one summary
153
+ * line, then every URL that was left as written and why.
154
+ */
155
+ function formatDocsLinkReport (result, filePath) {
156
+ const lines = [`Converted ${result.converted} docs URL${result.converted === 1 ? '' : 's'} to xrefs in ${filePath}`]
157
+ const leftRaw = (urls, reason) => {
158
+ if (!urls.length) return
159
+ lines.push(`Left ${urls.length} docs URL${urls.length === 1 ? '' : 's'} as written (${reason}):`)
160
+ for (const url of urls) lines.push(` ${url}`)
161
+ }
162
+ leftRaw(result.unmapped, 'no page in this checkout matches; the url-to-xref extension converts cross-component URLs at build time and warns about broken ones')
163
+ leftRaw(result.withQueryString, 'an xref cannot carry a query string')
164
+ leftRaw(result.withoutLinkText, 'the target page could not be read to check the fragment')
165
+ leftRaw(result.missingAnchor || [], 'anchor not found: the page exists but has no section or anchor with that id')
166
+ return lines.join('\n')
167
+ }
168
+
169
+ module.exports = {
170
+ buildDocsResolverContext,
171
+ convertDocsLinks,
172
+ convertDocsLinksInFile,
173
+ formatDocsLinkReport,
174
+ }
@@ -0,0 +1,43 @@
1
+ 'use strict'
2
+
3
+ // The one comment line every doc-tools generator writes at the top of each
4
+ // file it produces. convert-to-markdown reads it and reports the provenance
5
+ // in the Markdown twins (page-generated-content, page-generated-by,
6
+ // page-generated-from, page-generated-sources), so a reader or an agent can
7
+ // tell generated text from hand-written text and knows where a fix goes.
8
+ //
9
+ // The first two sentences are the legacy banner that existing tests and the
10
+ // guard-generated-files check already look for, so they never change.
11
+ //
12
+ // The marker names no doc-tools version. Generated files are committed, so a
13
+ // version in the marker would rewrite the first line of every generated file
14
+ // on every release even when nothing else changed, and every auto-docs PR
15
+ // would carry hundreds of one-line diffs. DOC_TOOLS_VERSION is for provenance
16
+ // recorded at build time (pages generated during the site build), where no
17
+ // file is committed.
18
+ const pkg = require('../package.json')
19
+
20
+ const GENERATED_MARKER_LEGACY = 'This content is autogenerated. Do not edit manually.'
21
+ const DOC_TOOLS_VERSION = pkg.version
22
+
23
+ /**
24
+ * Build the marker line, without a trailing newline.
25
+ * @param {string} generator - Short generator name, for example `property-docs`
26
+ * @param {string} source - Where the text comes from, as a noun phrase, for
27
+ * example `the configuration property definitions in redpanda-data/streaming-enterprise`
28
+ * @param {string} [hint] - How to change the text, as one or more sentences
29
+ * @returns {string} `// This content is autogenerated. ... Generated by doc-tools (<generator>) from <source>. <hint>`
30
+ */
31
+ function generatedMarker (generator, source, hint) {
32
+ if (!generator || !source) throw new Error('generatedMarker needs a generator and a source')
33
+ const parts = [
34
+ GENERATED_MARKER_LEGACY,
35
+ `Generated by doc-tools (${generator}) from ${source.replace(/\.$/, '')}.`,
36
+ ]
37
+ // Handlebars passes its options object as the last argument when a
38
+ // template omits the hint, so only a string counts as one.
39
+ if (typeof hint === 'string' && hint.trim()) parts.push(hint.trim())
40
+ return `// ${parts.join(' ')}`
41
+ }
42
+
43
+ module.exports = { generatedMarker, GENERATED_MARKER_LEGACY, DOC_TOOLS_VERSION }