@redpanda-data/docs-extensions-and-macros 5.55.0 → 5.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/bin/doc-tools.js +315 -7
  2. package/cli-utils/docs-links-to-xrefs.js +174 -0
  3. package/cli-utils/generated-marker.js +43 -0
  4. package/cli-utils/helm-commented-values.js +37 -3
  5. package/extension-utils/llms-utils.js +6 -2
  6. package/extension-utils/metadata-candidates.js +304 -0
  7. package/extension-utils/metadata-check.js +174 -0
  8. package/extension-utils/metadata-suggest.js +573 -0
  9. package/extension-utils/taxonomy-categories.js +106 -0
  10. package/extension-utils/taxonomy.js +566 -0
  11. package/extensions/REFERENCE.adoc +90 -4
  12. package/extensions/algolia-indexer/generate-index.js +13 -1
  13. package/extensions/algolia-indexer/index.js +4 -2
  14. package/extensions/convert-sitemap-to-markdown.js +47 -12
  15. package/extensions/convert-to-markdown.js +176 -0
  16. package/extensions/generate-fields-only-pages.js +14 -0
  17. package/extensions/modify-connect-tag-playbook.js +2 -2
  18. package/extensions/url-to-xref.js +92 -22
  19. package/extensions/validate-attributes.js +10 -1
  20. package/package.json +2 -1
  21. package/tools/check-metadata/index.js +523 -0
  22. package/tools/cloud-regions/cloud-regions-table-adoc.hbs +1 -2
  23. package/tools/cloud-regions/cloud-regions-table-md.hbs +2 -2
  24. package/tools/cloud-regions/render-cloud-regions.js +3 -0
  25. package/tools/dispatch-graph/check-dispatch-graph.js +790 -0
  26. package/tools/dispatch-graph/classify-runs.js +128 -0
  27. package/tools/dispatch-graph/parse-workflows.js +499 -0
  28. package/tools/enterprise-features/detect-gating-changes.js +393 -0
  29. package/tools/enterprise-features/verify.js +2 -1
  30. package/tools/gen-rpk-ascii.py +7 -1
  31. package/tools/lint-strings/fixtures/connect/lint_connect.go +8 -0
  32. package/tools/lint-strings/fixtures/crd/lint_types.go +4 -1
  33. package/tools/lint-strings/fixtures/rpk-pr461/delete.base.go +42 -0
  34. package/tools/lint-strings/fixtures/rpk-pr461/delete.head.go +45 -0
  35. package/tools/lint-strings/fixtures/rpk-pr461/reviewer-comments.json +1 -0
  36. package/tools/lint-strings/index.js +133 -3
  37. package/tools/lint-strings/rules/verbatim-asciidoc.js +166 -1
  38. package/tools/lint-strings/surfaces/connect.js +72 -12
  39. package/tools/lint-strings/surfaces/crd.js +10 -6
  40. package/tools/metrics/metrics.py +11 -0
  41. package/tools/property-extractor/generate-handlebars-docs.js +9 -1
  42. package/tools/property-extractor/helpers/applyPropertyLinks.js +35 -0
  43. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +29 -8
  44. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +19 -24
  45. package/tools/redpanda-connect/templates/bloblang-function.hbs +1 -1
  46. package/tools/redpanda-connect/templates/descriptions-partials.hbs +1 -1
  47. package/tools/redpanda-connect/templates/examples-partials.hbs +1 -1
  48. package/tools/redpanda-connect/templates/fields-partials.hbs +2 -2
  49. package/tools/redpanda-connect/templates/metadata-partials.hbs +1 -1
  50. package/tools/rpk-docs/generate-rpk-docs.js +2 -0
  51. package/tools/rpk-docs/generate-x-env-partial.js +7 -4
  52. package/tools/rpk-docs/helpers/index.js +4 -1
  53. package/tools/rpk-docs/templates/command.hbs +1 -1
@@ -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 auto-generated. Do not edit manually.
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.