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