@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 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
- let doc = fs.readFileSync(outFile, 'utf8')
1639
- const xrefRe = /https:\/\/docs\.redpanda\.com[^\s\]\[\)"]+(?:\[[^\]]*\])?/g
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
- let doc = fs.readFileSync(opts.output, 'utf8')
1951
- const xrefRe = /https:\/\/docs\.redpanda\.com[^\s\]\[\)"]+(?:\[[^\]]*\])?/g
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 }
@@ -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