@redpanda-data/docs-extensions-and-macros 5.54.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.
- package/bin/doc-tools-mcp.js +4 -5
- package/bin/doc-tools.js +354 -9
- package/bin/mcp-tools/generated-docs-review.js +26 -9
- package/bin/mcp-tools/overrides-audit.js +1 -1
- package/bin/mcp-tools/rpcn-docs.js +4 -14
- package/cli-utils/docs-links-to-xrefs.js +174 -0
- package/cli-utils/format-helm-spec.js +4 -3
- package/cli-utils/generated-marker.js +43 -0
- package/cli-utils/helm-commented-values.js +125 -21
- package/cli-utils/self-managed-docs-branch.js +36 -30
- package/extension-utils/html-utils.js +27 -0
- package/extension-utils/llms-utils.js +6 -2
- package/extension-utils/metadata-candidates.js +304 -0
- package/extension-utils/metadata-check.js +174 -0
- package/extension-utils/metadata-suggest.js +573 -0
- package/extension-utils/taxonomy-categories.js +106 -0
- package/extension-utils/taxonomy.js +566 -0
- package/extensions/REFERENCE.adoc +110 -8
- package/extensions/add-git-dates.js +13 -1
- package/extensions/aggregate-terms.js +34 -20
- package/extensions/algolia-indexer/generate-index.js +13 -1
- package/extensions/algolia-indexer/index.js +4 -2
- package/extensions/convert-sitemap-to-markdown.js +47 -12
- package/extensions/convert-to-markdown.js +176 -0
- package/extensions/generate-fields-only-pages.js +14 -0
- package/extensions/modify-connect-tag-playbook.js +181 -3
- package/extensions/resolve-xrefs-in-attachments.js +1 -12
- package/extensions/url-to-xref.js +92 -22
- package/extensions/util/connect-docs-asset.js +6 -5
- package/extensions/validate-attributes.js +10 -1
- package/extensions/version-fetcher/fetch-latest-docker-tag.js +35 -13
- package/extensions/version-fetcher/get-latest-redpanda-helm-version-from-operator.js +64 -40
- package/extensions/version-fetcher/list-connect-releases.js +3 -3
- package/extensions/version-fetcher/release-lines.js +64 -0
- package/extensions/version-fetcher/set-latest-version.js +73 -3
- package/macros/README.adoc +1 -0
- package/macros/REFERENCE.adoc +99 -0
- package/macros/badge.js +5 -8
- package/macros/enterprise.js +2 -5
- package/macros/glossary.js +4 -14
- package/macros/iceberg-explorer.js +242 -0
- package/macros/prop.js +3 -13
- package/mcp/USER_GUIDE.adoc +25 -135
- package/package.json +5 -2
- package/tools/check-metadata/index.js +523 -0
- package/tools/cloud-regions/cloud-regions-table-adoc.hbs +1 -2
- package/tools/cloud-regions/cloud-regions-table-md.hbs +2 -2
- package/tools/cloud-regions/render-cloud-regions.js +3 -0
- package/tools/dispatch-graph/check-dispatch-graph.js +790 -0
- package/tools/dispatch-graph/classify-runs.js +128 -0
- package/tools/dispatch-graph/parse-workflows.js +499 -0
- package/tools/doc-impact-recording/index.js +190 -0
- package/tools/enterprise-features/detect-gating-changes.js +393 -0
- package/tools/enterprise-features/verify.js +2 -1
- package/tools/gen-rpk-ascii.py +7 -1
- package/tools/lint-strings/fixtures/connect/lint_connect.go +8 -0
- package/tools/lint-strings/fixtures/crd/lint_types.go +4 -1
- package/tools/lint-strings/fixtures/rpk-pr461/delete.base.go +42 -0
- package/tools/lint-strings/fixtures/rpk-pr461/delete.head.go +45 -0
- package/tools/lint-strings/fixtures/rpk-pr461/reviewer-comments.json +1 -0
- package/tools/lint-strings/index.js +133 -3
- package/tools/lint-strings/rules/verbatim-asciidoc.js +166 -1
- package/tools/lint-strings/surfaces/connect.js +72 -12
- package/tools/lint-strings/surfaces/crd.js +10 -6
- package/tools/metrics/metrics.py +11 -0
- package/tools/overrides-audit/README.adoc +1 -2
- package/tools/overrides-audit/adapters/properties.js +1 -1
- package/tools/overrides-audit/index.js +3 -4
- package/tools/property-extractor/generate-handlebars-docs.js +9 -1
- package/tools/property-extractor/helpers/applyPropertyLinks.js +35 -0
- package/tools/redpanda-connect/AUTOMATION.md +3 -36
- package/tools/redpanda-connect/README.adoc +5 -33
- package/tools/redpanda-connect/generate-rpcn-connector-docs.js +32 -11
- package/tools/redpanda-connect/pr-summary-formatter.js +6 -4
- package/tools/redpanda-connect/report-delta.js +6 -3
- package/tools/redpanda-connect/rpcn-connector-docs-handler.js +42 -25
- package/tools/redpanda-connect/templates/bloblang-function.hbs +1 -1
- package/tools/redpanda-connect/templates/descriptions-partials.hbs +1 -1
- package/tools/redpanda-connect/templates/examples-partials.hbs +1 -1
- package/tools/redpanda-connect/templates/fields-partials.hbs +2 -2
- package/tools/redpanda-connect/templates/metadata-partials.hbs +1 -1
- package/tools/redpanda-release-notes/generate-release-notes.js +116 -1
- package/tools/rpk-docs/generate-rpk-docs.js +2 -0
- package/tools/rpk-docs/generate-x-env-partial.js +7 -4
- package/tools/rpk-docs/helpers/index.js +4 -1
- package/tools/rpk-docs/templates/command.hbs +2 -2
- package/tools/overrides-audit/adapters/connect.js +0 -144
|
@@ -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
|
+
}
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
function formatHelmSpec (doc) {
|
|
11
11
|
const formatted = doc
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
.replace(/(\[\d
|
|
12
|
+
// an array index inside a heading's link text (ingress.hosts[0].host)
|
|
13
|
+
// closes the link text early unless its "]" is escaped
|
|
14
|
+
.replace(/(\[\d+)\]\./g, '$1\\].')
|
|
15
|
+
.replace(/(\[\d+)\]\]/g, '$1\\]]')
|
|
15
16
|
// a section title that is a bare URL has to be a link macro to render
|
|
16
17
|
.replace(/^=== +(https?:\/\/[^[]*)\[([^\]]*)\]/gm, '=== link:++$1++[$2]')
|
|
17
18
|
.replace(/^== # (.*)$/gm, '= $1')
|
|
@@ -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 }
|
|
@@ -24,6 +24,19 @@
|
|
|
24
24
|
*
|
|
25
25
|
* yields `external.domain`.
|
|
26
26
|
*
|
|
27
|
+
* The description attaches to the first commented-out key after it. When
|
|
28
|
+
* more commented-out siblings follow in the same comment run, each one
|
|
29
|
+
* with its own plain comment above it is documented by that comment:
|
|
30
|
+
*
|
|
31
|
+
* external:
|
|
32
|
+
* # -- Optional domain advertised to external clients.
|
|
33
|
+
* # domain: local
|
|
34
|
+
* # Optional list of advertised addresses.
|
|
35
|
+
* # addresses:
|
|
36
|
+
*
|
|
37
|
+
* yields `external.domain` and `external.addresses`. A sibling with no
|
|
38
|
+
* comment of its own is not documented.
|
|
39
|
+
*
|
|
27
40
|
* 2. Explicit, fully-qualified syntax that works anywhere in the file:
|
|
28
41
|
*
|
|
29
42
|
* # @doc external.addresses -- Optional list of advertised addresses.
|
|
@@ -33,6 +46,8 @@
|
|
|
33
46
|
* displayed default, which is otherwise `nil`.
|
|
34
47
|
*/
|
|
35
48
|
|
|
49
|
+
const { spawnSync } = require('child_process')
|
|
50
|
+
|
|
36
51
|
// Key names never contain dots or slashes: a dotted or slashed "key" in a
|
|
37
52
|
// comment is prose or a URL (`# docker.redpanda.com/...:4.101.0`), and
|
|
38
53
|
// treating it as a key both fabricates a path and truncates the description.
|
|
@@ -89,10 +104,26 @@ function parseValuesFile (yamlText, { attachRealKeys = false } = {}) {
|
|
|
89
104
|
const records = []
|
|
90
105
|
const stack = [] // enclosing real keys: { name, indent }
|
|
91
106
|
|
|
92
|
-
// Accumulating comment block: { descLines, default, candidate }.
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
107
|
+
// Accumulating comment block: { marked, descLines, default, candidate }.
|
|
108
|
+
// A marked block opens at a `# --` line. Its description is every comment
|
|
109
|
+
// line up to the first commented-out key, and that key (the candidate) is
|
|
110
|
+
// the one it documents, the same way helm-docs reads a `# --` block as the
|
|
111
|
+
// description of the key directly below it.
|
|
112
|
+
//
|
|
113
|
+
// Optional values often ship as a run of commented-out siblings under one
|
|
114
|
+
// marker, each with its own plain comment above it:
|
|
115
|
+
//
|
|
116
|
+
// # -- Optional domain.
|
|
117
|
+
// # domain: local
|
|
118
|
+
// # Optional list of addresses.
|
|
119
|
+
// # addresses:
|
|
120
|
+
//
|
|
121
|
+
// Once a block has its key, a plain comment line at that key's depth (or
|
|
122
|
+
// shallower) ends it and opens an unmarked block for the next sibling, so
|
|
123
|
+
// `addresses` is documented by its own comment instead of handing its key
|
|
124
|
+
// line to the domain description. An unmarked block is only ever emitted
|
|
125
|
+
// when it has description text and a key; it never becomes a dead marker.
|
|
126
|
+
// The run ends at the first non-comment line, as a marked block does.
|
|
96
127
|
let block = null
|
|
97
128
|
let atDoc = null // accumulating @doc entry: { path, descLines, default }
|
|
98
129
|
let skipScalarIndent = -1 // inside a block scalar when >= 0
|
|
@@ -135,6 +166,7 @@ function parseValuesFile (yamlText, { attachRealKeys = false } = {}) {
|
|
|
135
166
|
// The generator drops these; the linter reports them, because their
|
|
136
167
|
// description silently never ships.
|
|
137
168
|
const emitDead = (b) => {
|
|
169
|
+
if (!b.marked) return
|
|
138
170
|
record({
|
|
139
171
|
kind: 'dead-marker',
|
|
140
172
|
descLines: b.descLines,
|
|
@@ -145,12 +177,26 @@ function parseValuesFile (yamlText, { attachRealKeys = false } = {}) {
|
|
|
145
177
|
})
|
|
146
178
|
}
|
|
147
179
|
|
|
180
|
+
const openUnmarkedBlock = (line, firstDescLine) => ({
|
|
181
|
+
marked: false,
|
|
182
|
+
markerLine: line,
|
|
183
|
+
lastLine: line,
|
|
184
|
+
descLines: firstDescLine === null ? [] : [firstDescLine],
|
|
185
|
+
default: '',
|
|
186
|
+
annotations: {},
|
|
187
|
+
candidate: null,
|
|
188
|
+
})
|
|
189
|
+
|
|
148
190
|
// Emit the active block's candidate, if any, and close the block. The
|
|
149
191
|
// emitted key suppresses its own commented subtree until real content
|
|
150
192
|
// appears, so nested example structures do not emit bogus paths.
|
|
151
193
|
const flushBlock = () => {
|
|
152
194
|
if (!block) return
|
|
153
|
-
if (block.candidate) {
|
|
195
|
+
if (block.candidate && !block.marked && !block.descLines.some((l) => l.trim() !== '')) {
|
|
196
|
+
// An unmarked sibling with no comment of its own has nothing to say;
|
|
197
|
+
// still suppress its commented subtree.
|
|
198
|
+
suppressInnerIndent = block.candidate.effIndent
|
|
199
|
+
} else if (block.candidate) {
|
|
154
200
|
const { name, effIndent } = block.candidate
|
|
155
201
|
const parents = stack.filter((k) => k.indent < effIndent).map((k) => k.name)
|
|
156
202
|
record({
|
|
@@ -233,13 +279,26 @@ function parseValuesFile (yamlText, { attachRealKeys = false } = {}) {
|
|
|
233
279
|
// "key" is a URL scheme and the "value" is the scheme-relative part.
|
|
234
280
|
const simpleValue = (value === '' || !/\s/.test(value)) && !value.startsWith('/')
|
|
235
281
|
if (block && simpleValue) {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
282
|
+
const key = { name: commentedKey[3], effIndent }
|
|
283
|
+
if (!block.candidate || effIndent < block.candidate.effIndent) {
|
|
284
|
+
// The first key line after the description is the documented key.
|
|
285
|
+
// A shallower key replaces it, because the deeper line was a nested
|
|
286
|
+
// example structure rather than the key itself.
|
|
287
|
+
block.candidate = key
|
|
288
|
+
block.lastLine = i
|
|
289
|
+
} else if (effIndent > block.candidate.effIndent) {
|
|
290
|
+
// A nested line of the documented key's commented-out example.
|
|
291
|
+
block.lastLine = i
|
|
292
|
+
} else {
|
|
293
|
+
// A sibling key with no comment of its own. The documented key is
|
|
294
|
+
// complete; the sibling opens an unmarked block, which flushBlock
|
|
295
|
+
// drops because it has no description, while still suppressing
|
|
296
|
+
// the sibling's own commented subtree.
|
|
297
|
+
flushBlock()
|
|
298
|
+
suppressInnerIndent = -1
|
|
299
|
+
block = openUnmarkedBlock(i, null)
|
|
300
|
+
block.candidate = key
|
|
241
301
|
}
|
|
242
|
-
block.lastLine = i
|
|
243
302
|
continue
|
|
244
303
|
}
|
|
245
304
|
}
|
|
@@ -248,18 +307,30 @@ function parseValuesFile (yamlText, { attachRealKeys = false } = {}) {
|
|
|
248
307
|
if (descMarker) {
|
|
249
308
|
flushAtDoc()
|
|
250
309
|
flushBlock()
|
|
251
|
-
block = { markerLine: i, lastLine: i, descLines: [descMarker[1]], default: '', annotations: {}, candidate: null }
|
|
310
|
+
block = { marked: true, markerLine: i, lastLine: i, descLines: [descMarker[1]], default: '', annotations: {}, candidate: null }
|
|
252
311
|
continue
|
|
253
312
|
}
|
|
254
313
|
|
|
255
314
|
const comment = line.match(COMMENT_RE)
|
|
256
315
|
if (comment && line.trim().startsWith('#')) {
|
|
257
316
|
if (atDoc) { atDoc.descLines.push(comment[1]); atDoc.lastLine = i }
|
|
258
|
-
// Description lines precede the documented key
|
|
259
|
-
// exists, trailing comment lines belong to its example structure and
|
|
260
|
-
// only extend the span.
|
|
317
|
+
// Description lines precede the documented key.
|
|
261
318
|
else if (block && !block.candidate) { block.descLines.push(comment[1]); block.lastLine = i }
|
|
262
|
-
else if (block)
|
|
319
|
+
else if (block) {
|
|
320
|
+
// After the key: deeper lines, list items, and bare `#` lines belong
|
|
321
|
+
// to its commented-out example. Prose at the key's own depth or
|
|
322
|
+
// shallower is the description of the next commented-out sibling.
|
|
323
|
+
const [, before, after] = line.match(/^(\s*)#(\s*)/)
|
|
324
|
+
const proseIndent = before.length + Math.max(0, after.length - 1)
|
|
325
|
+
const text = comment[1].trim()
|
|
326
|
+
if (text === '' || text.startsWith('- ') || text === '-' || proseIndent > block.candidate.effIndent) {
|
|
327
|
+
block.lastLine = i
|
|
328
|
+
} else {
|
|
329
|
+
flushBlock()
|
|
330
|
+
suppressInnerIndent = -1
|
|
331
|
+
block = openUnmarkedBlock(i, comment[1])
|
|
332
|
+
}
|
|
333
|
+
}
|
|
263
334
|
continue
|
|
264
335
|
}
|
|
265
336
|
|
|
@@ -270,8 +341,9 @@ function parseValuesFile (yamlText, { attachRealKeys = false } = {}) {
|
|
|
270
341
|
flushAtDoc()
|
|
271
342
|
|
|
272
343
|
const realKey = line.match(REAL_KEY_RE)
|
|
273
|
-
if (attachRealKeys && realKey && block && !block.candidate && block.lastLine === i - 1) {
|
|
274
|
-
// helm-docs' own attachment: the block sits DIRECTLY above a
|
|
344
|
+
if (attachRealKeys && realKey && block && block.marked && !block.candidate && block.lastLine === i - 1) {
|
|
345
|
+
// helm-docs' own attachment: the marked block sits DIRECTLY above a
|
|
346
|
+
// real key.
|
|
275
347
|
const indent = realKey[1].length
|
|
276
348
|
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop()
|
|
277
349
|
const name = realKey[2].replace(/^"|"$/g, '')
|
|
@@ -394,6 +466,31 @@ function filterEntriesBySchema (entries, schema) {
|
|
|
394
466
|
return { accepted, rejected }
|
|
395
467
|
}
|
|
396
468
|
|
|
469
|
+
/**
|
|
470
|
+
* Convert Markdown to AsciiDoc with pandoc. `generate helm-spec` uses this for
|
|
471
|
+
* the helm-docs README and for each values.yaml comment it injects, so both
|
|
472
|
+
* get the same conversion. values.yaml comments are Markdown (helm-docs copies
|
|
473
|
+
* them into the README as is), so without it an injected comment's links and
|
|
474
|
+
* emphasis are published as literal Markdown.
|
|
475
|
+
*
|
|
476
|
+
* The `smart` extension is turned off on input. With it, pandoc turns straight
|
|
477
|
+
* quotes into curly ones and writes them as ``text'', which only AsciiDoc
|
|
478
|
+
* Python understood. Asciidoctor prints those backticks and apostrophes
|
|
479
|
+
* literally, so straight quotes are passed through instead.
|
|
480
|
+
*
|
|
481
|
+
* @param {string} markdown - Markdown text.
|
|
482
|
+
* @returns {string} AsciiDoc text, without a trailing newline.
|
|
483
|
+
* @throws {Error} When pandoc cannot be run or exits non-zero.
|
|
484
|
+
*/
|
|
485
|
+
function markdownToAsciiDoc (markdown) {
|
|
486
|
+
if (!markdown || !markdown.trim()) return markdown
|
|
487
|
+
const r = spawnSync('pandoc', ['-f', 'markdown-smart', '-t', 'asciidoc'], { input: markdown, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
|
|
488
|
+
if (r.error) throw new Error(`pandoc failed: ${r.error.message}`)
|
|
489
|
+
if (r.status !== 0) throw new Error(`pandoc exited ${r.status}: ${(r.stderr || '').trim()}`)
|
|
490
|
+
if (r.stderr) process.stderr.write(r.stderr)
|
|
491
|
+
return r.stdout.replace(/\s+$/, '')
|
|
492
|
+
}
|
|
493
|
+
|
|
397
494
|
// Neutralize description lines that would otherwise parse as AsciiDoc
|
|
398
495
|
// structure when injected at block level: section titles re-parent the
|
|
399
496
|
// following sections, and an unclosed block delimiter swallows the rest of
|
|
@@ -414,11 +511,17 @@ function sanitizeDescription (text) {
|
|
|
414
511
|
* sections and are inserted in alphabetical key order. Keys that are already
|
|
415
512
|
* documented are skipped.
|
|
416
513
|
*
|
|
514
|
+
* Descriptions and defaults come from values.yaml comments, which are
|
|
515
|
+
* Markdown. Pass `toAsciiDoc` (for example `markdownToAsciiDoc`) to convert
|
|
516
|
+
* them the way the rest of the document was converted. Without it, the text
|
|
517
|
+
* is inserted as is.
|
|
518
|
+
*
|
|
417
519
|
* @param {string} adoc - The converted AsciiDoc document.
|
|
418
520
|
* @param {Array<{path: string, description: string, default: string}>} entries
|
|
521
|
+
* @param {{toAsciiDoc?: (markdown: string) => string}} [options]
|
|
419
522
|
* @returns {{doc: string, injected: string[], sectionsFound: number}}
|
|
420
523
|
*/
|
|
421
|
-
function injectIntoAsciiDoc (adoc, entries) {
|
|
524
|
+
function injectIntoAsciiDoc (adoc, entries, { toAsciiDoc = (text) => text } = {}) {
|
|
422
525
|
// The label may itself contain brackets (`[storage.volume[0].name]` in the
|
|
423
526
|
// connectors chart), so it is matched to end of line rather than to the
|
|
424
527
|
// first closing bracket.
|
|
@@ -460,9 +563,9 @@ function injectIntoAsciiDoc (adoc, entries) {
|
|
|
460
563
|
const section = [
|
|
461
564
|
`=== link:++${urlPrefix}${entry.path}++[${entry.path}]`,
|
|
462
565
|
'',
|
|
463
|
-
sanitizeDescription(entry.description),
|
|
566
|
+
sanitizeDescription(toAsciiDoc(entry.description)),
|
|
464
567
|
'',
|
|
465
|
-
`*Default:* ${entry.default}`,
|
|
568
|
+
`*Default:* ${toAsciiDoc(entry.default)}`,
|
|
466
569
|
'',
|
|
467
570
|
'',
|
|
468
571
|
].join('\n')
|
|
@@ -492,6 +595,7 @@ module.exports = {
|
|
|
492
595
|
parseValuesFile,
|
|
493
596
|
extractCommentedValueDocs,
|
|
494
597
|
injectIntoAsciiDoc,
|
|
598
|
+
markdownToAsciiDoc,
|
|
495
599
|
filterEntriesBySchema,
|
|
496
600
|
isPathAllowedBySchema,
|
|
497
601
|
// Exported so the lint surface cannot hold its own copy. Every one of these
|
|
@@ -43,51 +43,57 @@ async function fetchRemoteAntoraVersion(deps = {}) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
*
|
|
46
|
+
* Reports whether a branch exists in the docs repository.
|
|
47
47
|
*
|
|
48
|
-
*
|
|
48
|
+
* @param {string} branch - Branch name, such as "main" or "v/25.1".
|
|
49
|
+
* @returns {boolean} True when `git ls-remote` finds the branch.
|
|
50
|
+
*/
|
|
51
|
+
function docsBranchExists(branch) {
|
|
52
|
+
const repo = 'https://github.com/redpanda-data/docs.git'
|
|
53
|
+
return spawnSync('git', ['ls-remote', '--exit-code', '--heads', repo, `refs/heads/${branch}`], {
|
|
54
|
+
stdio: 'ignore'
|
|
55
|
+
}).status === 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Determines the appropriate documentation branch for a given operator tag or branch based on the remote Antora version.
|
|
60
|
+
*
|
|
61
|
+
* Normalizes the input, extracts the major.minor version, and applies version-specific logic to select the correct branch. Verifies that the chosen branch exists in the remote repository.
|
|
62
|
+
* A ref with no version, such as `main`, `dev`, or a feature branch, holds content for the next release, which the docs repository keeps on `main`.
|
|
49
63
|
*
|
|
50
|
-
* @param {string}
|
|
64
|
+
* @param {string} operatorRef - The operator tag or branch to evaluate (for example, "operator/v25.1.2", "v25.1.2", "release/v25.1.x", or "main").
|
|
65
|
+
* @param {object} [deps] - Injectable for tests: { antoraVersion, branchExists, fetchImpl, token }.
|
|
51
66
|
* @returns {Promise<string>} The name of the documentation branch to use.
|
|
52
67
|
*
|
|
53
|
-
* @throws {Error} If the
|
|
68
|
+
* @throws {Error} If the Antora version cannot be fetched or if the determined branch does not exist in the remote repository.
|
|
54
69
|
*/
|
|
55
|
-
async function determineDocsBranch(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const ANTORA = await fetchRemoteAntoraVersion()
|
|
70
|
+
async function determineDocsBranch(operatorRef, deps = {}) {
|
|
71
|
+
const branchExists = deps.branchExists || docsBranchExists
|
|
72
|
+
// Strip any "operator/" or "release/" prefix
|
|
73
|
+
const TAG = operatorRef.replace(/^(?:operator|release)\//, '')
|
|
60
74
|
// Extract v<major>.<minor>
|
|
61
75
|
const mm = TAG.match(/^v(\d+\.\d+)/)
|
|
62
76
|
const filtered = mm ? `v${mm[1]}` : null
|
|
63
77
|
|
|
64
|
-
if (!filtered) {
|
|
65
|
-
throw new Error(`Could not parse major.minor from ${TAG}`)
|
|
66
|
-
}
|
|
67
|
-
// We started versioning the operator in line with Redpanda core versions. But when v2.4.x was the latest version, the docs were still on 25.1 and v25.1.x of the operator was still in beta. So we need to handle this special case.
|
|
68
78
|
let branch
|
|
69
|
-
if (filtered
|
|
70
|
-
|
|
79
|
+
if (!filtered) {
|
|
80
|
+
branch = 'main'
|
|
81
|
+
} else {
|
|
82
|
+
// Pull in the remote Antora version
|
|
83
|
+
const ANTORA = deps.antoraVersion || await fetchRemoteAntoraVersion(deps)
|
|
84
|
+
// We started versioning the operator in line with Redpanda core versions. But when v2.4.x was the latest version, the docs were still on 25.1 and v25.1.x of the operator was still in beta. So we need to handle this special case.
|
|
85
|
+
if (filtered === 'v2.4') {
|
|
86
|
+
branch = ANTORA === '25.1' ? 'main' : 'v/24.3'
|
|
87
|
+
// For all other versions use the v<major>.<minor> branch unless it is the current version, in which case we use 'main'.
|
|
88
|
+
} else if (filtered === `v${ANTORA}`) {
|
|
71
89
|
branch = 'main'
|
|
72
90
|
} else {
|
|
73
|
-
branch =
|
|
91
|
+
branch = `v/${filtered.slice(1)}`
|
|
74
92
|
}
|
|
75
|
-
// For all other versions use the v<major>.<minor> branch unless it is the current version, in which case we use 'main'.
|
|
76
|
-
} else if (filtered === `v${ANTORA}`) {
|
|
77
|
-
branch = 'main'
|
|
78
|
-
} else {
|
|
79
|
-
branch = `v/${filtered.slice(1)}`
|
|
80
93
|
}
|
|
81
94
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const ref = `refs/heads/${branch}`
|
|
85
|
-
const ok = spawnSync('git', ['ls-remote', '--exit-code', '--heads', repo, ref], {
|
|
86
|
-
stdio: 'ignore'
|
|
87
|
-
}).status === 0
|
|
88
|
-
|
|
89
|
-
if (!ok) {
|
|
90
|
-
throw new Error(`Docs branch ${branch} not found in ${repo}`)
|
|
95
|
+
if (!branchExists(branch)) {
|
|
96
|
+
throw new Error(`Docs branch ${branch} not found in https://github.com/redpanda-data/docs.git`)
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
return branch
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared HTML utility functions for extensions and macros.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Escape a value for safe inclusion in HTML text or inside a double-quoted
|
|
9
|
+
* attribute. Escaping `&` first is what keeps the result idempotent-safe for a
|
|
10
|
+
* single pass; escaping `"` is what stops an author-supplied string from ending
|
|
11
|
+
* the attribute it sits in.
|
|
12
|
+
*
|
|
13
|
+
* @param {*} value - Value to escape. `null` and `undefined` become ''.
|
|
14
|
+
* @returns {string} The escaped string.
|
|
15
|
+
*/
|
|
16
|
+
function escapeHtml (value) {
|
|
17
|
+
if (value === null || value === undefined) return ''
|
|
18
|
+
return String(value)
|
|
19
|
+
.replace(/&/g, '&')
|
|
20
|
+
.replace(/</g, '<')
|
|
21
|
+
.replace(/>/g, '>')
|
|
22
|
+
.replace(/"/g, '"')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = {
|
|
26
|
+
escapeHtml,
|
|
27
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { decode } = require('html-entities');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Shared utilities for llms.txt generation and markdown processing.
|
|
5
7
|
* Used by both convert-to-markdown.js and convert-llms-to-txt.js.
|
|
@@ -115,9 +117,11 @@ const MAX_INDEX_CHARS = 45000;
|
|
|
115
117
|
*/
|
|
116
118
|
const MAX_INDEX_DESCRIPTION_CHARS = 200;
|
|
117
119
|
|
|
120
|
+
// Titles and descriptions arrive as converted HTML, so tags are stripped and
|
|
121
|
+
// entities decoded. Decoding comes after stripping: an escaped placeholder
|
|
122
|
+
// such as `<name>` must survive as `<name>`, not be removed as a tag.
|
|
118
123
|
function oneLine(text) {
|
|
119
|
-
return String(text || '')
|
|
120
|
-
.replace(/<[^>]+>/g, '')
|
|
124
|
+
return decode(String(text || '').replace(/<[^>]+>/g, ''))
|
|
121
125
|
.replace(/\s+/g, ' ')
|
|
122
126
|
.trim();
|
|
123
127
|
}
|