@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.
Files changed (87) hide show
  1. package/bin/doc-tools-mcp.js +4 -5
  2. package/bin/doc-tools.js +354 -9
  3. package/bin/mcp-tools/generated-docs-review.js +26 -9
  4. package/bin/mcp-tools/overrides-audit.js +1 -1
  5. package/bin/mcp-tools/rpcn-docs.js +4 -14
  6. package/cli-utils/docs-links-to-xrefs.js +174 -0
  7. package/cli-utils/format-helm-spec.js +4 -3
  8. package/cli-utils/generated-marker.js +43 -0
  9. package/cli-utils/helm-commented-values.js +125 -21
  10. package/cli-utils/self-managed-docs-branch.js +36 -30
  11. package/extension-utils/html-utils.js +27 -0
  12. package/extension-utils/llms-utils.js +6 -2
  13. package/extension-utils/metadata-candidates.js +304 -0
  14. package/extension-utils/metadata-check.js +174 -0
  15. package/extension-utils/metadata-suggest.js +573 -0
  16. package/extension-utils/taxonomy-categories.js +106 -0
  17. package/extension-utils/taxonomy.js +566 -0
  18. package/extensions/REFERENCE.adoc +110 -8
  19. package/extensions/add-git-dates.js +13 -1
  20. package/extensions/aggregate-terms.js +34 -20
  21. package/extensions/algolia-indexer/generate-index.js +13 -1
  22. package/extensions/algolia-indexer/index.js +4 -2
  23. package/extensions/convert-sitemap-to-markdown.js +47 -12
  24. package/extensions/convert-to-markdown.js +176 -0
  25. package/extensions/generate-fields-only-pages.js +14 -0
  26. package/extensions/modify-connect-tag-playbook.js +181 -3
  27. package/extensions/resolve-xrefs-in-attachments.js +1 -12
  28. package/extensions/url-to-xref.js +92 -22
  29. package/extensions/util/connect-docs-asset.js +6 -5
  30. package/extensions/validate-attributes.js +10 -1
  31. package/extensions/version-fetcher/fetch-latest-docker-tag.js +35 -13
  32. package/extensions/version-fetcher/get-latest-redpanda-helm-version-from-operator.js +64 -40
  33. package/extensions/version-fetcher/list-connect-releases.js +3 -3
  34. package/extensions/version-fetcher/release-lines.js +64 -0
  35. package/extensions/version-fetcher/set-latest-version.js +73 -3
  36. package/macros/README.adoc +1 -0
  37. package/macros/REFERENCE.adoc +99 -0
  38. package/macros/badge.js +5 -8
  39. package/macros/enterprise.js +2 -5
  40. package/macros/glossary.js +4 -14
  41. package/macros/iceberg-explorer.js +242 -0
  42. package/macros/prop.js +3 -13
  43. package/mcp/USER_GUIDE.adoc +25 -135
  44. package/package.json +5 -2
  45. package/tools/check-metadata/index.js +523 -0
  46. package/tools/cloud-regions/cloud-regions-table-adoc.hbs +1 -2
  47. package/tools/cloud-regions/cloud-regions-table-md.hbs +2 -2
  48. package/tools/cloud-regions/render-cloud-regions.js +3 -0
  49. package/tools/dispatch-graph/check-dispatch-graph.js +790 -0
  50. package/tools/dispatch-graph/classify-runs.js +128 -0
  51. package/tools/dispatch-graph/parse-workflows.js +499 -0
  52. package/tools/doc-impact-recording/index.js +190 -0
  53. package/tools/enterprise-features/detect-gating-changes.js +393 -0
  54. package/tools/enterprise-features/verify.js +2 -1
  55. package/tools/gen-rpk-ascii.py +7 -1
  56. package/tools/lint-strings/fixtures/connect/lint_connect.go +8 -0
  57. package/tools/lint-strings/fixtures/crd/lint_types.go +4 -1
  58. package/tools/lint-strings/fixtures/rpk-pr461/delete.base.go +42 -0
  59. package/tools/lint-strings/fixtures/rpk-pr461/delete.head.go +45 -0
  60. package/tools/lint-strings/fixtures/rpk-pr461/reviewer-comments.json +1 -0
  61. package/tools/lint-strings/index.js +133 -3
  62. package/tools/lint-strings/rules/verbatim-asciidoc.js +166 -1
  63. package/tools/lint-strings/surfaces/connect.js +72 -12
  64. package/tools/lint-strings/surfaces/crd.js +10 -6
  65. package/tools/metrics/metrics.py +11 -0
  66. package/tools/overrides-audit/README.adoc +1 -2
  67. package/tools/overrides-audit/adapters/properties.js +1 -1
  68. package/tools/overrides-audit/index.js +3 -4
  69. package/tools/property-extractor/generate-handlebars-docs.js +9 -1
  70. package/tools/property-extractor/helpers/applyPropertyLinks.js +35 -0
  71. package/tools/redpanda-connect/AUTOMATION.md +3 -36
  72. package/tools/redpanda-connect/README.adoc +5 -33
  73. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +32 -11
  74. package/tools/redpanda-connect/pr-summary-formatter.js +6 -4
  75. package/tools/redpanda-connect/report-delta.js +6 -3
  76. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +42 -25
  77. package/tools/redpanda-connect/templates/bloblang-function.hbs +1 -1
  78. package/tools/redpanda-connect/templates/descriptions-partials.hbs +1 -1
  79. package/tools/redpanda-connect/templates/examples-partials.hbs +1 -1
  80. package/tools/redpanda-connect/templates/fields-partials.hbs +2 -2
  81. package/tools/redpanda-connect/templates/metadata-partials.hbs +1 -1
  82. package/tools/redpanda-release-notes/generate-release-notes.js +116 -1
  83. package/tools/rpk-docs/generate-rpk-docs.js +2 -0
  84. package/tools/rpk-docs/generate-x-env-partial.js +7 -4
  85. package/tools/rpk-docs/helpers/index.js +4 -1
  86. package/tools/rpk-docs/templates/command.hbs +2 -2
  87. package/tools/overrides-audit/adapters/connect.js +0 -144
