@redpanda-data/docs-extensions-and-macros 5.8.0 → 5.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/doc-tools.js +172 -62
- package/cli-utils/format-helm-spec.js +58 -0
- package/extensions/README.adoc +4 -0
- package/extensions/REFERENCE.adoc +91 -0
- package/extensions/convert-to-markdown.js +115 -0
- package/extensions/external-link-checker.js +132 -0
- package/extensions/property-page-index.js +44 -0
- package/extensions/url-to-xref.js +515 -0
- package/extensions/util/scan-content-urls.js +121 -0
- package/extensions/validate-attributes.js +1 -1
- package/macros/PROPERTY_AND_ENTERPRISE_REFERENCES.adoc +90 -0
- package/macros/README.adoc +2 -0
- package/macros/REFERENCE.adoc +167 -0
- package/macros/badge.js +35 -13
- package/macros/enterprise.js +390 -0
- package/macros/prop.js +491 -0
- package/package.json +12 -2
- package/tools/enterprise-features/parsers.js +134 -0
- package/tools/enterprise-features/verify.js +244 -0
- package/tools/generate-cli-docs.js +19 -1
- package/tools/migrate-property-refs.js +206 -0
- package/tools/property-extractor/generate-handlebars-docs.js +28 -1
- package/tools/property-extractor/property_extractor.py +34 -0
- package/tools/rpk-docs/validate-output.js +4 -3
- package/cli-utils/convert-doc-links.js +0 -58
package/bin/doc-tools.js
CHANGED
|
@@ -34,7 +34,6 @@ const {
|
|
|
34
34
|
// Import other utilities
|
|
35
35
|
const { determineDocsBranch } = require('../cli-utils/self-managed-docs-branch.js')
|
|
36
36
|
const fetchFromGithub = require('../tools/fetch-from-github.js')
|
|
37
|
-
const { urlToXref } = require('../cli-utils/convert-doc-links.js')
|
|
38
37
|
const { getAntoraValue, setAntoraValue } = require('../cli-utils/antora-utils')
|
|
39
38
|
|
|
40
39
|
// --------------------------------------------------------------------
|
|
@@ -827,6 +826,48 @@ automation
|
|
|
827
826
|
migrateMetadataToPartials({ write: Boolean(options.write) })
|
|
828
827
|
})
|
|
829
828
|
|
|
829
|
+
/**
|
|
830
|
+
* @description One-time migration of plain-backtick property mentions to the
|
|
831
|
+
* prop: inline macro, so property tooltips become opt-in. Only names that
|
|
832
|
+
* exist in the published property reference JSON AND contain a separator
|
|
833
|
+
* (_ or .) are converted. Separator-free names such as admin, brokers, rack,
|
|
834
|
+
* retries, and superusers are the ambiguous words that motivated opt-in
|
|
835
|
+
* marking, so they are always left for a human. Dry run unless --write is given.
|
|
836
|
+
*
|
|
837
|
+
* @example
|
|
838
|
+
* # Preview the conversion from a docs repo checkout
|
|
839
|
+
* npx doc-tools generate migrate-property-refs --properties modules/reference/attachments/redpanda-properties-v26.2.1.json
|
|
840
|
+
*
|
|
841
|
+
* # Apply it
|
|
842
|
+
* npx doc-tools generate migrate-property-refs --properties modules/reference/attachments/redpanda-properties-v26.2.1.json --write
|
|
843
|
+
*/
|
|
844
|
+
automation
|
|
845
|
+
.command('migrate-property-refs')
|
|
846
|
+
.description('One-time migration of `property_name` prose mentions to prop:property_name[] macros. Dry run unless --write is given.')
|
|
847
|
+
.requiredOption('--properties <path>', 'Path to the published redpanda-properties JSON to validate names against')
|
|
848
|
+
.option('--docs-dir <path>', 'Docs repo root containing modules/', '.')
|
|
849
|
+
.option('--config-refs', 'Also convert config_ref macro calls to prop macro calls')
|
|
850
|
+
.option('--write', 'Apply changes (default is a dry run that only reports)')
|
|
851
|
+
.action((options) => {
|
|
852
|
+
const { migrate } = require('../tools/migrate-property-refs.js')
|
|
853
|
+
const propertiesJson = JSON.parse(fs.readFileSync(path.resolve(options.properties), 'utf8'))
|
|
854
|
+
const result = migrate({
|
|
855
|
+
docsDir: path.resolve(options.docsDir),
|
|
856
|
+
propertiesJson,
|
|
857
|
+
write: Boolean(options.write),
|
|
858
|
+
configRefs: Boolean(options.configRefs),
|
|
859
|
+
})
|
|
860
|
+
result.changed
|
|
861
|
+
.sort((a, b) => b.count - a.count)
|
|
862
|
+
.forEach(({ file, count }) => console.log(`${String(count).padStart(4)} ${file}`))
|
|
863
|
+
console.log(`\n${options.write ? 'Converted' : 'Would convert'} ${result.conversions} mention(s) across ${result.changed.length} of ${result.files} files.`)
|
|
864
|
+
console.log(`Left alone (ambiguous, no separator): ${result.ambiguous.join(', ')}`)
|
|
865
|
+
if (result.skippedConfigRefs && result.skippedConfigRefs.length) {
|
|
866
|
+
console.log(`config_ref calls left alone (names not in the published JSON): ${result.skippedConfigRefs.join(', ')}`)
|
|
867
|
+
}
|
|
868
|
+
if (!options.write && result.conversions) console.log('Re-run with --write to apply.')
|
|
869
|
+
})
|
|
870
|
+
|
|
830
871
|
/**
|
|
831
872
|
* generate property-docs
|
|
832
873
|
*
|
|
@@ -1635,47 +1676,8 @@ automation
|
|
|
1635
1676
|
r = spawnSync('pandoc', [md, '-t', 'asciidoc', '-o', outFile], { stdio: 'inherit' })
|
|
1636
1677
|
if (r.status !== 0) process.exit(r.status)
|
|
1637
1678
|
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
doc = doc
|
|
1641
|
-
.replace(/(\[\d+\])\]\./g, '$1\\].')
|
|
1642
|
-
.replace(/(\[\d+\])\]\]/g, '$1\\]\\]')
|
|
1643
|
-
.replace(/^=== +(https?:\/\/[^\[]*)\[([^\]]*)\]/gm, '=== link:++$1++[$2]')
|
|
1644
|
-
.replace(/^== # (.*)$/gm, '= $1')
|
|
1645
|
-
.replace(/^== description: (.*)$/gm, ':description: $1')
|
|
1646
|
-
.replace(xrefRe, (match) => {
|
|
1647
|
-
let urlPart = match
|
|
1648
|
-
let bracketPart = ''
|
|
1649
|
-
const m = match.match(/^([^\[]+)(\[[^\]]*\])$/)
|
|
1650
|
-
if (m) {
|
|
1651
|
-
urlPart = m[1]
|
|
1652
|
-
bracketPart = m[2]
|
|
1653
|
-
}
|
|
1654
|
-
if (urlPart.endsWith('#')) return match
|
|
1655
|
-
try {
|
|
1656
|
-
const xref = urlToXref(urlPart)
|
|
1657
|
-
return bracketPart ? `${xref}${bracketPart}` : `${xref}[]`
|
|
1658
|
-
} catch (err) {
|
|
1659
|
-
console.warn(`⚠️ urlToXref failed on ${urlPart}: ${err.message}`)
|
|
1660
|
-
return match
|
|
1661
|
-
}
|
|
1662
|
-
})
|
|
1663
|
-
// Antora resolves page metadata with a header-only parse that stops at
|
|
1664
|
-
// the first blank line. The chart README templates emit :description:
|
|
1665
|
-
// below the title's blank line, so the page shipped the generic site
|
|
1666
|
-
// meta description despite carrying one (found on k-connect-helm-spec,
|
|
1667
|
-
// DOC-2414 investigation). Relocate the first :description: into the
|
|
1668
|
-
// header, directly under the title.
|
|
1669
|
-
const descMatch = doc.match(/^:description:[^\n]*$/m)
|
|
1670
|
-
if (descMatch) {
|
|
1671
|
-
const headerEnd = doc.indexOf('\n\n')
|
|
1672
|
-
if (headerEnd !== -1 && doc.indexOf(descMatch[0]) > headerEnd) {
|
|
1673
|
-
doc = doc.replace(descMatch[0] + '\n', '')
|
|
1674
|
-
doc = doc.replace(/^(= .+)$/m, `$1\n${descMatch[0]}`)
|
|
1675
|
-
doc = doc.replace(/\n{3,}/g, '\n\n')
|
|
1676
|
-
}
|
|
1677
|
-
}
|
|
1678
|
-
fs.writeFileSync(outFile, doc, 'utf8')
|
|
1679
|
+
const { formatHelmSpec } = require('../cli-utils/format-helm-spec')
|
|
1680
|
+
fs.writeFileSync(outFile, formatHelmSpec(fs.readFileSync(outFile, 'utf8')), 'utf8')
|
|
1679
1681
|
|
|
1680
1682
|
console.log(`Done: Wrote ${outFile}`)
|
|
1681
1683
|
}
|
|
@@ -1947,26 +1949,8 @@ automation
|
|
|
1947
1949
|
process.exit(1)
|
|
1948
1950
|
}
|
|
1949
1951
|
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
doc = doc.replace(xrefRe, (match) => {
|
|
1953
|
-
let urlPart = match
|
|
1954
|
-
let bracketPart = ''
|
|
1955
|
-
const m = match.match(/^([^\[]+)(\[[^\]]*\])$/)
|
|
1956
|
-
if (m) {
|
|
1957
|
-
urlPart = m[1]
|
|
1958
|
-
bracketPart = m[2]
|
|
1959
|
-
}
|
|
1960
|
-
if (urlPart.endsWith('#')) return match
|
|
1961
|
-
try {
|
|
1962
|
-
const xref = urlToXref(urlPart)
|
|
1963
|
-
return bracketPart ? `${xref}${bracketPart}` : `${xref}[]`
|
|
1964
|
-
} catch (err) {
|
|
1965
|
-
console.warn(`⚠️ urlToXref failed on ${urlPart}: ${err.message}`)
|
|
1966
|
-
return match
|
|
1967
|
-
}
|
|
1968
|
-
})
|
|
1969
|
-
fs.writeFileSync(opts.output, doc, 'utf8')
|
|
1952
|
+
// docs.redpanda.com URLs are left as-is: the url-to-xref Antora
|
|
1953
|
+
// extension converts them to validated xrefs at site build time.
|
|
1970
1954
|
|
|
1971
1955
|
if (tmpSrc) fs.rmSync(tmpSrc, { recursive: true, force: true })
|
|
1972
1956
|
fs.rmSync(configTmp, { recursive: true, force: true })
|
|
@@ -2133,5 +2117,131 @@ automation
|
|
|
2133
2117
|
}
|
|
2134
2118
|
})
|
|
2135
2119
|
|
|
2120
|
+
const validation = new Command('validate').description('Validate docs data against internal sources of truth')
|
|
2121
|
+
|
|
2122
|
+
/**
|
|
2123
|
+
* @description Checks the enterprise features registry (the shared component's
|
|
2124
|
+
* enterprise-features.yml in the docs repo) against the internal sources of
|
|
2125
|
+
* truth: the license_required_feature enum and config::enterprise<> property
|
|
2126
|
+
* wrappers in redpanda core, the enterprise plugins in connect info.csv, and
|
|
2127
|
+
* the hand-maintained disable-enterprise-features.adoc table.
|
|
2128
|
+
* Exit codes: 0 clean, 1 drift found (error or needs-human findings), 2 execution error.
|
|
2129
|
+
*
|
|
2130
|
+
* @why A feature must only be documented as enterprise under its approved
|
|
2131
|
+
* external name. This check catches new license-gated features in core that
|
|
2132
|
+
* have no registry entry yet, registry pointers that no longer match core,
|
|
2133
|
+
* and stale connect enterprise lists.
|
|
2134
|
+
*
|
|
2135
|
+
* @example
|
|
2136
|
+
* # Check everything against the default remote sources
|
|
2137
|
+
* npx doc-tools validate enterprise-features
|
|
2138
|
+
*
|
|
2139
|
+
* # Check a local registry file (for example, in docs repo CI)
|
|
2140
|
+
* npx doc-tools validate enterprise-features --registry shared/modules/ROOT/partials/enterprise-features.yml
|
|
2141
|
+
*
|
|
2142
|
+
* # Regenerate the rpk name-mapping partial
|
|
2143
|
+
* npx doc-tools validate enterprise-features --write-mapping modules/get-started/partials/licensing/feature-name-mapping.adoc
|
|
2144
|
+
*
|
|
2145
|
+
* @requirements
|
|
2146
|
+
* Network access to raw.githubusercontent.com for any source not supplied
|
|
2147
|
+
* with a local path option.
|
|
2148
|
+
*/
|
|
2149
|
+
validation
|
|
2150
|
+
.command('enterprise-features')
|
|
2151
|
+
.description('Check the enterprise features registry against core, connect, and docs sources of truth')
|
|
2152
|
+
.option('--registry <path>', 'Local path to enterprise-features.yml (default: fetch from docs repo main)')
|
|
2153
|
+
.option('--tag <ref>', 'Redpanda git ref for the core headers', 'dev')
|
|
2154
|
+
.option('--connect-ref <ref>', 'Connect git ref for info.csv', 'main')
|
|
2155
|
+
.option('--docs-ref <ref>', 'Docs repo git ref for remote fetches', 'main')
|
|
2156
|
+
.option('--disable-page <path>', 'Local path to disable-enterprise-features.adoc (default: fetch from docs repo)')
|
|
2157
|
+
.option('--antora <path>', 'Local path to rp-connect-docs antora.yml (default: fetch from rp-connect-docs main)')
|
|
2158
|
+
.option('--properties <path>', 'Local path to a generated cluster-properties JSON; enables existence checks for gating properties')
|
|
2159
|
+
.option('--skip-connect', 'Skip the connect info.csv comparison')
|
|
2160
|
+
.option('--format <format>', 'Output format: text or json', 'text')
|
|
2161
|
+
.option('--write-mapping <path>', 'Write the internal-to-external name mapping partial to this path')
|
|
2162
|
+
.action(async (options) => {
|
|
2163
|
+
const { runChecks, buildMappingPartial } = require('../tools/enterprise-features/verify')
|
|
2164
|
+
const { extractAntoraEnterpriseComponents } = require('../tools/enterprise-features/parsers')
|
|
2165
|
+
const yamlLib = require('js-yaml')
|
|
2166
|
+
|
|
2167
|
+
const RAW = 'https://raw.githubusercontent.com'
|
|
2168
|
+
// Sources that fail to load are reported as error-level findings rather
|
|
2169
|
+
// than silently skipped, so CI cannot pass on a check that never ran.
|
|
2170
|
+
const failedSources = []
|
|
2171
|
+
async function fetchText (url, sourceName) {
|
|
2172
|
+
const resp = await fetch(url)
|
|
2173
|
+
if (!resp.ok) {
|
|
2174
|
+
if (sourceName) {
|
|
2175
|
+
failedSources.push({ level: 'error', check: 'fetch', message: `Could not load ${sourceName} (${url}): ${resp.status} ${resp.statusText}. The related checks did not run.` })
|
|
2176
|
+
return undefined
|
|
2177
|
+
}
|
|
2178
|
+
throw new Error(`Failed to fetch ${url}: ${resp.status} ${resp.statusText}`)
|
|
2179
|
+
}
|
|
2180
|
+
return resp.text()
|
|
2181
|
+
}
|
|
2182
|
+
const readLocal = (p) => fs.readFileSync(path.resolve(p), 'utf8')
|
|
2183
|
+
|
|
2184
|
+
try {
|
|
2185
|
+
const registryYaml = options.registry
|
|
2186
|
+
? readLocal(options.registry)
|
|
2187
|
+
: await fetchText(`${RAW}/redpanda-data/docs/${options.docsRef}/shared/modules/ROOT/partials/enterprise-features.yml`)
|
|
2188
|
+
const coreHeader = await fetchText(`${RAW}/redpanda-data/redpanda/${options.tag}/src/v/features/enterprise_features.h`, 'core enterprise_features.h')
|
|
2189
|
+
const configurationHeader = await fetchText(`${RAW}/redpanda-data/redpanda/${options.tag}/src/v/config/configuration.h`, 'core configuration.h')
|
|
2190
|
+
const infoCsv = options.skipConnect
|
|
2191
|
+
? undefined
|
|
2192
|
+
: await fetchText(`${RAW}/redpanda-data/connect/${options.connectRef}/internal/plugins/info.csv`, 'connect info.csv')
|
|
2193
|
+
const disablePage = options.disablePage
|
|
2194
|
+
? readLocal(options.disablePage)
|
|
2195
|
+
: await fetchText(`${RAW}/redpanda-data/docs/${options.docsRef}/modules/get-started/pages/licensing/disable-enterprise-features.adoc`, 'disable-enterprise-features.adoc')
|
|
2196
|
+
const antoraYaml = options.antora
|
|
2197
|
+
? readLocal(options.antora)
|
|
2198
|
+
: await fetchText(`${RAW}/redpanda-data/rp-connect-docs/main/antora.yml`, 'rp-connect-docs antora.yml')
|
|
2199
|
+
const antoraEnterpriseComponents = antoraYaml
|
|
2200
|
+
? extractAntoraEnterpriseComponents(yamlLib.load(antoraYaml))
|
|
2201
|
+
: undefined
|
|
2202
|
+
|
|
2203
|
+
let allPropertyNames
|
|
2204
|
+
if (options.properties) {
|
|
2205
|
+
const propertyData = JSON.parse(readLocal(options.properties))
|
|
2206
|
+
allPropertyNames = Object.keys(propertyData.properties || propertyData)
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
const { findings, features, enumValues } = runChecks({
|
|
2210
|
+
registryYaml,
|
|
2211
|
+
coreHeader,
|
|
2212
|
+
configurationHeader,
|
|
2213
|
+
infoCsv,
|
|
2214
|
+
antoraEnterpriseComponents,
|
|
2215
|
+
disablePage,
|
|
2216
|
+
allPropertyNames,
|
|
2217
|
+
})
|
|
2218
|
+
findings.push(...failedSources)
|
|
2219
|
+
|
|
2220
|
+
if (options.writeMapping) {
|
|
2221
|
+
const partial = buildMappingPartial(features, enumValues)
|
|
2222
|
+
fs.mkdirSync(path.dirname(path.resolve(options.writeMapping)), { recursive: true })
|
|
2223
|
+
fs.writeFileSync(path.resolve(options.writeMapping), `${partial}\n`)
|
|
2224
|
+
console.log(`Wrote name mapping partial to ${options.writeMapping}`)
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
const drift = findings.filter((f) => f.level === 'error' || f.level === 'needs-human')
|
|
2228
|
+
if (options.format === 'json') {
|
|
2229
|
+
console.log(JSON.stringify({ findings, drift: drift.length > 0 }, null, 2))
|
|
2230
|
+
} else {
|
|
2231
|
+
for (const level of ['error', 'needs-human', 'info']) {
|
|
2232
|
+
for (const f of findings.filter((entry) => entry.level === level)) {
|
|
2233
|
+
console.log(`${level.toUpperCase()} [${f.check}] ${f.message}`)
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
console.log(drift.length ? `\n${drift.length} finding(s) need attention.` : '\nRegistry is in sync with all checked sources.')
|
|
2237
|
+
}
|
|
2238
|
+
process.exit(drift.length ? 1 : 0)
|
|
2239
|
+
} catch (err) {
|
|
2240
|
+
console.error(`Error: ${err.message}`)
|
|
2241
|
+
process.exit(2)
|
|
2242
|
+
}
|
|
2243
|
+
})
|
|
2244
|
+
|
|
2136
2245
|
programCli.addCommand(automation)
|
|
2246
|
+
programCli.addCommand(validation)
|
|
2137
2247
|
programCli.parse(process.argv)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cleans up the AsciiDoc that pandoc produces from a chart's helm-docs README
|
|
5
|
+
* so that it renders correctly as a docs page.
|
|
6
|
+
*
|
|
7
|
+
* docs.redpanda.com URLs are deliberately left as they are: the url-to-xref
|
|
8
|
+
* Antora extension converts them to validated xrefs at site build time.
|
|
9
|
+
*/
|
|
10
|
+
function formatHelmSpec (doc) {
|
|
11
|
+
const formatted = doc
|
|
12
|
+
// pandoc escapes footnote-style references inconsistently
|
|
13
|
+
.replace(/(\[\d+\])\]\./g, '$1\\].')
|
|
14
|
+
.replace(/(\[\d+\])\]\]/g, '$1\\]\\]')
|
|
15
|
+
// a section title that is a bare URL has to be a link macro to render
|
|
16
|
+
.replace(/^=== +(https?:\/\/[^[]*)\[([^\]]*)\]/gm, '=== link:++$1++[$2]')
|
|
17
|
+
.replace(/^== # (.*)$/gm, '= $1')
|
|
18
|
+
.replace(/^== description: (.*)$/gm, ':description: $1')
|
|
19
|
+
// helm-docs prints each chart dependency's repository verbatim into the
|
|
20
|
+
// Requirements table, and charts in the redpanda-operator monorepo refer
|
|
21
|
+
// to their subcharts by local path (for example the Redpanda chart's
|
|
22
|
+
// console dependency, repository: file://../../console/chart).
|
|
23
|
+
// Asciidoctor auto-links a bare file:// URL, which publishes a link that
|
|
24
|
+
// can never resolve for a reader, so render these as literal code.
|
|
25
|
+
.replace(/(^|[^`])(file:\/\/[^\s|\]`]+)/gm, '$1`$2`')
|
|
26
|
+
|
|
27
|
+
return relocateDescriptionIntoHeader(formatted)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Moves the first `:description:` attribute up into the document header.
|
|
32
|
+
*
|
|
33
|
+
* Antora resolves page metadata with a header-only parse that stops at the
|
|
34
|
+
* first blank line. The chart README templates emit `:description:` below the
|
|
35
|
+
* title's blank line, so the page shipped the generic site meta description
|
|
36
|
+
* despite carrying one of its own (found on k-connect-helm-spec, DOC-2414).
|
|
37
|
+
*
|
|
38
|
+
* The conversion above turns `== description:` into the attribute but leaves it
|
|
39
|
+
* where pandoc put it, so the move has to happen after it.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} doc - AsciiDoc source.
|
|
42
|
+
* @returns {string} Source with the description in the header.
|
|
43
|
+
*/
|
|
44
|
+
function relocateDescriptionIntoHeader (doc) {
|
|
45
|
+
const descMatch = doc.match(/^:description:[^\n]*$/m)
|
|
46
|
+
if (!descMatch) return doc
|
|
47
|
+
|
|
48
|
+
const headerEnd = doc.indexOf('\n\n')
|
|
49
|
+
// Already inside the header, so there is nothing to move.
|
|
50
|
+
if (headerEnd === -1 || doc.indexOf(descMatch[0]) < headerEnd) return doc
|
|
51
|
+
|
|
52
|
+
return doc
|
|
53
|
+
.replace(descMatch[0] + '\n', '')
|
|
54
|
+
.replace(/^(= .+)$/m, `$1\n${descMatch[0]}`)
|
|
55
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { formatHelmSpec }
|
package/extensions/README.adoc
CHANGED
|
@@ -89,6 +89,10 @@ IMPORTANT: Extensions must be registered under the `antora.extensions` key in yo
|
|
|
89
89
|
* **set-available-attachment-versions** - Point UI templates at the newest generated JSON attachments that exist in the catalog
|
|
90
90
|
* **validate-attributes** - Validate AsciiDoc attributes
|
|
91
91
|
|
|
92
|
+
=== Link health
|
|
93
|
+
* **url-to-xref** - Convert docs.redpanda.com URLs in content into validated xrefs and warn about URLs that match no published page
|
|
94
|
+
* **external-link-checker** - Verify external URLs respond and report dead links in the build log
|
|
95
|
+
|
|
92
96
|
=== AI-friendly documentation
|
|
93
97
|
* **convert-to-markdown** - Export documentation pages as markdown with frontmatter
|
|
94
98
|
* **convert-llms-to-txt** - Generate llms.txt and component-specific exports for AI agents
|
|
@@ -485,6 +485,97 @@ antora:
|
|
|
485
485
|
- require: '@redpanda-data/docs-extensions-and-macros/extensions/set-available-attachment-versions'
|
|
486
486
|
----
|
|
487
487
|
|
|
488
|
+
== URL to xref
|
|
489
|
+
|
|
490
|
+
This extension converts absolute docs.redpanda.com URLs found in page and partial content into Antora xrefs, using the content catalog as the single source of truth. A URL is converted only when it maps to a page that is actually published in the current build, so the extension never guesses at URL structure. Converted links are validated by Antora's own xref resolution, and internal URLs that match no published page are logged as warnings — which makes this extension a broken-internal-link detector for the whole site.
|
|
491
|
+
|
|
492
|
+
Behavior details:
|
|
493
|
+
|
|
494
|
+
* Runs at `contentClassified`, before AsciiDoc conversion, so emitted xrefs flow through Antora's normal resolution and broken-xref logging.
|
|
495
|
+
* URLs inside listing, literal, fenced, and passthrough blocks, inline code spans, attribute entry lines, and macro attribute values (such as `link=` on an image macro) are left untouched.
|
|
496
|
+
* Legacy URL shapes (`/docs/...`, `/current/...`, `/vX.Y/...`, and pre-rename component slugs such as `/redpanda-connect/`) are rewritten to candidate paths, but a candidate is only used when it matches a published page.
|
|
497
|
+
* Fragments (`#anchor`) and link labels are preserved, including a label that a generator wrapped across a line break, whose line breaks are collapsed to single spaces.
|
|
498
|
+
* An unlabeled URL becomes `xref:...[]` so that Antora fills in the target page title. An unlabeled URL that carries a fragment is given link text explicitly, because Antora cannot resolve a section title and would render the raw resource ID as the link text: the extension uses the heading the fragment points at, falls back to the target page title, and leaves the URL as a raw link if it can read neither. Heading ids are also read from the partials a target page includes, because the reference pages that hold most anchor targets are thin wrappers around generated partials.
|
|
499
|
+
* Targets in the latest version of a component emit an unversioned xref; targets pinned to an older version emit `version@component:module:page`.
|
|
500
|
+
* A URL that points at a page's former path resolves through the `page-aliases` attribute that the page declares. Antora does not register those aliases as catalog files until it converts documents, which is after this extension runs, so the extension reads them from the page header. Only aliases in the same component and version as the page that declares them are mapped.
|
|
501
|
+
* A URL that carries a query string is left as a raw link, because an xref cannot express one and dropping it would change the link (for example `?platform=kubernetes`, which the UI reads to preselect a tab). These are reported at info level, not as warnings.
|
|
502
|
+
* Builds that publish the latest version under its real version number instead of a symbolic segment (such as this repository's preview build) still resolve `/current/...` URLs, so the same content works in both.
|
|
503
|
+
|
|
504
|
+
=== Environment variables
|
|
505
|
+
|
|
506
|
+
This extension does not require any environment variables.
|
|
507
|
+
|
|
508
|
+
=== Configuration options
|
|
509
|
+
|
|
510
|
+
hostnames (optional)::
|
|
511
|
+
List of hostnames treated as internal documentation URLs. Default: `['docs.redpanda.com']`.
|
|
512
|
+
|
|
513
|
+
ignore (optional)::
|
|
514
|
+
List of regular expressions matched against URL paths. Matching URLs are left untouched with no warning. Use this for paths on the docs domain that live outside the Antora catalog. Default: `['^/api/', '^/mcp(/|$)']` (the API reference is hosted by Bump.sh, and `/mcp` is the docs MCP server endpoint).
|
|
515
|
+
|
|
516
|
+
log_unconverted (optional)::
|
|
517
|
+
Whether to log a warning for each internal URL that matches no published page. Default: `true`.
|
|
518
|
+
|
|
519
|
+
=== Registration
|
|
520
|
+
|
|
521
|
+
Register the `url-to-xref` extension in the Antora playbook under the `antora.extensions` key like so:
|
|
522
|
+
|
|
523
|
+
[source,yaml]
|
|
524
|
+
----
|
|
525
|
+
antora:
|
|
526
|
+
extensions:
|
|
527
|
+
- require: '@redpanda-data/docs-extensions-and-macros/extensions/url-to-xref'
|
|
528
|
+
----
|
|
529
|
+
|
|
530
|
+
== External link checker
|
|
531
|
+
|
|
532
|
+
This extension collects every external http(s) URL referenced in page and partial content and verifies that it responds, reporting dead links in the build log. Internal links are covered by the url-to-xref extension plus Antora's own xref validation; this extension covers everything that points off-site.
|
|
533
|
+
|
|
534
|
+
Because it performs network requests, register it only in builds where that belongs (for example a scheduled nightly build), not necessarily on every preview build.
|
|
535
|
+
|
|
536
|
+
Responses are classified as follows:
|
|
537
|
+
|
|
538
|
+
* 2xx/3xx: ok
|
|
539
|
+
* 404/410: broken — logged as a warning, or at error level with `fail_on_broken`
|
|
540
|
+
* 401/403/429: unverifiable — logged at info level, because bot-walled or rate-limited sites are not broken
|
|
541
|
+
* Other statuses, network errors, and timeouts (after one retry): unverifiable — logged as a warning
|
|
542
|
+
|
|
543
|
+
=== Environment variables
|
|
544
|
+
|
|
545
|
+
This extension does not require any environment variables.
|
|
546
|
+
|
|
547
|
+
=== Configuration options
|
|
548
|
+
|
|
549
|
+
internal_hostnames (optional)::
|
|
550
|
+
Hostnames to skip because they are internal documentation URLs. Default: `['docs.redpanda.com']`.
|
|
551
|
+
|
|
552
|
+
include (optional)::
|
|
553
|
+
List of regular expressions. When set, only URLs matching at least one pattern are checked.
|
|
554
|
+
|
|
555
|
+
exclude (optional)::
|
|
556
|
+
List of regular expressions. URLs matching any pattern are skipped.
|
|
557
|
+
|
|
558
|
+
concurrency (optional)::
|
|
559
|
+
Number of simultaneous requests. Default: `10`.
|
|
560
|
+
|
|
561
|
+
timeout (optional)::
|
|
562
|
+
Per-request timeout in milliseconds. Default: `10000`.
|
|
563
|
+
|
|
564
|
+
fail_on_broken (optional)::
|
|
565
|
+
Log broken links at error level so a build run with `--log-failure-level=error` fails. Default: `false`.
|
|
566
|
+
|
|
567
|
+
=== Registration
|
|
568
|
+
|
|
569
|
+
Register the `external-link-checker` extension in the Antora playbook under the `antora.extensions` key like so:
|
|
570
|
+
|
|
571
|
+
[source,yaml]
|
|
572
|
+
----
|
|
573
|
+
antora:
|
|
574
|
+
extensions:
|
|
575
|
+
- require: '@redpanda-data/docs-extensions-and-macros/extensions/external-link-checker'
|
|
576
|
+
fail_on_broken: false
|
|
577
|
+
----
|
|
578
|
+
|
|
488
579
|
== Validate attributes
|
|
489
580
|
|
|
490
581
|
This extension ensures the consistency and validity of page attributes, focusing on validating page categories against a predefined list of valid categories and subcategories. It automatically adds missing parent categories for any specified subcategories and removes any specified categories that are invalid. Additionally, it processes specific environment attributes, setting corresponding page-level attributes when environment conditions are met.
|
|
@@ -187,6 +187,116 @@ function generateFrontmatter(page) {
|
|
|
187
187
|
return `---\n${yamlContent}---\n\n`
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
// Default class the enterprise inline macro puts on its wrapping span. The
|
|
191
|
+
// macro lets a playbook override it via the enterprise-feature-role document
|
|
192
|
+
// attribute, so match on a substring rather than the exact value.
|
|
193
|
+
const ENTERPRISE_FEATURE_CLASS = 'enterprise-feature'
|
|
194
|
+
|
|
195
|
+
// Status words collected into the single trailing parenthetical.
|
|
196
|
+
const ENTERPRISE_STATUS = 'enterprise'
|
|
197
|
+
const BETA_STATUS = 'beta'
|
|
198
|
+
|
|
199
|
+
// Class the badge macro puts on a beta badge.
|
|
200
|
+
const BETA_BADGE_CLASS = 'badge--beta'
|
|
201
|
+
|
|
202
|
+
// Explicit textual marker for enterprise features in Markdown output.
|
|
203
|
+
const ENTERPRISE_MARKER = `(${ENTERPRISE_STATUS})`
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Format one parenthetical from the status words that apply to a feature.
|
|
207
|
+
*
|
|
208
|
+
* A beta enterprise feature carries two markers, and emitting them separately
|
|
209
|
+
* reads as "Stretch Clusters (enterprise) (beta)". One list avoids the stutter.
|
|
210
|
+
*
|
|
211
|
+
* @param {string[]} statuses - Status words, in display order.
|
|
212
|
+
* @returns {string} Parenthetical, or an empty string when there are none.
|
|
213
|
+
*/
|
|
214
|
+
function formatStatusMarker (statuses) {
|
|
215
|
+
const words = statuses.filter(Boolean)
|
|
216
|
+
return words.length ? `(${words.join(', ')})` : ''
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function hasClass (node, className) {
|
|
220
|
+
if (!node || node.nodeName !== 'SPAN') return false
|
|
221
|
+
const classAttr = node.getAttribute?.('class') || node.className || ''
|
|
222
|
+
return typeof classAttr === 'string' && classAttr.includes(className)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The next sibling element, skipping the whitespace text nodes that sit between
|
|
227
|
+
* the enterprise span and a badge that follows it.
|
|
228
|
+
*/
|
|
229
|
+
function nextElementSibling (node) {
|
|
230
|
+
let sibling = node && node.nextSibling
|
|
231
|
+
while (sibling && sibling.nodeType === 3 && !(sibling.textContent || '').trim()) {
|
|
232
|
+
sibling = sibling.nextSibling
|
|
233
|
+
}
|
|
234
|
+
return sibling && sibling.nodeType === 1 ? sibling : null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Turndown rules that keep the enterprise and beta signals in Markdown output.
|
|
239
|
+
*
|
|
240
|
+
* In HTML both signals are carried by things Turndown discards. The enterprise
|
|
241
|
+
* badge is drawn by CSS from the span's class and explained by a title tooltip,
|
|
242
|
+
* so `enterprise:Tiered Storage[]` would arrive in Markdown as the bare words
|
|
243
|
+
* "Tiered Storage", indistinguishable from any other feature name. The beta
|
|
244
|
+
* badge does survive, because its label keeps real bracketed text, but it lands
|
|
245
|
+
* as a second parenthetical right after the first.
|
|
246
|
+
*
|
|
247
|
+
* These rules emit one trailing parenthetical instead, listing every status
|
|
248
|
+
* that applies, and pass the span's already-converted inner Markdown through
|
|
249
|
+
* untouched so a linked feature keeps its link:
|
|
250
|
+
*
|
|
251
|
+
* [Stretch Clusters](../deploy/stretch-clusters.md) (enterprise, beta)
|
|
252
|
+
*
|
|
253
|
+
* The beta badge is a sibling of the enterprise span rather than a child. The
|
|
254
|
+
* enterprise rule looks ahead for it and, when it finds one, emits no marker of
|
|
255
|
+
* its own and hands the combined marker to the badge. Emitting it there rather
|
|
256
|
+
* than on the enterprise span reuses the whitespace already between the two
|
|
257
|
+
* elements, instead of suppressing the badge and leaving a doubled space.
|
|
258
|
+
*
|
|
259
|
+
* @returns {{enterpriseFeature: object, betaBadge: object}} Turndown rules,
|
|
260
|
+
* both of which must be registered.
|
|
261
|
+
*/
|
|
262
|
+
function createEnterpriseFeatureRules () {
|
|
263
|
+
// Scoped per call so each converter tracks its own deferred markers.
|
|
264
|
+
const deferredToBadge = new WeakSet()
|
|
265
|
+
|
|
266
|
+
const enterpriseFeature = {
|
|
267
|
+
filter: (node) => hasClass(node, ENTERPRISE_FEATURE_CLASS),
|
|
268
|
+
replacement: (content, node) => {
|
|
269
|
+
const inner = (content || '').trim()
|
|
270
|
+
// An empty span carries no feature to mark.
|
|
271
|
+
if (!inner) return ''
|
|
272
|
+
// Nested or already-marked content must not collect a second marker.
|
|
273
|
+
if (inner.toLowerCase().endsWith(ENTERPRISE_MARKER)) return inner
|
|
274
|
+
|
|
275
|
+
const sibling = nextElementSibling(node)
|
|
276
|
+
if (hasClass(sibling, BETA_BADGE_CLASS)) {
|
|
277
|
+
// The badge renders "(enterprise, beta)" for both of us.
|
|
278
|
+
deferredToBadge.add(sibling)
|
|
279
|
+
return inner
|
|
280
|
+
}
|
|
281
|
+
return `${inner} ${ENTERPRISE_MARKER}`
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const betaBadge = {
|
|
286
|
+
filter: (node) => hasClass(node, BETA_BADGE_CLASS),
|
|
287
|
+
replacement: (content, node) =>
|
|
288
|
+
deferredToBadge.has(node)
|
|
289
|
+
? formatStatusMarker([ENTERPRISE_STATUS, BETA_STATUS])
|
|
290
|
+
: formatStatusMarker([BETA_STATUS]),
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { enterpriseFeature, betaBadge }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
module.exports.createEnterpriseFeatureRules = createEnterpriseFeatureRules
|
|
297
|
+
module.exports.formatStatusMarker = formatStatusMarker
|
|
298
|
+
module.exports.ENTERPRISE_MARKER = ENTERPRISE_MARKER
|
|
299
|
+
|
|
190
300
|
module.exports.register = function () {
|
|
191
301
|
raiseListenerLimit(this)
|
|
192
302
|
const logger = this.getLogger('convert-to-markdown-extension')
|
|
@@ -406,6 +516,11 @@ module.exports.register = function () {
|
|
|
406
516
|
})
|
|
407
517
|
|
|
408
518
|
// Markdown table conversion
|
|
519
|
+
// Keep the enterprise-license signal, which is otherwise CSS-only.
|
|
520
|
+
const enterpriseRules = createEnterpriseFeatureRules()
|
|
521
|
+
turndownInstance.addRule('enterprise-feature', enterpriseRules.enterpriseFeature)
|
|
522
|
+
turndownInstance.addRule('beta-badge', enterpriseRules.betaBadge)
|
|
523
|
+
|
|
409
524
|
turndownInstance.addRule('tables', {
|
|
410
525
|
filter: (node) => {
|
|
411
526
|
if (node.nodeName !== 'TABLE') return false
|