@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
@@ -46,6 +46,8 @@
46
46
  * displayed default, which is otherwise `nil`.
47
47
  */
48
48
 
49
+ const { spawnSync } = require('child_process')
50
+
49
51
  // Key names never contain dots or slashes: a dotted or slashed "key" in a
50
52
  // comment is prose or a URL (`# docker.redpanda.com/...:4.101.0`), and
51
53
  // treating it as a key both fabricates a path and truncates the description.
@@ -464,6 +466,31 @@ function filterEntriesBySchema (entries, schema) {
464
466
  return { accepted, rejected }
465
467
  }
466
468
 
469
+ /**
470
+ * Convert Markdown to AsciiDoc with pandoc. `generate helm-spec` uses this for
471
+ * the helm-docs README and for each values.yaml comment it injects, so both
472
+ * get the same conversion. values.yaml comments are Markdown (helm-docs copies
473
+ * them into the README as is), so without it an injected comment's links and
474
+ * emphasis are published as literal Markdown.
475
+ *
476
+ * The `smart` extension is turned off on input. With it, pandoc turns straight
477
+ * quotes into curly ones and writes them as ``text'', which only AsciiDoc
478
+ * Python understood. Asciidoctor prints those backticks and apostrophes
479
+ * literally, so straight quotes are passed through instead.
480
+ *
481
+ * @param {string} markdown - Markdown text.
482
+ * @returns {string} AsciiDoc text, without a trailing newline.
483
+ * @throws {Error} When pandoc cannot be run or exits non-zero.
484
+ */
485
+ function markdownToAsciiDoc (markdown) {
486
+ if (!markdown || !markdown.trim()) return markdown
487
+ const r = spawnSync('pandoc', ['-f', 'markdown-smart', '-t', 'asciidoc'], { input: markdown, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
488
+ if (r.error) throw new Error(`pandoc failed: ${r.error.message}`)
489
+ if (r.status !== 0) throw new Error(`pandoc exited ${r.status}: ${(r.stderr || '').trim()}`)
490
+ if (r.stderr) process.stderr.write(r.stderr)
491
+ return r.stdout.replace(/\s+$/, '')
492
+ }
493
+
467
494
  // Neutralize description lines that would otherwise parse as AsciiDoc
468
495
  // structure when injected at block level: section titles re-parent the
469
496
  // following sections, and an unclosed block delimiter swallows the rest of
@@ -484,11 +511,17 @@ function sanitizeDescription (text) {
484
511
  * sections and are inserted in alphabetical key order. Keys that are already
485
512
  * documented are skipped.
486
513
  *
514
+ * Descriptions and defaults come from values.yaml comments, which are
515
+ * Markdown. Pass `toAsciiDoc` (for example `markdownToAsciiDoc`) to convert
516
+ * them the way the rest of the document was converted. Without it, the text
517
+ * is inserted as is.
518
+ *
487
519
  * @param {string} adoc - The converted AsciiDoc document.
488
520
  * @param {Array<{path: string, description: string, default: string}>} entries
521
+ * @param {{toAsciiDoc?: (markdown: string) => string}} [options]
489
522
  * @returns {{doc: string, injected: string[], sectionsFound: number}}
490
523
  */
491
- function injectIntoAsciiDoc (adoc, entries) {
524
+ function injectIntoAsciiDoc (adoc, entries, { toAsciiDoc = (text) => text } = {}) {
492
525
  // The label may itself contain brackets (`[storage.volume[0].name]` in the
493
526
  // connectors chart), so it is matched to end of line rather than to the
494
527
  // first closing bracket.
@@ -530,9 +563,9 @@ function injectIntoAsciiDoc (adoc, entries) {
530
563
  const section = [
531
564
  `=== link:++${urlPrefix}${entry.path}++[${entry.path}]`,
532
565
  '',
533
- sanitizeDescription(entry.description),
566
+ sanitizeDescription(toAsciiDoc(entry.description)),
534
567
  '',
535
- `*Default:* ${entry.default}`,
568
+ `*Default:* ${toAsciiDoc(entry.default)}`,
536
569
  '',
537
570
  '',
538
571
  ].join('\n')
@@ -562,6 +595,7 @@ module.exports = {
562
595
  parseValuesFile,
563
596
  extractCommentedValueDocs,
564
597
  injectIntoAsciiDoc,
598
+ markdownToAsciiDoc,
565
599
  filterEntriesBySchema,
566
600
  isPathAllowedBySchema,
567
601
  // Exported so the lint surface cannot hold its own copy. Every one of these
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { decode } = require('html-entities');
4
+
3
5
  /**
4
6
  * Shared utilities for llms.txt generation and markdown processing.
5
7
  * Used by both convert-to-markdown.js and convert-llms-to-txt.js.
@@ -115,9 +117,11 @@ const MAX_INDEX_CHARS = 45000;
115
117
  */
116
118
  const MAX_INDEX_DESCRIPTION_CHARS = 200;
117
119
 
120
+ // Titles and descriptions arrive as converted HTML, so tags are stripped and
121
+ // entities decoded. Decoding comes after stripping: an escaped placeholder
122
+ // such as `&lt;name&gt;` must survive as `<name>`, not be removed as a tag.
118
123
  function oneLine(text) {
119
- return String(text || '')
120
- .replace(/<[^>]+>/g, '')
124
+ return decode(String(text || '').replace(/<[^>]+>/g, ''))
121
125
  .replace(/\s+/g, ' ')
122
126
  .trim();
123
127
  }
@@ -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
+ }