@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,304 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Candidate taxonomy terms: frequent, distinctive terms on a page that no
5
+ * concept's label or altLabel matches. `doc-tools check-metadata --suggest`
6
+ * lists them beside the suggestions, and `--candidates-only` aggregates them
7
+ * across a repository, so a person or a later model step can propose new
8
+ * concepts or altLabels from evidence instead of from memory.
9
+ *
10
+ * Deterministic, with no model. Two kinds of term are collected:
11
+ *
12
+ * - Capitalized terms in prose: a run of up to four capitalized, CamelCase,
13
+ * or all-caps words (Amazon S3, BigQuery, SASL), in the description and
14
+ * the body outside code blocks. Titles and headings are left out because
15
+ * they are title case. A single word is counted only where it does not
16
+ * start a sentence, and a leading common word is dropped ("Use Amazon S3"
17
+ * is "Amazon S3").
18
+ * - Code identifiers: a snake_case or kebab-case name in backticks that the
19
+ * page uses as a component (followed by input, output, processor, cache,
20
+ * connector, and so on), or the target of an xref to a Redpanda Connect
21
+ * component page. Other identifiers are configuration properties and
22
+ * fields, not taxonomy values, so they are left out.
23
+ *
24
+ * A term is dropped when a taxonomy label or altLabel matches inside it, when
25
+ * it is in the terminology list (the style guide's word lists or the docs
26
+ * glossary, see loadTerminology), when every word is common English
27
+ * (COMMON_WORDS), or when a single word also appears in lowercase in the
28
+ * page's prose, which is the mark of an ordinary word that started a phrase.
29
+ *
30
+ * Each candidate has a likely facet from simple cues: a connector name or a
31
+ * database, format, or product cue word next to it is a technology; a
32
+ * multi-word title-case name with no cue is a topic (Remote Read Replicas);
33
+ * anything else has no facet.
34
+ */
35
+
36
+ const fs = require('fs')
37
+ const path = require('path')
38
+ const { matchText, words, foldWord } = require('./metadata-suggest')
39
+
40
+ // Common English and docs boilerplate words. A term made only of these is not
41
+ // distinctive. Lowercase.
42
+ const COMMON_WORDS = new Set(`
43
+ a about above across add added after again against all allow also always an and another any are as at available
44
+ back be because been before being below best between both build built but by
45
+ can cannot change changes check choose click close come complete configure configuring connect content contents
46
+ copy could create created current custom
47
+ data default define delete describe description different do does doing done down during
48
+ each edit either else enable enabled end enter even every example examples except
49
+ false file files find first follow following for from full
50
+ get give go good
51
+ has have help here how however
52
+ if important in include includes information install instead into is it its
53
+ just
54
+ keep know
55
+ last learn least less let like limit limits list load local
56
+ make many may more most move must
57
+ name names need new next no none not note now number
58
+ of off on once one only open option optional options or order other our out output over overview
59
+ page part per prerequisites prerequisite previous provide
60
+ read reference related remove replace required requirements result return run running
61
+ same save see select set sets setting settings should show since so some specify start started step steps still
62
+ such summary support supported
63
+ table take than that the their them then there these they this those through tip to true try two type types
64
+ under until up update updated use used uses using
65
+ value values version versions view
66
+ want warning was way we what when where whether which while who why will with within without would write
67
+ yes you your
68
+ caution notice
69
+ b kb mb gb tb pb kib mib gib tib bps kbps mbps gbps ms msec ns us
70
+ api apis ui url urls uri uris id ids ip ips cpu cpus ram os vm vms cli sdk http https tcp udp dns utc sha
71
+ get post put patch head
72
+ monday tuesday wednesday thursday friday saturday sunday
73
+ january february march april june july august september october november december
74
+ `.trim().split(/\s+/))
75
+
76
+ // Words next to a term that say which facet it belongs to.
77
+ const CUES = [
78
+ { facet: 'technology', cue: 'connector-name', re: /^(?:input|output|processor|cache|buffer|connector|scanner|tracer|rate[_ -]?limit|component)s?$/i },
79
+ { facet: 'technology', cue: 'database-name', re: /^(?:database|databases|db|table|tables|replica|cdc|warehouse)$/i },
80
+ { facet: 'technology', cue: 'format-name', re: /^(?:format|formats|schema|schemas|serialization|encoding|file|files)$/i },
81
+ { facet: 'technology', cue: 'product-name', re: /^(?:cli|api|apis|sdk|sdks|client|clients|library|libraries|server|servers|service|services|catalog|catalogs|bucket|buckets|operator|chart|cluster|clusters|plugin|plugins|driver|drivers|provider|providers|console|dashboard|dashboards)$/i },
82
+ ]
83
+ const CONNECT_COMPONENT_XREF_RE = /\bxref:(?:connect:)?components:(?:inputs|outputs|processors|caches|buffers|rate_limits|metrics|tracers|scanners)\/([a-z0-9_]+)\.adoc/g
84
+ const BACKTICK_RE = /`([^`\s]+)`/g
85
+ const IDENT_RE = /^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)+$/
86
+ const CAP_WORD_RE = /^(?:[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]*)*|[A-Z]{2,}[a-z]?[0-9]*|[A-Z][a-z]*[0-9]+[A-Za-z0-9]*|[a-z]+[A-Z][A-Za-z0-9]*)$/
87
+ // Words dropped from the front of a capitalized run: articles, prepositions,
88
+ // and the imperative verbs that start steps ("Configure Amazon S3").
89
+ const LEADING_WORDS = new Set('a an the to for in on of with from by if when and or use using see set create configure enable disable install select click add remove update delete run start stop open go read write check review learn edit choose deploy manage connect test verify view'.split(' '))
90
+ const MAX_TERM_WORDS = 4
91
+ const MAX_LINES = 10
92
+
93
+ /** Lowercase, whitespace-collapsed key. */
94
+ const keyOf = (term) => String(term).normalize('NFKC').replace(/\s+/g, ' ').trim().toLowerCase()
95
+
96
+ /**
97
+ * Read terminology lists into a Set of lowercase keys.
98
+ *
99
+ * Accepts, per path:
100
+ * - a directory of docs glossary partials (shared/modules/terms/partials):
101
+ * each file's `:term-name:` and its `=== heading`;
102
+ * - a Markdown file such as the style guide's terminology.md: the first
103
+ * column of each table, except under a heading that names brands (the
104
+ * third-party brand list names exactly the products a candidate should
105
+ * surface);
106
+ * - any other file: one term per line.
107
+ * A term with an abbreviation in parentheses, "access control list (ACL)",
108
+ * adds both.
109
+ *
110
+ * @param {Array<string>} paths
111
+ * @returns {{terms: Set<string>, sources: string[]}}
112
+ */
113
+ function loadTerminology (paths) {
114
+ const terms = new Set()
115
+ const sources = []
116
+ const add = (raw) => {
117
+ const t = String(raw).replace(/[`*_]/g, '').trim()
118
+ if (!t) return
119
+ const paren = /^(.*?)\s*\(([^)]+)\)\s*$/.exec(t)
120
+ if (paren) {
121
+ add(paren[1])
122
+ add(paren[2])
123
+ return
124
+ }
125
+ terms.add(keyOf(t))
126
+ }
127
+ for (const p of paths || []) {
128
+ if (!fs.existsSync(p)) continue
129
+ sources.push(p)
130
+ if (fs.statSync(p).isDirectory()) {
131
+ for (const f of fs.readdirSync(p).filter((f) => f.endsWith('.adoc')).sort()) {
132
+ const text = fs.readFileSync(path.join(p, f), 'utf8')
133
+ const name = /^:term-name:\s*(.+)$/m.exec(text)
134
+ if (name) add(name[1])
135
+ const heading = /^=+\s+(.+)$/m.exec(text)
136
+ if (heading) add(heading[1])
137
+ }
138
+ continue
139
+ }
140
+ const text = fs.readFileSync(p, 'utf8')
141
+ if (/\.md$/i.test(p)) {
142
+ let brands = false
143
+ for (const line of text.split(/\r?\n/)) {
144
+ const h = /^#+\s+(.*)$/.exec(line)
145
+ if (h) { brands = /brand/i.test(h[1]); continue }
146
+ if (brands) continue
147
+ const row = /^\|([^|]+)\|/.exec(line)
148
+ if (!row || /^\s*:?-+:?\s*$/.test(row[1]) || /^\s*(term|unit|extension)\s*$/i.test(row[1])) continue
149
+ add(row[1])
150
+ }
151
+ } else {
152
+ for (const line of text.split(/\r?\n/)) if (line.trim() && !line.startsWith('#')) add(line)
153
+ }
154
+ }
155
+ return { terms, sources }
156
+ }
157
+
158
+ /** The cue in the word right after a term ("Snowflake catalog"), if any. */
159
+ function cueAfter (word) {
160
+ return word ? CUES.find((x) => x.re.test(word)) || null : null
161
+ }
162
+
163
+ /**
164
+ * Collect candidate terms on one page.
165
+ *
166
+ * @param {Object} parts - from pageParts
167
+ * @param {Object} matcher - from buildMatcher(taxonomy, { allLabels: true }), so a
168
+ * term any concept covers, in any scheme, is dropped
169
+ * @param {Object} [options]
170
+ * @param {Set<string>} [options.terminology] - lowercase keys to drop
171
+ * @param {number} [options.minCount=2] - occurrences on the page
172
+ * @param {number} [options.max=10] - candidates per page, most frequent first
173
+ * @returns {Array<{term: string, count: number, lines: Array<{line: number, source?: string}>, facet: string|null, cue: string}>}
174
+ */
175
+ function pageCandidates (parts, matcher, { terminology = new Set(), minCount = 2, max = 10 } = {}) {
176
+ const found = new Map() // key -> { spellings: Map, count, lines, cues: Map }
177
+ const note = (term, part, cue) => {
178
+ const key = keyOf(term)
179
+ if (!found.has(key)) found.set(key, { spellings: new Map(), count: 0, lines: [], cues: new Map() })
180
+ const f = found.get(key)
181
+ f.spellings.set(term, (f.spellings.get(term) || 0) + 1)
182
+ f.count++
183
+ if (f.lines.length < MAX_LINES && !f.lines.some((l) => l.line === part.line && l.source === part.source)) {
184
+ f.lines.push(part.source ? { line: part.line, source: part.source } : { line: part.line })
185
+ }
186
+ const name = cue ? `${cue.facet}:${cue.cue}` : 'none'
187
+ f.cues.set(name, (f.cues.get(name) || 0) + 1)
188
+ }
189
+
190
+ // Lowercase words in prose: a capitalized single word that also appears
191
+ // lowercase is an ordinary word.
192
+ const lowercase = new Set()
193
+ const prose = [parts.description, ...parts.body.filter((b) => !b.code && !b.sourceLanguage)].filter(Boolean)
194
+ for (const p of prose) for (const w of words(p.text)) if (w.raw === w.raw.toLowerCase()) lowercase.add(foldWord(w.raw))
195
+
196
+ // Capitalized terms.
197
+ for (const p of prose) {
198
+ const ws = words(p.text)
199
+ let i = 0
200
+ while (i < ws.length) {
201
+ if (!CAP_WORD_RE.test(ws[i].raw)) { i++; continue }
202
+ let j = i
203
+ // Consecutive words, separated by a single space only.
204
+ while (j + 1 < ws.length && j + 1 - i < MAX_TERM_WORDS && CAP_WORD_RE.test(ws[j + 1].raw) &&
205
+ /^ $/.test(p.text.slice(ws[j].index + ws[j].raw.length, ws[j + 1].index))) j++
206
+ let start = i
207
+ // Drop leading common words ("Use", "The") and a sentence-starting word.
208
+ while (start < j && (LEADING_WORDS.has(ws[start].raw.toLowerCase()) || (start === i && ws[i].sentenceStart && COMMON_WORDS.has(ws[i].raw.toLowerCase())))) start++
209
+ const span = ws.slice(start, j + 1)
210
+ i = j + 1
211
+ if (!span.length) continue
212
+ if (span.length === 1 && span[0].sentenceStart) continue
213
+ if (span.length === 1 && lowercase.has(foldWord(span[0].raw.toLowerCase()))) continue
214
+ // A lowerCamelCase word is an identifier (stringArray), unless the rest
215
+ // is capitals (mTLS).
216
+ if (span.some((w) => /^[a-z]/.test(w.raw) && !/^[a-z]{1,2}[A-Z0-9]+$/.test(w.raw))) continue
217
+ if (span.every((w) => COMMON_WORDS.has(w.raw.toLowerCase()))) continue
218
+ if (span.every((w) => /^[A-Z0-9]+$/.test(w.raw) && w.raw.length < 2)) continue
219
+ const term = p.text.slice(span[0].index, span[span.length - 1].index + span[span.length - 1].raw.length)
220
+ // Part of a hyphenated compound ("Comma-separated"), not a name.
221
+ const end = span[span.length - 1].index + span[span.length - 1].raw.length
222
+ if (p.text[end] === '-' || p.text[span[0].index - 1] === '-') continue
223
+ note(term, p, cueAfter(ws[j + 1] && ws[j + 1].raw))
224
+ }
225
+ }
226
+
227
+ // Code identifiers used as components, and Connect component xrefs.
228
+ for (const b of parts.body) {
229
+ if (b.sourceLanguage || !b.raw) continue
230
+ for (const m of b.raw.matchAll(CONNECT_COMPONENT_XREF_RE)) note(m[1], b, CUES[0])
231
+ if (b.code) continue
232
+ for (const m of b.raw.matchAll(BACKTICK_RE)) {
233
+ const ident = m[1]
234
+ if (!IDENT_RE.test(ident)) continue
235
+ const [after] = words(b.raw.slice(m.index + m[0].length))
236
+ const cue = cueAfter(after && after.raw)
237
+ if (cue && cue.cue === 'connector-name') note(ident, b, cue)
238
+ }
239
+ }
240
+
241
+ const out = []
242
+ for (const [key, f] of found) {
243
+ if (f.count < minCount) continue
244
+ if (terminology.has(key)) continue
245
+ const spelling = [...f.spellings.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0][0]
246
+ if (matchText(matcher, spelling).length) continue // a label already covers it
247
+ const best = [...f.cues.entries()].filter(([k]) => k !== 'none').sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]
248
+ let facet = null
249
+ let cue = 'none'
250
+ if (best) [facet, cue] = best[0].split(':')
251
+ else if (/^[a-z]+[A-Z]|[a-z][A-Z]/.test(spelling) && !/\s/.test(spelling)) { facet = 'technology'; cue = 'brand-casing' }
252
+ else if (/\s/.test(spelling) && spelling.split(/\s+/).every((w) => /^[A-Z][a-z]/.test(w))) { facet = 'topic'; cue = 'title-case-name' }
253
+ out.push({ term: spelling, count: f.count, lines: f.lines, facet, cue })
254
+ }
255
+ out.sort((a, b) => b.count - a.count || a.term.localeCompare(b.term))
256
+ return out.slice(0, max)
257
+ }
258
+
259
+ /**
260
+ * Aggregate per-page candidates across a repository: one entry per term with
261
+ * the pages it appears in, most widespread first.
262
+ *
263
+ * @param {Array<{file: string, candidates: Array}>} perPage
264
+ * @param {Object} [options]
265
+ * @param {number} [options.minPages=1]
266
+ * @param {number} [options.minCount=2] - occurrences across all pages
267
+ * @param {number} [options.maxPageShare=0.2] - a term on more than this share
268
+ * of the pages is boilerplate (a shared partial, a table heading), not a
269
+ * distinctive term; dropped when there are 20 pages or more
270
+ * @returns {Array<{term, count, pageCount, facet, cue, pages: Array<{file, count, lines}>}>}
271
+ */
272
+ function aggregateCandidates (perPage, { minPages = 1, minCount = 2, maxPageShare = 0.2 } = {}) {
273
+ const all = new Map()
274
+ for (const { file, candidates } of perPage) {
275
+ for (const c of candidates) {
276
+ const key = keyOf(c.term)
277
+ if (!all.has(key)) all.set(key, { spellings: new Map(), count: 0, facets: new Map(), pages: [] })
278
+ const a = all.get(key)
279
+ a.spellings.set(c.term, (a.spellings.get(c.term) || 0) + c.count)
280
+ a.count += c.count
281
+ const fk = `${c.facet || ''}:${c.cue}`
282
+ a.facets.set(fk, (a.facets.get(fk) || 0) + c.count)
283
+ a.pages.push({ file, count: c.count, lines: c.lines })
284
+ }
285
+ }
286
+ const out = []
287
+ for (const a of all.values()) {
288
+ if (a.pages.length < minPages || a.count < minCount) continue
289
+ if (perPage.length >= 20 && a.pages.length > maxPageShare * perPage.length) continue
290
+ const term = [...a.spellings.entries()].sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]))[0][0]
291
+ const ranked = [...a.facets.entries()].sort((x, y) => (x[0].startsWith(':') ? 1 : 0) - (y[0].startsWith(':') ? 1 : 0) || y[1] - x[1] || x[0].localeCompare(y[0]))
292
+ const [facet, cue] = ranked[0][0].split(':')
293
+ out.push({ term, count: a.count, pageCount: a.pages.length, facet: facet || null, cue, pages: a.pages.sort((x, y) => y.count - x.count || x.file.localeCompare(y.file)) })
294
+ }
295
+ out.sort((x, y) => y.pageCount - x.pageCount || y.count - x.count || x.term.localeCompare(y.term))
296
+ return out
297
+ }
298
+
299
+ module.exports = {
300
+ COMMON_WORDS,
301
+ loadTerminology,
302
+ pageCandidates,
303
+ aggregateCandidates,
304
+ }
@@ -0,0 +1,174 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The taxonomy metadata rules, in one place. `doc-tools check-metadata` runs
5
+ * them on page headers in a PR, validate-attributes runs them on Product Docs
6
+ * pages in the build, and solutions-catalog runs them on solution overviews,
7
+ * so the PR check and the build can never disagree about a value.
8
+ *
9
+ * Rules (severity for a Product Docs page / a solution overview):
10
+ *
11
+ * unknown-value the value names no concept warning / error
12
+ * deprecated-value the concept is deprecated warning / warning
13
+ * not-in-taxonomy a legacy category taxonomy.yml lacks warning / warning
14
+ * wrong-facet a solution facet value from another facet - / warning
15
+ * duplicate-concept two values on the page are one concept warning / error
16
+ *
17
+ * `:page-topic-type:` is checked against the content-type scheme, as a
18
+ * warning only. Every message names the replacement when there is one.
19
+ */
20
+
21
+ // Attributes the rules read, with the scheme a value must belong to (null:
22
+ // any scheme, as page-categories mixes topics, use cases, technologies, ...).
23
+ const CATEGORY_ATTRIBUTE = 'page-categories'
24
+ const TOPIC_TYPE_ATTRIBUTE = 'page-topic-type'
25
+ const SOLUTION_FACETS = Object.freeze({
26
+ 'page-solution-use-cases': 'use-case',
27
+ 'page-solution-industries': 'industry',
28
+ 'page-solution-technologies': 'technology',
29
+ 'page-solution-platforms': 'deployment',
30
+ })
31
+ const SOLUTION_LAYOUT = 'solution'
32
+
33
+ const SEVERITY = Object.freeze({
34
+ 'unknown-value': { docs: 'warning', solution: 'error' },
35
+ 'deprecated-value': { docs: 'warning', solution: 'warning' },
36
+ 'not-in-taxonomy': { docs: 'warning', solution: 'warning' },
37
+ 'wrong-facet': { docs: 'warning', solution: 'warning' },
38
+ 'duplicate-concept': { docs: 'warning', solution: 'error' },
39
+ 'unknown-topic-type': { docs: 'warning', solution: 'warning' },
40
+ })
41
+
42
+ /** Split a comma-separated attribute value. */
43
+ function splitValues (value) {
44
+ if (Array.isArray(value)) return value.map((v) => String(v).trim()).filter(Boolean)
45
+ if (value === undefined || value === null || value === true) return []
46
+ return String(value).split(',').map((v) => v.trim()).filter(Boolean)
47
+ }
48
+
49
+ /** Is this the overview page of a solution? */
50
+ function isSolutionPage (attributes) {
51
+ return Boolean(attributes) && attributes['page-layout'] === SOLUTION_LAYOUT
52
+ }
53
+
54
+ function labelsOf (taxonomy, ids) {
55
+ return ids.map((id) => taxonomy.label(id))
56
+ }
57
+
58
+ function describe (taxonomy, id) {
59
+ const concept = taxonomy.get(id)
60
+ return concept ? `${concept.prefLabel} (${concept.scheme})` : id
61
+ }
62
+
63
+ /**
64
+ * Check one page's taxonomy metadata.
65
+ *
66
+ * @param {Object} attributes - attribute name (without colons) -> value. Values
67
+ * are strings as AsciiDoc holds them, or arrays.
68
+ * @param {Object} options
69
+ * @param {import('./taxonomy').Taxonomy} options.taxonomy
70
+ * @param {boolean} [options.solution] - a solution overview; default from page-layout
71
+ * @param {(label: string) => boolean} [options.isLegacyCategory] - valid-categories.yml
72
+ * membership, so a legacy category the taxonomy lacks is a gap, not unknown
73
+ * @returns {Array<{attribute: string, value: string, severity: string, rule: string, message: string, replacement: string|null, replacementIds: string[], conceptIds: string[]}>}
74
+ */
75
+ function checkPageMetadata (attributes, { taxonomy, solution, isLegacyCategory } = {}) {
76
+ const attrs = attributes || {}
77
+ const kind = (solution === undefined ? isSolutionPage(attrs) : solution) ? 'solution' : 'docs'
78
+ const findings = []
79
+ const seen = new Map() // concept id -> { attribute, value }
80
+ const push = (rule, attribute, value, message, { replacementIds = [], conceptIds = [] } = {}) => {
81
+ findings.push({
82
+ attribute,
83
+ value,
84
+ severity: SEVERITY[rule][kind],
85
+ rule,
86
+ message,
87
+ replacement: replacementIds.length ? labelsOf(taxonomy, replacementIds).join(', ') : null,
88
+ replacementIds,
89
+ conceptIds,
90
+ })
91
+ }
92
+ const noteConcepts = (attribute, value, ids) => {
93
+ for (const id of ids) {
94
+ const first = seen.get(id)
95
+ if (first && !(first.attribute === attribute && first.value === value)) {
96
+ push('duplicate-concept', attribute, value,
97
+ `${attribute} value '${value}' is the same concept as ${first.attribute} value '${first.value}': ${describe(taxonomy, id)}. Keep one`,
98
+ { conceptIds: [id] })
99
+ } else if (!first) {
100
+ seen.set(id, { attribute, value })
101
+ }
102
+ }
103
+ }
104
+
105
+ // page-categories: any scheme.
106
+ for (const value of splitValues(attrs[CATEGORY_ATTRIBUTE])) {
107
+ const hit = taxonomy.resolve(value)
108
+ if (!hit) {
109
+ if (isLegacyCategory && isLegacyCategory(value)) {
110
+ push('not-in-taxonomy', CATEGORY_ATTRIBUTE, value, `${CATEGORY_ATTRIBUTE} value '${value}' is in valid-categories.yml but not in taxonomy.yml; add it to the taxonomy`)
111
+ } else {
112
+ push('unknown-value', CATEGORY_ATTRIBUTE, value, `${CATEGORY_ATTRIBUTE} value '${value}' is not in the taxonomy; use an existing label, or add a concept to taxonomy.yml`)
113
+ }
114
+ continue
115
+ }
116
+ if (hit.deprecated) {
117
+ push('deprecated-value', CATEGORY_ATTRIBUTE, value, deprecatedMessage(CATEGORY_ATTRIBUTE, value, hit, taxonomy), { replacementIds: hit.ids, conceptIds: [hit.concept.id] })
118
+ }
119
+ noteConcepts(CATEGORY_ATTRIBUTE, value, hit.ids)
120
+ }
121
+
122
+ // Solution facets: each within its own scheme.
123
+ if (kind === 'solution') {
124
+ for (const [attribute, scheme] of Object.entries(SOLUTION_FACETS)) {
125
+ const prefer = scheme === 'deployment' ? 'id' : 'label'
126
+ for (const value of splitValues(attrs[attribute])) {
127
+ const hit = taxonomy.resolve(value, { prefer })
128
+ if (!hit) {
129
+ push('unknown-value', attribute, value, `${attribute} value '${value}' is not in the taxonomy; use an existing ${scheme}, or add it to taxonomy.yml in the docs repository first`)
130
+ continue
131
+ }
132
+ if (hit.deprecated) {
133
+ push('deprecated-value', attribute, value, deprecatedMessage(attribute, value, hit, taxonomy), { replacementIds: hit.ids, conceptIds: [hit.concept.id] })
134
+ } else if (hit.concept.scheme !== scheme) {
135
+ push('wrong-facet', attribute, value, `${attribute} value '${value}' is ${describe(taxonomy, hit.concept.id)}, not a ${scheme}`, { conceptIds: [hit.concept.id] })
136
+ continue
137
+ }
138
+ noteConcepts(attribute, value, hit.ids)
139
+ }
140
+ }
141
+ }
142
+
143
+ // Content type: one value, warning only.
144
+ const topicType = splitValues(attrs[TOPIC_TYPE_ATTRIBUTE])
145
+ for (const value of topicType) {
146
+ const hit = taxonomy.resolve(value, { schemes: ['content-type'], prefer: 'id' })
147
+ if (!hit) {
148
+ push('unknown-topic-type', TOPIC_TYPE_ATTRIBUTE, value, `${TOPIC_TYPE_ATTRIBUTE} '${value}' is not a content type in the taxonomy; use one of ${taxonomy.conceptIds('content-type').join(', ')}`)
149
+ } else if (hit.deprecated) {
150
+ push('deprecated-value', TOPIC_TYPE_ATTRIBUTE, value, deprecatedMessage(TOPIC_TYPE_ATTRIBUTE, value, hit, taxonomy), { replacementIds: hit.ids, conceptIds: [hit.concept.id] })
151
+ } else if (hit.via !== 'id') {
152
+ push('unknown-topic-type', TOPIC_TYPE_ATTRIBUTE, value, `${TOPIC_TYPE_ATTRIBUTE} '${value}' should be the ID ${hit.concept.id}`, { replacementIds: [hit.concept.id], conceptIds: [hit.concept.id] })
153
+ }
154
+ }
155
+
156
+ return findings
157
+ }
158
+
159
+ function deprecatedMessage (attribute, value, hit, taxonomy) {
160
+ const instead = hit.ids.length
161
+ ? `use ${hit.ids.map((id) => `'${taxonomy.label(id)}'`).join(', ')} instead`
162
+ : 'no single value replaces it; see its definition in taxonomy.yml and choose per page'
163
+ return `${attribute} value '${value}' is deprecated; ${instead}`
164
+ }
165
+
166
+ module.exports = {
167
+ CATEGORY_ATTRIBUTE,
168
+ TOPIC_TYPE_ATTRIBUTE,
169
+ SOLUTION_FACETS,
170
+ SEVERITY,
171
+ splitValues,
172
+ isSolutionPage,
173
+ checkPageMetadata,
174
+ }