@redpanda-data/docs-extensions-and-macros 5.55.0 → 5.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/doc-tools.js +315 -7
- package/cli-utils/docs-links-to-xrefs.js +174 -0
- package/cli-utils/generated-marker.js +43 -0
- package/cli-utils/helm-commented-values.js +37 -3
- 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 +90 -4
- 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 +2 -2
- package/extensions/url-to-xref.js +92 -22
- package/extensions/validate-attributes.js +10 -1
- package/package.json +2 -1
- 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/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/property-extractor/generate-handlebars-docs.js +9 -1
- package/tools/property-extractor/helpers/applyPropertyLinks.js +35 -0
- package/tools/redpanda-connect/generate-rpcn-connector-docs.js +29 -8
- package/tools/redpanda-connect/rpcn-connector-docs-handler.js +19 -24
- 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/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 +1 -1
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* doc-tools check-metadata: the deterministic, model-free taxonomy check for
|
|
5
|
+
* page headers. Docs repos run it on the pages a PR changes, agents run it on
|
|
6
|
+
* what they draft, and the build runs the same rules
|
|
7
|
+
* (extension-utils/metadata-check.js), so the three never disagree.
|
|
8
|
+
*
|
|
9
|
+
* It reads the AsciiDoc header of each page (the attribute entries before the
|
|
10
|
+
* first blank line after the title, including entries inside ifdef/ifndef),
|
|
11
|
+
* loads the taxonomy (extension-utils/taxonomy.js loadTaxonomyFrom), and
|
|
12
|
+
* reports one finding per problem value with file, line, attribute, value,
|
|
13
|
+
* severity, message, and replacement.
|
|
14
|
+
*
|
|
15
|
+
* With --fix, it first rewrites `:page-categories:` in place (fixCategories),
|
|
16
|
+
* then checks the result. Each value becomes the preferred label of the
|
|
17
|
+
* concept it resolves to, and a deprecated value becomes its replacements.
|
|
18
|
+
* A replacement that is a task is dropped rather than written, because the
|
|
19
|
+
* task is derived from the Antora module. A deprecated value with no
|
|
20
|
+
* replacement (Integration, Streaming, GitOps), or whose replacement is a
|
|
21
|
+
* content type (Architecture), needs a judgment call and is left as it is,
|
|
22
|
+
* and so is any value the taxonomy does not know. An attribute left with no
|
|
23
|
+
* values is removed.
|
|
24
|
+
*
|
|
25
|
+
* With --suggest, it also lists the taxonomy values each page's text names
|
|
26
|
+
* that its header does not carry yet (extension-utils/metadata-suggest.js),
|
|
27
|
+
* with a score and evidence, so authors and agents choose values instead of
|
|
28
|
+
* inventing them, and the candidate terms that no concept matches
|
|
29
|
+
* (extension-utils/metadata-candidates.js). --candidates-only aggregates the
|
|
30
|
+
* candidates across the pages instead. Neither changes the exit code.
|
|
31
|
+
*
|
|
32
|
+
* Exit codes: 0 no errors, 1 errors (or warnings with --strict), 2 the check
|
|
33
|
+
* could not run (no taxonomy, an invalid taxonomy, a bad argument).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const fs = require('fs')
|
|
37
|
+
const path = require('path')
|
|
38
|
+
const { spawnSync } = require('child_process')
|
|
39
|
+
const yaml = require('js-yaml')
|
|
40
|
+
const { loadTaxonomyFrom } = require('../../extension-utils/taxonomy')
|
|
41
|
+
const { checkPageMetadata, isSolutionPage, CATEGORY_ATTRIBUTE, TOPIC_TYPE_ATTRIBUTE, SOLUTION_FACETS } = require('../../extension-utils/metadata-check')
|
|
42
|
+
const { buildMatcher, suggestForPage, pageParts, DEFAULT_MIN_SCORE, DEFAULT_LIMIT } = require('../../extension-utils/metadata-suggest')
|
|
43
|
+
const { loadTerminology, pageCandidates, aggregateCandidates } = require('../../extension-utils/metadata-candidates')
|
|
44
|
+
|
|
45
|
+
const CHECKED_ATTRIBUTES = new Set([CATEGORY_ATTRIBUTE, TOPIC_TYPE_ATTRIBUTE, 'page-layout', ...Object.keys(SOLUTION_FACETS)])
|
|
46
|
+
const LEGACY_CATEGORIES_PATH = 'shared/modules/ROOT/partials/valid-categories.yml'
|
|
47
|
+
const GLOSSARY_PATH = 'shared/modules/terms/partials'
|
|
48
|
+
const DEFAULT_GLOB = 'modules/**/*.adoc'
|
|
49
|
+
|
|
50
|
+
const ATTRIBUTE_RE = /^:([A-Za-z0-9_][A-Za-z0-9_-]*)!?:(?:[ \t]+(.*?))?[ \t]*$/
|
|
51
|
+
const CONDITIONAL_RE = /^(?:ifdef|ifndef|ifeval|endif)::/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read the attribute entries of an AsciiDoc header.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} content
|
|
57
|
+
* @returns {Array<{name: string, value: string, line: number, start: number}>}
|
|
58
|
+
* every entry, in order (`start` is its first line, `line` its last when the
|
|
59
|
+
* value is soft-wrapped); an attribute set in two conditional branches
|
|
60
|
+
* appears twice
|
|
61
|
+
*/
|
|
62
|
+
function parseHeader (content) {
|
|
63
|
+
const lines = String(content).split(/\r?\n/)
|
|
64
|
+
const entries = []
|
|
65
|
+
let inBlockComment = false
|
|
66
|
+
let sawTitle = false
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
const line = lines[i]
|
|
69
|
+
if (/^\/{4,}\s*$/.test(line)) {
|
|
70
|
+
inBlockComment = !inBlockComment
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (inBlockComment || /^\/\/(?!\/)/.test(line) || /^\/\/\s*$/.test(line)) continue
|
|
74
|
+
if (/^=\s+\S/.test(line)) {
|
|
75
|
+
if (sawTitle) break
|
|
76
|
+
sawTitle = true
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if (!line.trim()) {
|
|
80
|
+
if (sawTitle) break
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (CONDITIONAL_RE.test(line)) continue
|
|
84
|
+
const m = ATTRIBUTE_RE.exec(line)
|
|
85
|
+
if (!m) break
|
|
86
|
+
let value = m[2] || ''
|
|
87
|
+
const start = i + 1
|
|
88
|
+
// Soft-wrapped value: a line ending in " \" continues on the next.
|
|
89
|
+
while (/\s\\$/.test(value) && i + 1 < lines.length) {
|
|
90
|
+
value = value.replace(/\s\\$/, ' ') + lines[++i].trim()
|
|
91
|
+
}
|
|
92
|
+
entries.push({ name: m[1], value: value.trim(), line: i + 1, start })
|
|
93
|
+
}
|
|
94
|
+
return entries
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The .adoc files changed between the merge base with `base` and HEAD. */
|
|
98
|
+
function gitChangedAdoc (root, base) {
|
|
99
|
+
const run = (args) => spawnSync('git', args, { cwd: root, encoding: 'utf8' })
|
|
100
|
+
const res = run(['diff', '--name-only', '--diff-filter=ACMR', `${base}...HEAD`, '--', '*.adoc'])
|
|
101
|
+
if (res.status !== 0) throw new Error(`git diff against ${base} failed: ${(res.stderr || '').trim()}`)
|
|
102
|
+
return res.stdout.split('\n').map((s) => s.trim()).filter(Boolean)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The files to check: the changed pages with `changed`, the given paths
|
|
107
|
+
* (files, directories, or globs), or every page under modules/.
|
|
108
|
+
*/
|
|
109
|
+
function selectFiles ({ root, paths = [], changed }) {
|
|
110
|
+
const { globSync } = require('glob')
|
|
111
|
+
let selected = []
|
|
112
|
+
if (changed) {
|
|
113
|
+
selected = gitChangedAdoc(root, changed)
|
|
114
|
+
if (paths.length) {
|
|
115
|
+
const micromatch = require('micromatch')
|
|
116
|
+
const prefixes = paths.map((p) => p.replace(/\/+$/, '') + '/')
|
|
117
|
+
selected = selected.filter((f) => micromatch.isMatch(f, paths) || prefixes.some((p) => f.startsWith(p)) || paths.includes(f))
|
|
118
|
+
}
|
|
119
|
+
} else if (paths.length) {
|
|
120
|
+
for (const p of paths) {
|
|
121
|
+
const abs = path.resolve(root, p)
|
|
122
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
|
123
|
+
selected.push(...globSync('**/*.adoc', { cwd: abs, nodir: true, ignore: ['**/node_modules/**'] }).map((f) => path.relative(root, path.join(abs, f))))
|
|
124
|
+
} else if (fs.existsSync(abs)) {
|
|
125
|
+
selected.push(path.relative(root, abs))
|
|
126
|
+
} else {
|
|
127
|
+
selected.push(...globSync(p, { cwd: root, nodir: true, ignore: ['**/node_modules/**'] }))
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
selected = globSync(DEFAULT_GLOB, { cwd: root, nodir: true, ignore: ['**/node_modules/**'] })
|
|
132
|
+
}
|
|
133
|
+
return [...new Set(selected.map((f) => f.split(path.sep).join('/')))]
|
|
134
|
+
.filter((f) => f.endsWith('.adoc') && fs.existsSync(path.resolve(root, f)))
|
|
135
|
+
.sort()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** valid-categories.yml membership, when the docs checkout has it. */
|
|
139
|
+
function loadLegacyCategories (root) {
|
|
140
|
+
const file = path.join(root, LEGACY_CATEGORIES_PATH)
|
|
141
|
+
if (!fs.existsSync(file)) return null
|
|
142
|
+
const list = (yaml.load(fs.readFileSync(file, 'utf8')) || {})['page-valid-categories']
|
|
143
|
+
if (!Array.isArray(list)) return null
|
|
144
|
+
const names = new Set()
|
|
145
|
+
for (const c of list) {
|
|
146
|
+
if (c && c.category) names.add(c.category)
|
|
147
|
+
for (const s of (c && c.subcategories) || []) if (s && s.category) names.add(s.category)
|
|
148
|
+
}
|
|
149
|
+
return (label) => names.has(label)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check pages.
|
|
154
|
+
*
|
|
155
|
+
* @param {Object} input
|
|
156
|
+
* @param {string} input.root
|
|
157
|
+
* @param {Array<string>} input.files - relative to root
|
|
158
|
+
* @param {import('../../extension-utils/taxonomy').Taxonomy} input.taxonomy
|
|
159
|
+
* @param {boolean} [input.strict] - warnings become errors
|
|
160
|
+
* @param {(label: string) => boolean} [input.isLegacyCategory]
|
|
161
|
+
*/
|
|
162
|
+
function checkFiles ({ root, files, taxonomy, strict = false, isLegacyCategory }) {
|
|
163
|
+
const findings = []
|
|
164
|
+
let pages = 0
|
|
165
|
+
let solutions = 0
|
|
166
|
+
for (const file of files) {
|
|
167
|
+
const entries = parseHeader(fs.readFileSync(path.resolve(root, file), 'utf8')).filter((e) => CHECKED_ATTRIBUTES.has(e.name))
|
|
168
|
+
if (!entries.length) continue
|
|
169
|
+
pages++
|
|
170
|
+
// Values that reference attributes resolve only in a build.
|
|
171
|
+
const attributes = {}
|
|
172
|
+
const lineOf = {}
|
|
173
|
+
for (const e of entries) {
|
|
174
|
+
if (e.value.includes('{')) continue
|
|
175
|
+
attributes[e.name] = attributes[e.name] ? `${attributes[e.name]}, ${e.value}` : e.value
|
|
176
|
+
if (!lineOf[e.name]) lineOf[e.name] = []
|
|
177
|
+
lineOf[e.name].push(e)
|
|
178
|
+
}
|
|
179
|
+
const solution = isSolutionPage(attributes)
|
|
180
|
+
if (solution) solutions++
|
|
181
|
+
for (const f of checkPageMetadata(attributes, { taxonomy, solution, isLegacyCategory })) {
|
|
182
|
+
const entry = (lineOf[f.attribute] || []).find((e) => e.value.split(',').map((v) => v.trim()).includes(f.value)) || (lineOf[f.attribute] || [])[0]
|
|
183
|
+
findings.push({
|
|
184
|
+
file,
|
|
185
|
+
line: entry ? entry.line : 1,
|
|
186
|
+
attribute: f.attribute,
|
|
187
|
+
value: f.value,
|
|
188
|
+
severity: strict && f.severity === 'warning' ? 'error' : f.severity,
|
|
189
|
+
rule: f.rule,
|
|
190
|
+
message: f.message,
|
|
191
|
+
replacement: f.replacement,
|
|
192
|
+
replacementIds: f.replacementIds,
|
|
193
|
+
kind: solution ? 'solution' : 'docs',
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.value.localeCompare(b.value))
|
|
198
|
+
const errors = findings.filter((f) => f.severity === 'error').length
|
|
199
|
+
return {
|
|
200
|
+
files: files.length,
|
|
201
|
+
pages,
|
|
202
|
+
solutions,
|
|
203
|
+
strict,
|
|
204
|
+
findings,
|
|
205
|
+
summary: { errors, warnings: findings.length - errors },
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The schemes a page-categories value may name once it is rewritten. A task
|
|
210
|
+
// is derived from the module and a content type is :page-topic-type:, so
|
|
211
|
+
// neither is written into categories.
|
|
212
|
+
const CATEGORY_SCHEMES = new Set(['topic', 'use-case', 'technology', 'industry'])
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* What one page-categories value becomes under --fix.
|
|
216
|
+
*
|
|
217
|
+
* @returns {{values: string[], action: 'keep'|'relabel'|'replace'|'drop', reason?: string}}
|
|
218
|
+
* `keep` leaves the value as written, with the reason when it needs a person
|
|
219
|
+
*/
|
|
220
|
+
function fixCategoryValue (value, taxonomy) {
|
|
221
|
+
if (value.includes('{')) return { values: [value], action: 'keep', reason: 'references an attribute' }
|
|
222
|
+
const hit = taxonomy.resolve(value)
|
|
223
|
+
if (!hit) return { values: [value], action: 'keep', reason: 'not in the taxonomy' }
|
|
224
|
+
const { concept } = hit
|
|
225
|
+
if (!hit.deprecated) {
|
|
226
|
+
if (!CATEGORY_SCHEMES.has(concept.scheme)) return { values: [value], action: 'keep', reason: `is a ${concept.scheme}, not a category` }
|
|
227
|
+
return concept.prefLabel === value ? { values: [value], action: 'keep' } : { values: [concept.prefLabel], action: 'relabel' }
|
|
228
|
+
}
|
|
229
|
+
if (!hit.ids.length) return { values: [value], action: 'keep', reason: 'deprecated with no replacement; choose per page' }
|
|
230
|
+
const schemes = hit.ids.map((id) => taxonomy.get(id).scheme)
|
|
231
|
+
if (schemes.some((sc) => !CATEGORY_SCHEMES.has(sc) && sc !== 'task')) {
|
|
232
|
+
return { values: [value], action: 'keep', reason: `replaced by a ${schemes.find((sc) => !CATEGORY_SCHEMES.has(sc) && sc !== 'task')}; choose per page` }
|
|
233
|
+
}
|
|
234
|
+
const values = hit.ids.filter((id) => CATEGORY_SCHEMES.has(taxonomy.get(id).scheme)).map((id) => taxonomy.label(id))
|
|
235
|
+
return { values, action: values.length ? 'replace' : 'drop' }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Rewrite the `:page-categories:` entries of one page (see the header comment).
|
|
240
|
+
* Soft-wrapped entries are reported and left alone.
|
|
241
|
+
*
|
|
242
|
+
* @param {string} content
|
|
243
|
+
* @param {import('../../extension-utils/taxonomy').Taxonomy} taxonomy
|
|
244
|
+
* @returns {{content: string, changes: Array<{line: number, before: string, after: string|null, edits: Array<Object>}>, kept: Array<{line: number, value: string, reason: string}>}}
|
|
245
|
+
* `after` is null when the entry was removed
|
|
246
|
+
*/
|
|
247
|
+
function fixCategories (content, taxonomy) {
|
|
248
|
+
const text = String(content)
|
|
249
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n'
|
|
250
|
+
const lines = text.split(/\r?\n/)
|
|
251
|
+
const changes = []
|
|
252
|
+
const kept = []
|
|
253
|
+
const remove = new Set()
|
|
254
|
+
for (const entry of parseHeader(text)) {
|
|
255
|
+
if (entry.name !== CATEGORY_ATTRIBUTE) continue
|
|
256
|
+
if (entry.start !== entry.line) {
|
|
257
|
+
kept.push({ line: entry.start, value: entry.value, reason: 'soft-wrapped value; fix it by hand' })
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
const out = []
|
|
261
|
+
const seen = new Set()
|
|
262
|
+
const edits = []
|
|
263
|
+
for (const value of entry.value.split(',').map((v) => v.trim()).filter(Boolean)) {
|
|
264
|
+
const fix = fixCategoryValue(value, taxonomy)
|
|
265
|
+
if (fix.reason) kept.push({ line: entry.line, value, reason: fix.reason })
|
|
266
|
+
const written = []
|
|
267
|
+
for (const v of fix.values) {
|
|
268
|
+
const hit = v.includes('{') ? null : taxonomy.resolve(v)
|
|
269
|
+
const key = hit ? hit.concept.id : `value:${v}`
|
|
270
|
+
if (seen.has(key)) continue
|
|
271
|
+
seen.add(key)
|
|
272
|
+
out.push(v)
|
|
273
|
+
written.push(v)
|
|
274
|
+
}
|
|
275
|
+
if (fix.action !== 'keep' || written.length !== fix.values.length) edits.push({ value, action: fix.action === 'keep' ? 'dedupe' : fix.action, to: written })
|
|
276
|
+
}
|
|
277
|
+
if (!edits.length) continue
|
|
278
|
+
const idx = entry.line - 1
|
|
279
|
+
const before = lines[idx]
|
|
280
|
+
const prefix = before.match(/^(:page-categories!?:)/)[1]
|
|
281
|
+
const after = out.length ? `${prefix} ${out.join(', ')}` : null
|
|
282
|
+
if (after === null) remove.add(idx)
|
|
283
|
+
else lines[idx] = after
|
|
284
|
+
changes.push({ line: entry.line, before, after, edits })
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
content: remove.size ? lines.filter((_, i) => !remove.has(i)).join(eol) : lines.join(eol),
|
|
288
|
+
changes,
|
|
289
|
+
kept,
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Apply fixCategories to files, writing the ones that change. */
|
|
294
|
+
function fixFiles ({ root, files, taxonomy }) {
|
|
295
|
+
const fixed = []
|
|
296
|
+
for (const file of files) {
|
|
297
|
+
const abs = path.resolve(root, file)
|
|
298
|
+
const content = fs.readFileSync(abs, 'utf8')
|
|
299
|
+
const r = fixCategories(content, taxonomy)
|
|
300
|
+
if (r.changes.length) {
|
|
301
|
+
fs.writeFileSync(abs, r.content)
|
|
302
|
+
fixed.push({ file, changes: r.changes, kept: r.kept })
|
|
303
|
+
} else if (r.kept.length) {
|
|
304
|
+
fixed.push({ file, changes: [], kept: r.kept })
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return fixed
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Resolve an include:: target to a file in the checkout, so a single-sourced
|
|
312
|
+
* page is read with the text it publishes. Partials and plain relative paths
|
|
313
|
+
* only: examples are code samples, not prose. Supported forms:
|
|
314
|
+
* partial$x.adoc the including file's module
|
|
315
|
+
* module:partial$x.adoc another module of the same component
|
|
316
|
+
* component:module:partial$x a component checked out at <root>/<component> (shared)
|
|
317
|
+
* ./x.adoc, x.adoc relative to the including file
|
|
318
|
+
*
|
|
319
|
+
* @returns {(target: string, from: string|null) => {content: string, file: string}|null}
|
|
320
|
+
*/
|
|
321
|
+
function makeIncludeReader (root) {
|
|
322
|
+
return (target, from) => {
|
|
323
|
+
const fromRel = from ? from.split(path.sep).join('/') : ''
|
|
324
|
+
const m = /^(.*\/)?modules\/([^/]+)\/(?:pages|partials)\//.exec(fromRel)
|
|
325
|
+
let rel = null
|
|
326
|
+
const family = /^(?:([^:$]+):)?(?:([^:$]+):)?(partial|example|attachment|image|page)\$(.+)$/.exec(target)
|
|
327
|
+
if (family) {
|
|
328
|
+
if (family[3] !== 'partial') return null
|
|
329
|
+
const [component, module] = family[2] !== undefined ? [family[1], family[2]] : [null, family[1]]
|
|
330
|
+
const base = component ? `${component}/` : (m ? m[1] || '' : '')
|
|
331
|
+
const mod = module || (m ? m[2] : 'ROOT')
|
|
332
|
+
rel = `${base}modules/${mod}/partials/${family[4]}`
|
|
333
|
+
} else if (from && !/^[a-z]+:\/\//i.test(target)) {
|
|
334
|
+
rel = path.posix.join(path.posix.dirname(fromRel), target)
|
|
335
|
+
}
|
|
336
|
+
if (!rel) return null
|
|
337
|
+
const abs = path.resolve(root, rel)
|
|
338
|
+
if (!abs.startsWith(path.resolve(root) + path.sep) || !fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null
|
|
339
|
+
return { content: fs.readFileSync(abs, 'utf8'), file: path.relative(root, abs).split(path.sep).join('/') }
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Suggest taxonomy values for pages (`--suggest`), one flat list for agents.
|
|
345
|
+
*
|
|
346
|
+
* @param {Object} input
|
|
347
|
+
* @param {string} input.root
|
|
348
|
+
* @param {Array<string>} input.files
|
|
349
|
+
* @param {import('../../extension-utils/taxonomy').Taxonomy} input.taxonomy
|
|
350
|
+
* @param {number} [input.minScore] - default: DEFAULT_MIN_SCORE for each page's kind
|
|
351
|
+
* @param {number} [input.limit] - per facet; default: DEFAULT_LIMIT for each page's kind
|
|
352
|
+
* @param {{terms: Set<string>, sources: string[]}} [input.terminology] - from loadTerminology
|
|
353
|
+
* @param {boolean} [input.candidatesOnly] - only candidates, aggregated across the pages
|
|
354
|
+
* @param {boolean} [input.emptyOnly] - only pages that carry no page-categories
|
|
355
|
+
* Partials and examples are skipped: they are read through the pages that include them.
|
|
356
|
+
* @returns {{minScore: Object, limit: Object, terminology: string[], suggestions: Array<Object>, needs: Array<Object>, candidates: Array<Object>}}
|
|
357
|
+
* minScore and limit are per page kind ({docs, solution}), as applied. With
|
|
358
|
+
* candidatesOnly, only { terminology, candidates }, one entry per term with
|
|
359
|
+
* its pages.
|
|
360
|
+
*/
|
|
361
|
+
function suggestFiles ({ root, files, taxonomy, minScore, limit, emptyOnly = false, terminology, candidatesOnly = false }) {
|
|
362
|
+
const matcher = buildMatcher(taxonomy)
|
|
363
|
+
const labelMatcher = buildMatcher(taxonomy, { allLabels: true })
|
|
364
|
+
const readInclude = makeIncludeReader(root)
|
|
365
|
+
const terms = terminology ? terminology.terms : new Set()
|
|
366
|
+
const suggestions = []
|
|
367
|
+
const needs = []
|
|
368
|
+
const candidates = []
|
|
369
|
+
const perPage = []
|
|
370
|
+
for (const file of files) {
|
|
371
|
+
// A partial or an example is read through the pages that include it.
|
|
372
|
+
if (/(?:^|\/)(?:partials|examples)\//.test(file)) continue
|
|
373
|
+
const content = fs.readFileSync(path.resolve(root, file), 'utf8')
|
|
374
|
+
if (emptyOnly && parseHeader(content).some((e) => e.name === CATEGORY_ATTRIBUTE && e.value.trim())) continue
|
|
375
|
+
const parts = pageParts(content, parseHeader, { file, readInclude })
|
|
376
|
+
// A corpus run counts a term once per page too, then keeps terms seen twice overall.
|
|
377
|
+
const found = pageCandidates(parts, labelMatcher, { terminology: terms, ...(candidatesOnly ? { minCount: 1, max: Infinity } : {}) })
|
|
378
|
+
if (candidatesOnly) {
|
|
379
|
+
perPage.push({ file, candidates: found })
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
const r = suggestForPage(content, { taxonomy, parseHeader, matcher, minScore, limit, file, readInclude })
|
|
383
|
+
for (const s of r.suggestions) suggestions.push({ file, kind: r.kind, ...s })
|
|
384
|
+
for (const n of r.needs) needs.push({ file, kind: r.kind, ...n })
|
|
385
|
+
for (const c of found) candidates.push({ file, ...c })
|
|
386
|
+
}
|
|
387
|
+
const terminologySources = terminology ? terminology.sources : []
|
|
388
|
+
if (candidatesOnly) return { terminology: terminologySources, candidates: aggregateCandidates(perPage) }
|
|
389
|
+
const perKind = (value, defaults) => (value === undefined ? { ...defaults } : { docs: value, solution: value })
|
|
390
|
+
return { minScore: perKind(minScore, DEFAULT_MIN_SCORE), limit: perKind(limit, DEFAULT_LIMIT), terminology: terminologySources, suggestions, needs, candidates }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Terminology lists that candidates are filtered by: the paths given, else the
|
|
395
|
+
* docs glossary in the checkout at root or at $REDPANDA_DOCS_PATH.
|
|
396
|
+
*/
|
|
397
|
+
function resolveTerminology (root, given, env = process.env) {
|
|
398
|
+
if (given && given.length) return loadTerminology(given.map((p) => path.resolve(root, p)))
|
|
399
|
+
const defaults = [path.join(root, GLOSSARY_PATH), ...(env.REDPANDA_DOCS_PATH ? [path.join(path.resolve(env.REDPANDA_DOCS_PATH), GLOSSARY_PATH)] : [])]
|
|
400
|
+
return loadTerminology(defaults.filter((p) => fs.existsSync(p)).slice(0, 1))
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const lineRef = (l) => (l.source ? `${l.source}:${l.line}` : String(l.line))
|
|
404
|
+
|
|
405
|
+
function formatCandidates (result) {
|
|
406
|
+
const out = []
|
|
407
|
+
for (const c of result.candidates) {
|
|
408
|
+
out.push(`TERM "${c.term}" x${c.count} on ${c.pageCount} page(s) (${c.facet || 'no facet'}, ${c.cue})`)
|
|
409
|
+
for (const p of c.pages.slice(0, 5)) out.push(` ${p.file} x${p.count} at ${p.lines.map(lineRef).join(', ')}`)
|
|
410
|
+
if (c.pages.length > 5) out.push(` and ${c.pages.length - 5} more page(s)`)
|
|
411
|
+
}
|
|
412
|
+
if (out.length) out.push('')
|
|
413
|
+
out.push(`check-metadata --candidates-only: ${result.candidates.length} term(s) that no concept matches, in ${result.files} file(s); terminology from ${result.terminology.length ? result.terminology.join(', ') : 'nowhere (no glossary found; pass --terminology)'}`)
|
|
414
|
+
return out.join('\n')
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function formatText (result) {
|
|
418
|
+
const out = []
|
|
419
|
+
if (result.fixes) {
|
|
420
|
+
for (const f of result.fixes) {
|
|
421
|
+
for (const c of f.changes) out.push(`FIXED ${f.file}:${c.line} ${c.before.trim()} -> ${c.after === null ? '(removed)' : c.after.trim()}`)
|
|
422
|
+
}
|
|
423
|
+
const changed = result.fixes.filter((f) => f.changes.length).length
|
|
424
|
+
out.push(`check-metadata --fix: rewrote page-categories in ${changed} file(s)`, '')
|
|
425
|
+
}
|
|
426
|
+
for (const f of result.findings) {
|
|
427
|
+
out.push(`${f.severity.toUpperCase().padEnd(7)} ${f.file}:${f.line} [${f.rule}] ${f.message}`)
|
|
428
|
+
}
|
|
429
|
+
if (result.suggest) {
|
|
430
|
+
if (out.length) out.push('')
|
|
431
|
+
let last = null
|
|
432
|
+
for (const s of result.suggest.suggestions) {
|
|
433
|
+
if (s.file !== last) {
|
|
434
|
+
out.push(`SUGGEST ${s.file}`)
|
|
435
|
+
last = s.file
|
|
436
|
+
}
|
|
437
|
+
const implied = s.implies.length ? `; implies ${s.implies.map((i) => i.label).join(', ')}` : ''
|
|
438
|
+
out.push(` ${s.attribute}: ${s.label} [${s.facet} ${s.conceptId}] score ${s.score}, ${s.reason}${implied}`)
|
|
439
|
+
for (const e of s.evidence) {
|
|
440
|
+
const where = e.location === 'path' ? 'path' : `${e.source || s.file}:${e.line} ${e.location}`
|
|
441
|
+
out.push(` "${e.term}" (${e.via === 'prefLabel' ? '' : `${e.via} `}${e.label}) at ${where}`)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
for (const n of result.suggest.needs) out.push(`NEEDS ${n.file} ${n.message}`)
|
|
445
|
+
for (const c of result.suggest.candidates) out.push(`TERM ${c.file} "${c.term}" x${c.count} at ${c.lines.map(lineRef).join(', ')} (${c.facet || 'no facet'}, ${c.cue}): no concept matches`)
|
|
446
|
+
out.push(`check-metadata --suggest: ${result.suggest.suggestions.length} suggestion(s) for ${new Set(result.suggest.suggestions.map((s) => s.file)).size} page(s) at min score ${result.suggest.minScore.docs} (Product Docs) and ${result.suggest.minScore.solution} (solutions)`)
|
|
447
|
+
}
|
|
448
|
+
if (out.length) out.push('')
|
|
449
|
+
for (const w of result.taxonomy.warnings) out.push(`note: ${w}`)
|
|
450
|
+
out.push(`check-metadata: ${result.pages} page(s) with taxonomy metadata in ${result.files} file(s) (${result.solutions} solution${result.solutions === 1 ? '' : 's'}), taxonomy from ${result.taxonomy.source}: ${result.summary.errors} error(s), ${result.summary.warnings} warning(s)${result.strict ? ' (strict)' : ''}`)
|
|
451
|
+
return out.join('\n')
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function splitList (values) {
|
|
455
|
+
if (!values) return []
|
|
456
|
+
return [].concat(values).flatMap((v) => String(v).split(',')).map((s) => s.trim()).filter(Boolean)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* @param {Array<string>} paths
|
|
461
|
+
* @param {Object} options - commander options: changed, taxonomy, format, strict, fix, root, output
|
|
462
|
+
* @param {Object} [deps] - { fetch } for tests
|
|
463
|
+
*/
|
|
464
|
+
async function runCli (paths, options = {}, { fetch } = {}) {
|
|
465
|
+
let result
|
|
466
|
+
try {
|
|
467
|
+
if (options.format && !['text', 'json'].includes(options.format)) throw new Error(`--format must be text or json, got ${options.format}`)
|
|
468
|
+
const minScore = options.minScore === undefined ? undefined : Number(options.minScore)
|
|
469
|
+
const limit = options.limit === undefined ? undefined : Number(options.limit)
|
|
470
|
+
if (minScore !== undefined && (!Number.isFinite(minScore) || minScore < 0)) throw new Error(`--min-score must be a number of 0 or more, got ${options.minScore}`)
|
|
471
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) throw new Error(`--limit must be a whole number of 1 or more, got ${options.limit}`)
|
|
472
|
+
if ((options.minScore !== undefined || options.limit !== undefined || options.suggestEmptyOnly) && !options.suggest) throw new Error('--min-score, --limit, and --suggest-empty-only need --suggest')
|
|
473
|
+
if (options.candidatesOnly && (options.suggest || options.fix || options.strict)) throw new Error('--candidates-only runs on its own, without --suggest, --fix, or --strict')
|
|
474
|
+
if (options.terminology && !options.suggest && !options.candidatesOnly) throw new Error('--terminology needs --suggest or --candidates-only')
|
|
475
|
+
const root = path.resolve(options.root || process.cwd())
|
|
476
|
+
const loaded = await loadTaxonomyFrom({ source: options.taxonomy, root, ...(fetch ? { fetch } : {}) })
|
|
477
|
+
const files = selectFiles({ root, paths: splitList(paths), changed: options.changed })
|
|
478
|
+
if (options.candidatesOnly) {
|
|
479
|
+
const terminology = resolveTerminology(root, splitList(options.terminology))
|
|
480
|
+
const { candidates, terminology: sources } = suggestFiles({ root, files, taxonomy: loaded.taxonomy, terminology, candidatesOnly: true })
|
|
481
|
+
result = { taxonomy: { source: loaded.source, schema: loaded.schemaSource, warnings: loaded.warnings }, files: files.length, terminology: sources, candidates }
|
|
482
|
+
if (options.output) {
|
|
483
|
+
fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true })
|
|
484
|
+
fs.writeFileSync(path.resolve(options.output), JSON.stringify(result, null, 2) + '\n')
|
|
485
|
+
}
|
|
486
|
+
console.log(options.format === 'json' ? JSON.stringify(result, null, 2) : formatCandidates(result))
|
|
487
|
+
process.exitCode = 0
|
|
488
|
+
return result
|
|
489
|
+
}
|
|
490
|
+
const fixes = options.fix ? fixFiles({ root, files, taxonomy: loaded.taxonomy }) : undefined
|
|
491
|
+
result = checkFiles({ root, files, taxonomy: loaded.taxonomy, strict: Boolean(options.strict), isLegacyCategory: loadLegacyCategories(root) })
|
|
492
|
+
result = { taxonomy: { source: loaded.source, schema: loaded.schemaSource, warnings: loaded.warnings }, ...result, ...(fixes ? { fixes } : {}) }
|
|
493
|
+
if (options.suggest) {
|
|
494
|
+
result.suggest = suggestFiles({ root, files, taxonomy: loaded.taxonomy, minScore, limit, emptyOnly: Boolean(options.suggestEmptyOnly), terminology: resolveTerminology(root, splitList(options.terminology)) })
|
|
495
|
+
}
|
|
496
|
+
} catch (err) {
|
|
497
|
+
console.error(`check-metadata: ${err.message}`)
|
|
498
|
+
process.exitCode = 2
|
|
499
|
+
return null
|
|
500
|
+
}
|
|
501
|
+
if (options.output) {
|
|
502
|
+
fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true })
|
|
503
|
+
fs.writeFileSync(path.resolve(options.output), JSON.stringify(result, null, 2) + '\n')
|
|
504
|
+
}
|
|
505
|
+
console.log(options.format === 'json' ? JSON.stringify(result, null, 2) : formatText(result))
|
|
506
|
+
process.exitCode = result.summary.errors > 0 ? 1 : 0
|
|
507
|
+
return result
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
module.exports = {
|
|
511
|
+
parseHeader,
|
|
512
|
+
selectFiles,
|
|
513
|
+
checkFiles,
|
|
514
|
+
fixCategoryValue,
|
|
515
|
+
fixCategories,
|
|
516
|
+
fixFiles,
|
|
517
|
+
formatText,
|
|
518
|
+
loadLegacyCategories,
|
|
519
|
+
makeIncludeReader,
|
|
520
|
+
resolveTerminology,
|
|
521
|
+
suggestFiles,
|
|
522
|
+
runCli,
|
|
523
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
{{!-- AsciiDoc Cloud Regions Table Template --}}
|
|
2
|
+
{{{generatedMarker "cloud-regions" "the Redpanda Cloud infrastructure definitions in redpanda-data/cloudv2"}}}
|
|
2
3
|
|
|
3
4
|
////
|
|
4
|
-
This content is auto-generated. Do not edit manually.
|
|
5
|
-
|
|
6
5
|
To regenerate this content, run:
|
|
7
6
|
npx doc-tools generate cloud-regions --help
|
|
8
7
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{{!-- Markdown Cloud Regions Table Template --}}
|
|
2
2
|
|
|
3
|
-
<!--
|
|
4
|
-
This content is
|
|
3
|
+
<!--
|
|
4
|
+
This content is autogenerated. Do not edit manually. Generated by doc-tools (cloud-regions) from the Redpanda Cloud infrastructure definitions in redpanda-data/cloudv2.
|
|
5
5
|
|
|
6
6
|
To regenerate this content, run:
|
|
7
7
|
npx doc-tools generate cloud-regions -h
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const handlebars = require('handlebars');
|
|
4
|
+
const { generatedMarker } = require('../../cli-utils/generated-marker');
|
|
4
5
|
|
|
6
|
+
// The provenance marker the templates write on their first line
|
|
7
|
+
handlebars.registerHelper('generatedMarker', generatedMarker);
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Generates a formatted string representing cloud provider regions using a Handlebars template.
|