@@ -0,0 +1,573 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Taxonomy suggestions for a page: which topics, use cases, technologies, and
5
+ * industries its text names that its header does not carry yet. This is what
6
+ * `doc-tools check-metadata --suggest` runs, so authors and agents choose
7
+ * values from the taxonomy instead of inventing them.
8
+ *
9
+ * It is deterministic and uses no model. Every live concept's prefLabel and
10
+ * altLabels are matched against the page's title, `:description:`, section
11
+ * headings, and body (code blocks included):
12
+ *
13
+ * - Matching is on whole words, case-insensitive, after splitting on
14
+ * anything that is not a letter or digit, so a code literal such as
15
+ * `postgres_cdc` contains the words "postgres" and "cdc" and matches
16
+ * PostgreSQL (altLabel Postgres) and Change data capture (altLabel CDC).
17
+ * - A trailing plural "s" is folded on both sides, so "connector" matches
18
+ * Connectors.
19
+ * - At one position the longest label wins: "Kafka Connect" is Kafka
20
+ * Connect, not Apache Kafka.
21
+ * - A label of one or two letters (Go) is too ambiguous for prose. It matches
22
+ * only with its exact case and never as the first word of a sentence, so
23
+ * "Go to Settings" is not the Go language. A source block's language
24
+ * (`[source,go]`) always counts.
25
+ * - The label of a deprecated concept is evidence for its live replacement
26
+ * ("Data Replication" for Replication), at half weight.
27
+ *
28
+ * Hits are weighted by where they are, title > description > heading > body,
29
+ * and a page's score for a concept is the sum (see WEIGHTS). Body mentions are
30
+ * damped, so a long reference page that names rpk 40 times does not outrank a
31
+ * short page about rpk.
32
+ *
33
+ * Suggestions respect the taxonomy:
34
+ * - deprecated concepts are never suggested;
35
+ * - a facet never goes past its cardinality for the page kind (taxonomy.yml
36
+ * `cardinality`), counting the values the page already has;
37
+ * - a concept the page already carries, or one implied by a value it carries
38
+ * or by a stronger suggestion in the same attribute (a broader topic, or
39
+ * an `implies` target such as Apache Iceberg under Iceberg Topics), is not
40
+ * suggested separately; it is listed under `implies` of the suggestion
41
+ * that implies it.
42
+ */
43
+
44
+ const { splitValues, isSolutionPage, CATEGORY_ATTRIBUTE, SOLUTION_FACETS } = require('./metadata-check')
45
+
46
+ // Schemes that suggestions are made for. Task and deployment are derived from
47
+ // the module and the env-* attributes, and content type is
48
+ // :page-topic-type:, so none of them is matched against text.
49
+ const SUGGEST_SCHEMES = Object.freeze(['topic', 'use-case', 'technology', 'industry'])
50
+
51
+ // Where a suggestion for each scheme is written. A Product Docs page has only
52
+ // page-categories; a solution overview has an attribute per facet.
53
+ const DOCS_ATTRIBUTE = Object.freeze({ topic: CATEGORY_ATTRIBUTE, 'use-case': CATEGORY_ATTRIBUTE, technology: CATEGORY_ATTRIBUTE, industry: CATEGORY_ATTRIBUTE })
54
+ const SOLUTION_ATTRIBUTE = Object.freeze({
55
+ topic: CATEGORY_ATTRIBUTE,
56
+ 'use-case': 'page-solution-use-cases',
57
+ technology: 'page-solution-technologies',
58
+ industry: 'page-solution-industries',
59
+ })
60
+
61
+ // Score of one concept on one page, the sum over where it is named:
62
+ // title, description, path WEIGHTS[location] once, however often
63
+ // heading WEIGHTS.heading per heading, up to maxHeadings
64
+ // body WEIGHTS.body * (1 + log2(mentions)), at most bodyCap
65
+ // `path` is the page's directories and file name under pages/, which the docs
66
+ // team curates (security/tls/, iceberg/). The body cap keeps a concept the
67
+ // body only mentions in passing below the default minimum score: it needs to
68
+ // be named somewhere a reader would see as the page's subject too.
69
+ const LOCATIONS = Object.freeze(['title', 'description', 'path', 'heading', 'body'])
70
+ const WEIGHTS = Object.freeze({ title: 8, description: 5, path: 6, heading: 1.5, maxHeadings: 3, body: 1, bodyCap: 3 })
71
+ const DEPRECATED_LABEL_FACTOR = 0.5
72
+
73
+ // Defaults per page kind, chosen by hiding the categories of tagged pages in
74
+ // docs main and the facets of the redpanda-solutions overviews, and comparing
75
+ // the suggestions with what authors had chosen. A solution overview is short
76
+ // and names each technology it uses in its body, often once, so it needs a
77
+ // lower bar and room for more technologies than a Product Docs page.
78
+ const DEFAULT_MIN_SCORE = Object.freeze({ docs: 5, solution: 3 })
79
+ const DEFAULT_LIMIT = Object.freeze({ docs: 3, solution: 6 })
80
+
81
+ // Used when a scheme in taxonomy.yml has no cardinality for the page kind.
82
+ const DEFAULT_CARDINALITY = Object.freeze({
83
+ docs: { topic: '0-3', 'use-case': '0-3', technology: '0-n', industry: '0-2' },
84
+ solutions: { topic: '0-3', 'use-case': '1-3', technology: '1-n', industry: '0-2' },
85
+ })
86
+
87
+ /** "0-3" -> { min: 0, max: 3 }; "1-n" -> { min: 1, max: Infinity }. */
88
+ function parseCardinality (value) {
89
+ const m = /^(\d+)\s*-\s*(\d+|n)$/.exec(String(value || '').trim())
90
+ if (!m) return { min: 0, max: Infinity }
91
+ return { min: Number(m[1]), max: m[2] === 'n' ? Infinity : Number(m[2]) }
92
+ }
93
+
94
+ function cardinalityOf (taxonomy, scheme, kind) {
95
+ const entry = taxonomy.schemes.get(scheme)
96
+ const key = kind === 'solution' ? 'solutions' : 'docs'
97
+ const raw = entry && entry.cardinality && entry.cardinality[key]
98
+ return parseCardinality(raw || DEFAULT_CARDINALITY[key][scheme])
99
+ }
100
+
101
+ /** Fold a lowercased word: drop a plural "s" (not "ss") from words over 3 letters. */
102
+ function foldWord (word) {
103
+ if (word.length > 3 && word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1)
104
+ return word
105
+ }
106
+
107
+ const WORD_RE = /[\p{L}\p{N}]+/gu
108
+
109
+ /**
110
+ * Split text into words with their offsets.
111
+ * @returns {Array<{raw: string, key: string, index: number, sentenceStart: boolean}>}
112
+ */
113
+ function words (text) {
114
+ const out = []
115
+ let last = 0
116
+ for (const m of String(text).matchAll(WORD_RE)) {
117
+ // The first word of the text (after list or table markup), the first
118
+ // word after a sentence-ending mark, or the first word of a table cell.
119
+ const sentenceStart = out.length === 0
120
+ ? /^[\s*.#|=>-]*$/.test(text.slice(0, m.index))
121
+ : /(?:[.!?:]\s+|\|\s*)$/.test(text.slice(last, m.index))
122
+ out.push({ raw: m[0], key: foldWord(m[0].normalize('NFKC').toLowerCase()), index: m.index, sentenceStart })
123
+ last = m.index + m[0].length
124
+ }
125
+ return out
126
+ }
127
+
128
+ /**
129
+ * Build the label matcher for a taxonomy: every label of every concept in a
130
+ * suggestable scheme, live labels pointing at their concept and deprecated
131
+ * labels at their live replacements in suggestable schemes.
132
+ *
133
+ * @param {import('./taxonomy').Taxonomy} taxonomy
134
+ * @param {Object} [options]
135
+ * @param {boolean} [options.allLabels] - every label of every concept, in
136
+ * every scheme and deprecated or not, each pointing at its own concept: what
137
+ * candidates use to tell whether any concept already covers a term
138
+ */
139
+ function buildMatcher (taxonomy, { allLabels = false } = {}) {
140
+ const byFirst = new Map() // first word key -> [{ words, label, ids, via, exact }]
141
+ const add = (label, ids, via) => {
142
+ const ws = words(label)
143
+ if (!ws.length || !ids.length) return
144
+ const exact = label.replace(/[^\p{L}\p{N}]/gu, '').length <= 2
145
+ const entry = { words: ws.map((w) => w.key), raw: ws.map((w) => w.raw), label, ids, via, exact }
146
+ const first = entry.words[0]
147
+ if (!byFirst.has(first)) byFirst.set(first, [])
148
+ byFirst.get(first).push(entry)
149
+ }
150
+ for (const concept of taxonomy.concepts.values()) {
151
+ if (allLabels) {
152
+ ;[concept.prefLabel, ...concept.altLabels, concept.id.replace(/-/g, ' ')].forEach((label) => add(label, [concept.id], 'label'))
153
+ continue
154
+ }
155
+ if (!SUGGEST_SCHEMES.includes(concept.scheme)) continue
156
+ const labels = [concept.prefLabel, ...concept.altLabels]
157
+ if (!concept.deprecated) {
158
+ labels.forEach((label, i) => add(label, [concept.id], i === 0 ? 'prefLabel' : 'altLabel'))
159
+ // The ID too, for code literals such as `redpanda-connect`, unless it
160
+ // is a short ID that only an exact-case label may match (go).
161
+ const idLabel = concept.id.replace(/-/g, ' ')
162
+ if (idLabel.replace(/[^\p{L}\p{N}]/gu, '').length > 2) add(idLabel, [concept.id], 'id')
163
+ } else {
164
+ const ids = concept.replacedBy.filter((id) => {
165
+ const c = taxonomy.get(id)
166
+ return c && !c.deprecated && SUGGEST_SCHEMES.includes(c.scheme)
167
+ })
168
+ for (const label of labels) add(label, ids, 'deprecated-label')
169
+ }
170
+ }
171
+ // Longest label first, so the first candidate that fits is the longest.
172
+ for (const list of byFirst.values()) list.sort((a, b) => b.words.length - a.words.length)
173
+ return { byFirst, taxonomy }
174
+ }
175
+
176
+ /**
177
+ * Find label matches in one piece of text.
178
+ * @returns {Array<{ids: string[], term: string, label: string, via: string}>}
179
+ */
180
+ function matchText (matcher, text) {
181
+ const ws = words(text)
182
+ const hits = []
183
+ for (let i = 0; i < ws.length; i++) {
184
+ const candidates = matcher.byFirst.get(ws[i].key)
185
+ if (!candidates) continue
186
+ let hit = null
187
+ for (const c of candidates) {
188
+ if (i + c.words.length > ws.length) continue
189
+ let ok = true
190
+ for (let j = 0; j < c.words.length; j++) {
191
+ if (ws[i + j].key !== c.words[j]) { ok = false; break }
192
+ if (c.exact && ws[i + j].raw !== c.raw[j]) { ok = false; break }
193
+ }
194
+ if (ok && c.exact && ws[i].sentenceStart) ok = false
195
+ if (ok) { hit = c; break }
196
+ }
197
+ if (!hit) continue
198
+ const end = ws[i + hit.words.length - 1]
199
+ hits.push({ ids: hit.ids, term: text.slice(ws[i].index, end.index + end.raw.length), label: hit.label, via: hit.via })
200
+ i += hit.words.length - 1
201
+ }
202
+ return hits
203
+ }
204
+
205
+ const MACRO_TARGET_RE = /\b(?:xref|link|mailto|image|video|audio|kbd|btn|menu|footnote|indexterm2?|pass):[^\s[\]]*\[([^\]]*)\]/g
206
+ const URL_RE = /\b(?:https?|ftp):\/\/[^\s[\]<>]+(?:\[([^\]]*)\])?/g
207
+ const XREF_SHORT_RE = /<<[^,>]+,?([^>]*)>>/g
208
+ const ATTR_REF_RE = /\{[A-Za-z0-9_-]+\}/g
209
+
210
+ /** Prose of one line with link targets, URLs, and attribute references removed. */
211
+ function proseOf (line) {
212
+ return line
213
+ .replace(MACRO_TARGET_RE, (_, text) => ` ${text} `)
214
+ .replace(URL_RE, (_, text) => ` ${text || ''} `)
215
+ .replace(XREF_SHORT_RE, (_, text) => ` ${text} `)
216
+ .replace(ATTR_REF_RE, ' ')
217
+ }
218
+
219
+ const INCLUDE_RE = /^include::([^[]+)\[(.*)\]\s*$/
220
+ const MAX_INCLUDE_DEPTH = 3
221
+
222
+ /** The tags an include selects (tag=x or tags=x;y), or null for the whole file. */
223
+ function includeTags (attrlist) {
224
+ const m = /(?:^|,)\s*tags?=([^,]*)/.exec(attrlist || '')
225
+ if (!m) return null
226
+ const tags = m[1].split(';').map((t) => t.trim()).filter((t) => t && !t.startsWith('!') && t !== '*' && t !== '**')
227
+ return tags.length ? new Set(tags) : null
228
+ }
229
+
230
+ /** Lines of an included file, limited to the selected tags. Keeps line numbers. */
231
+ function taggedLines (content, tags) {
232
+ const lines = String(content).split(/\r?\n/)
233
+ if (!tags) return lines.map((text, i) => ({ text, line: i + 1 }))
234
+ const out = []
235
+ const open = new Set()
236
+ lines.forEach((text, i) => {
237
+ const t = /(?:tag|end)::([^[\s]+)\[\]/.exec(text)
238
+ if (t && /tag::/.test(text) && !/end::/.test(text)) { if (tags.has(t[1])) open.add(t[1]); return }
239
+ if (t && /end::/.test(text)) { open.delete(t[1]); return }
240
+ if (open.size) out.push({ text, line: i + 1 })
241
+ })
242
+ return out
243
+ }
244
+
245
+ /**
246
+ * Split an AsciiDoc page into the parts suggestions weigh.
247
+ *
248
+ * @param {string} content
249
+ * @param {Function} parseHeader - tools/check-metadata parseHeader
250
+ * @param {Object} [options]
251
+ * @param {string} [options.file] - the page's path; its directories and name
252
+ * under pages/ are weighed as the `path` location
253
+ * @param {(target: string, from: string|null) => {content: string, file: string}|null} [options.readInclude]
254
+ * resolves an include:: target (partials only) so single-sourced pages are
255
+ * read with the text they publish
256
+ * @returns {{title, description, path, headings: Array<{text, line, source?}>, body: Array<{text, raw, line, source?, code?, sourceLanguage?}>, attributes: Object, lineOf: Object}}
257
+ * a body line has its prose (`text`), its source (`raw`), and `code: true`
258
+ * inside a listing or literal block
259
+ */
260
+ function pageParts (content, parseHeader, { file, readInclude } = {}) {
261
+ const entries = parseHeader(content)
262
+ const attributes = {}
263
+ const lineOf = {}
264
+ for (const e of entries) {
265
+ attributes[e.name] = attributes[e.name] ? `${attributes[e.name]}, ${e.value}` : e.value
266
+ if (!lineOf[e.name]) lineOf[e.name] = e.line
267
+ }
268
+ const headerEnd = entries.length ? Math.max(...entries.map((e) => e.line)) : 0
269
+ let title = null
270
+ const headings = []
271
+ const body = []
272
+
273
+ const walk = (lines, source, depth, isPage) => {
274
+ let inComment = false
275
+ let inListing = false
276
+ for (const { text: line, line: n } of lines) {
277
+ const at = source ? { line: n, source } : { line: n }
278
+ if (/^\/{4,}\s*$/.test(line)) { inComment = !inComment; continue }
279
+ if (inComment || (!inListing && /^\/\//.test(line))) continue
280
+ // Listing and literal blocks: code, matched like prose (code literals
281
+ // count) but never read as headings.
282
+ if (/^(?:-{4,}|\.{4,})\s*$/.test(line) || /^```/.test(line)) { inListing = !inListing; continue }
283
+ if (inListing && !INCLUDE_RE.test(line)) {
284
+ if (line.trim()) body.push({ text: proseOf(line), raw: line, code: true, ...at })
285
+ continue
286
+ }
287
+ const h = /^(=+)\s+(\S.*)$/.exec(line)
288
+ if (h) {
289
+ if (isPage && h[1].length === 1 && !title) title = { text: proseOf(h[2]), ...at }
290
+ else if (h[1].length > 1) headings.push({ text: proseOf(h[2]), ...at })
291
+ continue
292
+ }
293
+ if (/^:[A-Za-z0-9_-]+!?:/.test(line)) continue // attribute entries set values, they are not prose
294
+ const inc = INCLUDE_RE.exec(line)
295
+ if (inc) {
296
+ if (readInclude && depth < MAX_INCLUDE_DEPTH && !inc[1].includes('{')) {
297
+ const r = readInclude(inc[1].trim(), source || file || null)
298
+ if (r) walk(taggedLines(r.content, includeTags(inc[2])), r.file, depth + 1, false)
299
+ }
300
+ continue
301
+ }
302
+ if (/^(?:ifdef|ifndef|ifeval|endif|image|video|toc)::/.test(line)) continue
303
+ const src = /^\[(?:source)?,\s*([A-Za-z0-9_+#-]+)/.exec(line)
304
+ if (src) { body.push({ text: '', ...at, sourceLanguage: src[1] }); continue }
305
+ if (/^\[.*\]\s*$/.test(line)) continue // other block attribute lines
306
+ if (!line.trim()) continue
307
+ body.push({ text: proseOf(line), raw: line, ...at })
308
+ }
309
+ }
310
+ const lines = String(content).split(/\r?\n/).map((text, i) => ({ text, line: i + 1 }))
311
+ walk(lines.filter((l) => l.line > headerEnd || !/^:[A-Za-z0-9_-]+!?:/.test(l.text)), null, 0, true)
312
+
313
+ const description = attributes.description !== undefined ? { text: proseOf(attributes.description), line: lineOf.description } : null
314
+ let pathPart = null
315
+ if (file) {
316
+ const rel = String(file).split(/[\\/]/).join('/')
317
+ // The module and the path under pages/. A solution's module is its slug
318
+ // (cdc-to-lakehouse); a Product Docs module is a task area (manage).
319
+ const m = /(?:^|\/)modules\/([^/]+)\/pages\/(.+)$/.exec(rel)
320
+ const text = m ? `${m[1]}/${m[2]}` : rel.split('/').pop()
321
+ pathPart = { text: text.replace(/\.adoc$/, '').replace(/[/_]/g, ' / '), line: 0 }
322
+ }
323
+ return { title, description, path: pathPart, headings, body, attributes, lineOf }
324
+ }
325
+
326
+ /**
327
+ * Score every concept a page's text names.
328
+ *
329
+ * @param {Object} parts - from pageParts
330
+ * @param {Object} matcher - from buildMatcher
331
+ * @param {Object} [weights=WEIGHTS]
332
+ * @returns {Map<string, {score: number, counts: Object, evidence: Array}>}
333
+ */
334
+ function scorePage (parts, matcher, weights = WEIGHTS) {
335
+ const taxonomy = matcher.taxonomy
336
+ const W = { ...WEIGHTS, ...weights }
337
+ const acc = new Map()
338
+ const note = (ids, location, part, term, label, via) => {
339
+ for (const id of ids) {
340
+ if (!acc.has(id)) acc.set(id, { counts: Object.fromEntries(LOCATIONS.map((l) => [l, 0])), weighted: Object.fromEntries(LOCATIONS.map((l) => [l, 0])), evidence: [] })
341
+ const a = acc.get(id)
342
+ a.counts[location]++
343
+ a.weighted[location] += via === 'deprecated-label' ? DEPRECATED_LABEL_FACTOR : 1
344
+ a.evidence.push({ term, label, location, line: part.line, ...(part.source ? { source: part.source } : {}), via })
345
+ }
346
+ }
347
+ const scan = (part, location) => {
348
+ if (!part) return
349
+ for (const hit of matchText(matcher, part.text)) note(hit.ids, location, part, hit.term, hit.label, hit.via)
350
+ }
351
+ scan(parts.title, 'title')
352
+ scan(parts.description, 'description')
353
+ scan(parts.path, 'path')
354
+ for (const h of parts.headings) scan(h, 'heading')
355
+ for (const b of parts.body) {
356
+ if (b.sourceLanguage) {
357
+ // A source block's language is a code literal: match it like any word,
358
+ // ignoring the exact-case rule for short labels.
359
+ const key = foldWord(b.sourceLanguage.toLowerCase())
360
+ const c = (matcher.byFirst.get(key) || []).find((e) => e.words.length === 1)
361
+ if (c) note(c.ids, 'body', b, b.sourceLanguage, c.label, 'source-language')
362
+ continue
363
+ }
364
+ scan(b, 'body')
365
+ }
366
+ const out = new Map()
367
+ for (const [id, a] of acc) {
368
+ const concept = taxonomy.get(id)
369
+ if (!concept || concept.deprecated) continue
370
+ const w = a.weighted
371
+ const once = (loc) => W[loc] * Math.min(1, w[loc])
372
+ const bodyScore = w.body > 0 ? Math.min(W.bodyCap, W.body * (1 + Math.log2(Math.max(1, w.body))) * Math.min(1, w.body)) : 0
373
+ const score = once('title') + once('description') + once('path') + W.heading * Math.min(W.maxHeadings, w.heading) + bodyScore
374
+ out.set(id, { score: Math.round(score * 100) / 100, counts: a.counts, evidence: a.evidence })
375
+ }
376
+ return out
377
+ }
378
+
379
+ function reasonOf (counts) {
380
+ const parts = []
381
+ if (counts.title) parts.push('the title')
382
+ if (counts.description) parts.push('the description')
383
+ if (counts.path) parts.push('the page path')
384
+ if (counts.heading) parts.push(`${counts.heading} heading${counts.heading === 1 ? '' : 's'}`)
385
+ if (counts.body) parts.push(`${counts.body} body mention${counts.body === 1 ? '' : 's'}`)
386
+ if (!parts.length) return ''
387
+ const list = parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`
388
+ return `named in ${list}`
389
+ }
390
+
391
+ /**
392
+ * Keep the evidence short: one hit per line and location, the first hit in
393
+ * each location, then the rest in page order, up to `max`.
394
+ */
395
+ function trimEvidence (evidence, max = 5) {
396
+ const seen = new Set()
397
+ const unique = evidence.filter((e) => {
398
+ const key = `${e.source || ''}:${e.line}:${e.location}`
399
+ if (seen.has(key)) return false
400
+ seen.add(key)
401
+ return true
402
+ })
403
+ const firsts = LOCATIONS.map((loc) => unique.find((e) => e.location === loc)).filter(Boolean)
404
+ const rest = unique.filter((e) => !firsts.includes(e))
405
+ return [...firsts, ...rest].slice(0, max).sort((a, b) => LOCATIONS.indexOf(a.location) - LOCATIONS.indexOf(b.location) || a.line - b.line)
406
+ }
407
+
408
+ /**
409
+ * Suggest taxonomy values for one page.
410
+ *
411
+ * @param {string} content - the page's AsciiDoc source
412
+ * @param {Object} options
413
+ * @param {import('./taxonomy').Taxonomy} options.taxonomy
414
+ * @param {Function} options.parseHeader - tools/check-metadata parseHeader
415
+ * @param {Object} [options.matcher] - from buildMatcher, to reuse across pages
416
+ * @param {number} [options.minScore] - default DEFAULT_MIN_SCORE for the page kind
417
+ * @param {number} [options.limit] - per facet; default DEFAULT_LIMIT for the page kind
418
+ * @param {boolean} [options.solution] - default: page-layout solution, or any page-solution-* facet set
419
+ * @param {string} [options.file] - the page's path, weighed as the `path` location
420
+ * @param {Function} [options.readInclude] - see pageParts
421
+ * @param {Object} [options.weights] - overrides WEIGHTS, for measuring
422
+ * @returns {{kind: 'docs'|'solution', existing: Object<string, string[]>, suggestions: Array<Object>, needs: Array<Object>}}
423
+ */
424
+ function suggestForPage (content, { taxonomy, parseHeader, matcher, minScore, limit, solution, file, readInclude, weights } = {}) {
425
+ matcher = matcher || buildMatcher(taxonomy)
426
+ const parts = pageParts(content, parseHeader, { file, readInclude })
427
+ const attrs = parts.attributes
428
+ const isSolution = solution !== undefined
429
+ ? solution
430
+ : isSolutionPage(attrs) || ['page-solution-use-cases', 'page-solution-technologies', 'page-solution-industries'].some((a) => attrs[a] !== undefined)
431
+ const kind = isSolution ? 'solution' : 'docs'
432
+ if (minScore === undefined || minScore === null) minScore = DEFAULT_MIN_SCORE[kind]
433
+ if (limit === undefined || limit === null) limit = DEFAULT_LIMIT[kind]
434
+ const attributeFor = isSolution ? SOLUTION_ATTRIBUTE : DOCS_ATTRIBUTE
435
+
436
+ // What the page already carries, per attribute, with what it implies.
437
+ const carried = new Map() // attribute -> Set(direct live ids)
438
+ const sourceAttrs = isSolution ? [CATEGORY_ATTRIBUTE, ...Object.keys(SOLUTION_FACETS)] : [CATEGORY_ATTRIBUTE]
439
+ for (const attribute of sourceAttrs) {
440
+ const set = new Set()
441
+ for (const value of splitValues(attrs[attribute])) {
442
+ if (value.includes('{')) continue
443
+ const hit = taxonomy.resolve(value, { prefer: attribute === 'page-solution-platforms' ? 'id' : 'label' })
444
+ if (hit) hit.ids.forEach((id) => set.add(id))
445
+ }
446
+ carried.set(attribute, set)
447
+ }
448
+ const allCarried = new Set([...carried.values()].flatMap((s) => [...s]))
449
+ const covered = new Map() // attribute -> Map(id -> implied by id|null)
450
+ for (const scheme of SUGGEST_SCHEMES) {
451
+ const attribute = attributeFor[scheme]
452
+ if (covered.has(attribute)) continue
453
+ const m = new Map()
454
+ for (const id of carried.get(attribute) || []) {
455
+ m.set(id, null)
456
+ for (const [implied, how] of taxonomy.expand([id])) if (how === 'implied' && !m.has(implied)) m.set(implied, id)
457
+ }
458
+ covered.set(attribute, m)
459
+ }
460
+
461
+ const scores = scorePage(parts, matcher, weights)
462
+ const ranked = [...scores.entries()]
463
+ .map(([id, s]) => ({ id, ...s, concept: taxonomy.get(id) }))
464
+ .filter((s) => SUGGEST_SCHEMES.includes(s.concept.scheme))
465
+ .sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
466
+
467
+ // Prefer the narrower concept: a broader one that is outscored or matched
468
+ // by one of its own narrower concepts is implied, not suggested.
469
+ const scoreOf = (id) => (scores.get(id) ? scores.get(id).score : 0)
470
+
471
+ const suggestions = []
472
+ const taken = new Map() // scheme -> count of suggestions
473
+ const existingCount = {}
474
+ for (const scheme of SUGGEST_SCHEMES) {
475
+ existingCount[scheme] = [...(carried.get(attributeFor[scheme]) || [])].filter((id) => taxonomy.get(id) && taxonomy.get(id).scheme === scheme).length
476
+ }
477
+ for (const s of ranked) {
478
+ if (s.score < minScore) break
479
+ const scheme = s.concept.scheme
480
+ const attribute = attributeFor[scheme]
481
+ const cov = covered.get(attribute)
482
+ if (cov.has(s.id)) continue // carried, or implied by what is carried or suggested
483
+ if (attribute === CATEGORY_ATTRIBUTE && allCarried.has(s.id)) continue
484
+ const { max } = cardinalityOf(taxonomy, scheme, kind)
485
+ const room = Math.min(limit, max - existingCount[scheme])
486
+ if ((taken.get(scheme) || 0) >= room) continue
487
+ // A broader concept whose narrower concept also scored: suggest the narrower.
488
+ const narrower = taxonomy.narrower(s.id).filter((n) => scoreOf(n) >= minScore && !cov.has(n))
489
+ if (narrower.length) continue
490
+ const implies = [...taxonomy.expand([s.id])].filter(([id, how]) => how === 'implied' && !cov.has(id)).map(([id]) => id)
491
+ cov.set(s.id, null)
492
+ for (const id of implies) {
493
+ // Same attribute: the implied concept must not be written as well.
494
+ if (attributeFor[taxonomy.get(id).scheme] === attribute) cov.set(id, s.id)
495
+ }
496
+ taken.set(scheme, (taken.get(scheme) || 0) + 1)
497
+ suggestions.push({
498
+ facet: scheme,
499
+ attribute,
500
+ conceptId: s.id,
501
+ label: s.concept.prefLabel,
502
+ score: s.score,
503
+ reason: reasonOf(s.counts),
504
+ implies: implies.map((id) => ({ conceptId: id, label: taxonomy.label(id), facet: taxonomy.get(id).scheme })),
505
+ evidence: trimEvidence(s.evidence),
506
+ })
507
+ }
508
+
509
+ // On a solution overview each facet has its own attribute, so a concept
510
+ // implied across facets (Iceberg Topics implies Apache Iceberg) must be
511
+ // written in the other attribute too. Suggest it there when the page does
512
+ // not carry it, from a value the page carries or a suggestion above.
513
+ const impliers = [
514
+ ...[...allCarried].map((id) => ({ id, attribute: attributeFor[taxonomy.get(id) && taxonomy.get(id).scheme], score: scoreOf(id), evidence: [] })),
515
+ ...suggestions.map((x) => ({ id: x.conceptId, attribute: x.attribute, score: x.score, evidence: x.evidence })),
516
+ ]
517
+ for (const from of impliers) {
518
+ const concept = taxonomy.get(from.id)
519
+ if (!concept || !from.attribute) continue
520
+ for (const target of concept.implies) {
521
+ const t = taxonomy.get(target)
522
+ if (!t || t.deprecated || !SUGGEST_SCHEMES.includes(t.scheme)) continue
523
+ const attribute = attributeFor[t.scheme]
524
+ if (attribute === from.attribute) continue
525
+ const cov = covered.get(attribute)
526
+ if (cov.has(target) || allCarried.has(target)) continue
527
+ const { max } = cardinalityOf(taxonomy, t.scheme, kind)
528
+ if (existingCount[t.scheme] + (taken.get(t.scheme) || 0) >= max) continue
529
+ cov.set(target, from.id)
530
+ taken.set(t.scheme, (taken.get(t.scheme) || 0) + 1)
531
+ suggestions.push({
532
+ facet: t.scheme,
533
+ attribute,
534
+ conceptId: target,
535
+ label: t.prefLabel,
536
+ score: Math.max(from.score, scoreOf(target)),
537
+ reason: `implied by ${concept.prefLabel}, which the page ${allCarried.has(from.id) ? 'carries' : 'is suggested'}`,
538
+ implies: [],
539
+ impliedBy: from.id,
540
+ evidence: from.evidence,
541
+ })
542
+ }
543
+ }
544
+
545
+ // A required facet (min >= 1) with nothing carried and nothing suggested.
546
+ const needs = []
547
+ for (const scheme of SUGGEST_SCHEMES) {
548
+ const { min } = cardinalityOf(taxonomy, scheme, kind)
549
+ if (min > 0 && existingCount[scheme] + (taken.get(scheme) || 0) < min) {
550
+ needs.push({ facet: scheme, attribute: attributeFor[scheme], min, message: `${attributeFor[scheme]} needs at least ${min} ${scheme} value${min === 1 ? '' : 's'} and the text names none above the minimum score; choose from ${taxonomy.conceptIds(scheme).join(', ')}` })
551
+ }
552
+ }
553
+
554
+ const existing = {}
555
+ for (const [attribute, set] of carried) if (set.size) existing[attribute] = [...set]
556
+ return { kind, minScore, limit, existing, suggestions, needs }
557
+ }
558
+
559
+ module.exports = {
560
+ SUGGEST_SCHEMES,
561
+ LOCATIONS,
562
+ WEIGHTS,
563
+ DEFAULT_MIN_SCORE,
564
+ DEFAULT_LIMIT,
565
+ parseCardinality,
566
+ foldWord,
567
+ words,
568
+ buildMatcher,
569
+ matchText,
570
+ pageParts,
571
+ scorePage,
572
+ suggestForPage,
573
+ }
@@ -0,0 +1,106 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * validate-attributes' taxonomy pass, kept apart from the extension so its
5
+ * legacy check stays as it was. The rules themselves are
6
+ * extension-utils/metadata-check.js, the same ones `doc-tools check-metadata`
7
+ * runs on a PR.
8
+ */
9
+
10
+ const { checkPageMetadata, CATEGORY_ATTRIBUTE, TOPIC_TYPE_ATTRIBUTE } = require('./metadata-check')
11
+ const { normalizeKey } = require('./taxonomy')
12
+
13
+ // Components whose pages are not Product Docs, so validate-attributes leaves
14
+ // their categories to the legacy check alone. labs is retiring as it is, and
15
+ // solutions-catalog checks solution metadata itself (and writes the legacy
16
+ // list back onto every solution page before this runs).
17
+ const TAXONOMY_SKIPPED_COMPONENTS = ['labs', 'solutions']
18
+
19
+ function createTaxonomyReport () {
20
+ return new Map()
21
+ }
22
+
23
+ /**
24
+ * Run the metadata rules on a Product Docs page and return the page-categories
25
+ * list the legacy check should see.
26
+ *
27
+ * - A legacy value (one valid-categories.yml lists) passes through unchanged,
28
+ * so page-categories, and the Algolia `categories` built from it, stay
29
+ * exactly what they were.
30
+ * - A value only the taxonomy knows (a new label such as Iceberg Topics) is
31
+ * replaced by the legacy label of the concept it resolves to, or dropped
32
+ * when there is none. It is not an invalid category.
33
+ * - A value neither knows passes through, so the legacy check warns about it
34
+ * per page exactly as before; the report leaves it to that warning.
35
+ *
36
+ * Findings go into `report`, which logTaxonomyReport prints once per value.
37
+ *
38
+ * @returns {Array<string>} the list for the legacy check (unchanged for a skipped component)
39
+ */
40
+ function checkTaxonomy (list, { taxonomy, categoryMap, page, report }) {
41
+ const isLegacy = (label) => categoryMap.categories.has(label) || categoryMap.subcategories.has(label)
42
+ // Legacy labels by lookup key, so a value that differs from its legacy label
43
+ // only in case (the use case 'Disaster recovery' and the old category
44
+ // 'Disaster Recovery') emits the legacy spelling, not a deprecated sibling.
45
+ const legacyByKey = new Map()
46
+ for (const label of [...categoryMap.categories.keys(), ...categoryMap.subcategories.keys()]) {
47
+ if (!legacyByKey.has(normalizeKey(label))) legacyByKey.set(normalizeKey(label), label)
48
+ }
49
+ const legacyFor = (label) => legacyByKey.get(normalizeKey(label))
50
+ const component = page.src && page.src.component
51
+ if (TAXONOMY_SKIPPED_COMPONENTS.includes(component)) return list
52
+ const attrs = page.asciidoc.attributes
53
+ // What the author wrote, before validate-attributes adds legacy parents:
54
+ // this is the list the taxonomy resolves (see authoredCategories).
55
+ attrs['page-categories-authored'] = list.join(', ')
56
+ const where = [component, page.src && page.src.module, (page.src && page.src.relative) || attrs['page-relative-src-path']].filter(Boolean).join(':')
57
+
58
+ const findings = checkPageMetadata(
59
+ { [CATEGORY_ATTRIBUTE]: list, [TOPIC_TYPE_ATTRIBUTE]: attrs[TOPIC_TYPE_ATTRIBUTE] },
60
+ { taxonomy, solution: false, isLegacyCategory: isLegacy }
61
+ )
62
+ for (const f of findings) {
63
+ if (f.rule === 'unknown-value') continue // the legacy check's per-page warning covers it
64
+ const key = `${f.rule}\u0000${f.attribute}\u0000${f.value}`
65
+ if (!report.has(key)) report.set(key, { finding: f, pages: [] })
66
+ report.get(key).pages.push(where)
67
+ }
68
+
69
+ const out = []
70
+ for (const value of list) {
71
+ if (isLegacy(value)) {
72
+ out.push(value)
73
+ continue
74
+ }
75
+ const sameLegacy = legacyFor(value)
76
+ const resolved = taxonomy.resolve(value)
77
+ if (sameLegacy && resolved && !resolved.deprecated) {
78
+ if (!out.includes(sameLegacy)) out.push(sameLegacy)
79
+ continue
80
+ }
81
+ if (!resolved) {
82
+ out.push(value)
83
+ continue
84
+ }
85
+ for (const id of resolved.ids) {
86
+ const legacy = taxonomy.legacyLabel(id, (label) => Boolean(legacyFor(label)))
87
+ const spelled = legacy && legacyFor(legacy)
88
+ if (spelled && !out.includes(spelled)) out.push(spelled)
89
+ }
90
+ }
91
+ return out
92
+ }
93
+
94
+ /**
95
+ * One warning per value rather than one per page: deprecated values are on
96
+ * hundreds of pages until they are rewritten, and a per-page line would bury
97
+ * every other warning in the build. Nothing here fails the build.
98
+ */
99
+ function logTaxonomyReport (report, logger) {
100
+ const sample = (pages) => pages.slice(0, 3).join(', ') + (pages.length > 3 ? `, and ${pages.length - 3} more` : '')
101
+ for (const { finding, pages } of report.values()) {
102
+ logger.warn(`${finding.message} (${pages.length} page${pages.length === 1 ? '' : 's'}): ${sample(pages)}`)
103
+ }
104
+ }
105
+
106
+ module.exports = { TAXONOMY_SKIPPED_COMPONENTS, checkTaxonomy, createTaxonomyReport, logTaxonomyReport }