@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.
- package/bin/doc-tools-mcp.js +4 -5
- package/bin/doc-tools.js +354 -9
- package/bin/mcp-tools/generated-docs-review.js +26 -9
- package/bin/mcp-tools/overrides-audit.js +1 -1
- package/bin/mcp-tools/rpcn-docs.js +4 -14
- package/cli-utils/docs-links-to-xrefs.js +174 -0
- package/cli-utils/format-helm-spec.js +4 -3
- package/cli-utils/generated-marker.js +43 -0
- package/cli-utils/helm-commented-values.js +125 -21
- package/cli-utils/self-managed-docs-branch.js +36 -30
- package/extension-utils/html-utils.js +27 -0
- package/extension-utils/llms-utils.js +6 -2
- package/extension-utils/metadata-candidates.js +304 -0
- package/extension-utils/metadata-check.js +174 -0
- package/extension-utils/metadata-suggest.js +573 -0
- package/extension-utils/taxonomy-categories.js +106 -0
- package/extension-utils/taxonomy.js +566 -0
- package/extensions/REFERENCE.adoc +110 -8
- package/extensions/add-git-dates.js +13 -1
- package/extensions/aggregate-terms.js +34 -20
- package/extensions/algolia-indexer/generate-index.js +13 -1
- package/extensions/algolia-indexer/index.js +4 -2
- package/extensions/convert-sitemap-to-markdown.js +47 -12
- package/extensions/convert-to-markdown.js +176 -0
- package/extensions/generate-fields-only-pages.js +14 -0
- package/extensions/modify-connect-tag-playbook.js +181 -3
- package/extensions/resolve-xrefs-in-attachments.js +1 -12
- package/extensions/url-to-xref.js +92 -22
- package/extensions/util/connect-docs-asset.js +6 -5
- package/extensions/validate-attributes.js +10 -1
- package/extensions/version-fetcher/fetch-latest-docker-tag.js +35 -13
- package/extensions/version-fetcher/get-latest-redpanda-helm-version-from-operator.js +64 -40
- package/extensions/version-fetcher/list-connect-releases.js +3 -3
- package/extensions/version-fetcher/release-lines.js +64 -0
- package/extensions/version-fetcher/set-latest-version.js +73 -3
- package/macros/README.adoc +1 -0
- package/macros/REFERENCE.adoc +99 -0
- package/macros/badge.js +5 -8
- package/macros/enterprise.js +2 -5
- package/macros/glossary.js +4 -14
- package/macros/iceberg-explorer.js +242 -0
- package/macros/prop.js +3 -13
- package/mcp/USER_GUIDE.adoc +25 -135
- package/package.json +5 -2
- package/tools/check-metadata/index.js +523 -0
- package/tools/cloud-regions/cloud-regions-table-adoc.hbs +1 -2
- package/tools/cloud-regions/cloud-regions-table-md.hbs +2 -2
- package/tools/cloud-regions/render-cloud-regions.js +3 -0
- package/tools/dispatch-graph/check-dispatch-graph.js +790 -0
- package/tools/dispatch-graph/classify-runs.js +128 -0
- package/tools/dispatch-graph/parse-workflows.js +499 -0
- package/tools/doc-impact-recording/index.js +190 -0
- package/tools/enterprise-features/detect-gating-changes.js +393 -0
- package/tools/enterprise-features/verify.js +2 -1
- package/tools/gen-rpk-ascii.py +7 -1
- package/tools/lint-strings/fixtures/connect/lint_connect.go +8 -0
- package/tools/lint-strings/fixtures/crd/lint_types.go +4 -1
- package/tools/lint-strings/fixtures/rpk-pr461/delete.base.go +42 -0
- package/tools/lint-strings/fixtures/rpk-pr461/delete.head.go +45 -0
- package/tools/lint-strings/fixtures/rpk-pr461/reviewer-comments.json +1 -0
- package/tools/lint-strings/index.js +133 -3
- package/tools/lint-strings/rules/verbatim-asciidoc.js +166 -1
- package/tools/lint-strings/surfaces/connect.js +72 -12
- package/tools/lint-strings/surfaces/crd.js +10 -6
- package/tools/metrics/metrics.py +11 -0
- package/tools/overrides-audit/README.adoc +1 -2
- package/tools/overrides-audit/adapters/properties.js +1 -1
- package/tools/overrides-audit/index.js +3 -4
- package/tools/property-extractor/generate-handlebars-docs.js +9 -1
- package/tools/property-extractor/helpers/applyPropertyLinks.js +35 -0
- package/tools/redpanda-connect/AUTOMATION.md +3 -36
- package/tools/redpanda-connect/README.adoc +5 -33
- package/tools/redpanda-connect/generate-rpcn-connector-docs.js +32 -11
- package/tools/redpanda-connect/pr-summary-formatter.js +6 -4
- package/tools/redpanda-connect/report-delta.js +6 -3
- package/tools/redpanda-connect/rpcn-connector-docs-handler.js +42 -25
- package/tools/redpanda-connect/templates/bloblang-function.hbs +1 -1
- package/tools/redpanda-connect/templates/descriptions-partials.hbs +1 -1
- package/tools/redpanda-connect/templates/examples-partials.hbs +1 -1
- package/tools/redpanda-connect/templates/fields-partials.hbs +2 -2
- package/tools/redpanda-connect/templates/metadata-partials.hbs +1 -1
- package/tools/redpanda-release-notes/generate-release-notes.js +116 -1
- package/tools/rpk-docs/generate-rpk-docs.js +2 -0
- package/tools/rpk-docs/generate-x-env-partial.js +7 -4
- package/tools/rpk-docs/helpers/index.js +4 -1
- package/tools/rpk-docs/templates/command.hbs +2 -2
- 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
|
+
}
|