@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.
- package/bin/doc-tools.js +315 -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 +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/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,566 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The docs taxonomy: the controlled vocabulary in the docs repository at
|
|
5
|
+
* shared/modules/ROOT/attachments/taxonomy.yml, with its JSON Schema
|
|
6
|
+
* taxonomy.schema.json next to it.
|
|
7
|
+
*
|
|
8
|
+
* It is read from the content catalog (the `shared` component's attachments),
|
|
9
|
+
* never merged into page attributes: add-global-attributes only reads
|
|
10
|
+
* modules/ROOT/partials/, and the taxonomy is build data, not page data. The
|
|
11
|
+
* loaded object is shared site-wide on `siteCatalog.taxonomy` and cached per
|
|
12
|
+
* content catalog, so every extension in one build resolves against the same
|
|
13
|
+
* instance.
|
|
14
|
+
*
|
|
15
|
+
* When the file is absent, `getTaxonomy` returns null after one warning and
|
|
16
|
+
* every caller keeps its pre-taxonomy behavior, so DEM and the docs repo can
|
|
17
|
+
* deploy in either order. When the file is present it must be valid: a schema
|
|
18
|
+
* violation, a duplicate ID, a duplicate label across schemes, or a dangling
|
|
19
|
+
* reference fails the build.
|
|
20
|
+
*
|
|
21
|
+
* Everything below the loader is pure, so resolution can be unit tested
|
|
22
|
+
* without Antora.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const yaml = require('js-yaml')
|
|
26
|
+
|
|
27
|
+
const SCHEMES = Object.freeze(['topic', 'task', 'content-type', 'use-case', 'industry', 'technology', 'deployment'])
|
|
28
|
+
const TAXONOMY_COMPONENT = 'shared'
|
|
29
|
+
const TAXONOMY_FILENAME = 'taxonomy.yml'
|
|
30
|
+
const SCHEMA_FILENAME = 'taxonomy.schema.json'
|
|
31
|
+
const SOURCE_PATH = `${TAXONOMY_COMPONENT}/modules/ROOT/attachments/${TAXONOMY_FILENAME}`
|
|
32
|
+
// Where the published file lives: the shared component is unversioned, so its
|
|
33
|
+
// attachments are served from /shared/_attachments/.
|
|
34
|
+
const PUBLIC_URL = `https://docs.redpanda.com/${TAXONOMY_COMPONENT}/_attachments/${TAXONOMY_FILENAME}`
|
|
35
|
+
|
|
36
|
+
class TaxonomyError extends Error {
|
|
37
|
+
constructor (errors) {
|
|
38
|
+
super(`${SOURCE_PATH} is invalid:\n - ${errors.join('\n - ')}`)
|
|
39
|
+
this.name = 'TaxonomyError'
|
|
40
|
+
this.errors = errors
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Case-insensitive, whitespace-collapsed lookup key. NFKC folds no-break spaces. */
|
|
45
|
+
function normalizeKey (value) {
|
|
46
|
+
return String(value == null ? '' : value).normalize('NFKC').replace(/\s+/g, ' ').trim().toLowerCase()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Compile a JSON Schema (draft 2020-12) with the same Ajv setup as the rest of DEM. */
|
|
50
|
+
function compileSchema (schema) {
|
|
51
|
+
const Ajv2020 = require('ajv/dist/2020')
|
|
52
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false })
|
|
53
|
+
return ajv.compile(schema)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class Taxonomy {
|
|
57
|
+
/**
|
|
58
|
+
* @param {Object} data - the parsed taxonomy.yml
|
|
59
|
+
* @throws {TaxonomyError} on duplicate IDs or labels and dangling references
|
|
60
|
+
*/
|
|
61
|
+
constructor (data) {
|
|
62
|
+
this.schemaVersion = data['schema-version']
|
|
63
|
+
this.schemes = new Map()
|
|
64
|
+
this.concepts = new Map()
|
|
65
|
+
this._labels = new Map() // normalized label -> { id, via }
|
|
66
|
+
this._ids = new Map() // normalized id -> id
|
|
67
|
+
this._narrower = new Map()
|
|
68
|
+
this._replacing = new Map() // live id -> deprecated ids that list it in replacedBy, file order
|
|
69
|
+
const errors = []
|
|
70
|
+
|
|
71
|
+
for (const scheme of data.schemes || []) {
|
|
72
|
+
if (this.schemes.has(scheme.id)) errors.push(`scheme ${scheme.id} is defined more than once`)
|
|
73
|
+
const { concepts, ...meta } = scheme
|
|
74
|
+
this.schemes.set(scheme.id, { ...meta, conceptIds: [] })
|
|
75
|
+
for (const raw of concepts || []) {
|
|
76
|
+
const concept = {
|
|
77
|
+
id: raw.id,
|
|
78
|
+
scheme: scheme.id,
|
|
79
|
+
prefLabel: raw.prefLabel,
|
|
80
|
+
altLabels: raw.altLabels || [],
|
|
81
|
+
definition: raw.definition || '',
|
|
82
|
+
broader: raw.broader || null,
|
|
83
|
+
related: raw.related || [],
|
|
84
|
+
implies: raw.implies || [],
|
|
85
|
+
deprecated: raw.deprecated === true,
|
|
86
|
+
replacedBy: raw.replacedBy || [],
|
|
87
|
+
}
|
|
88
|
+
if (raw.kind) concept.kind = raw.kind
|
|
89
|
+
if (raw.component) concept.component = raw.component
|
|
90
|
+
if (this.concepts.has(concept.id)) {
|
|
91
|
+
errors.push(`concept ID ${concept.id} is defined more than once (in ${this.concepts.get(concept.id).scheme} and ${scheme.id})`)
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
this.concepts.set(concept.id, concept)
|
|
95
|
+
this._ids.set(normalizeKey(concept.id), concept.id)
|
|
96
|
+
this.schemes.get(scheme.id).conceptIds.push(concept.id)
|
|
97
|
+
const own = new Set()
|
|
98
|
+
for (const [label, via] of [[concept.prefLabel, 'prefLabel'], ...concept.altLabels.map((l) => [l, 'altLabel'])]) {
|
|
99
|
+
const key = normalizeKey(label)
|
|
100
|
+
if (!key) continue
|
|
101
|
+
if (own.has(key)) continue // a concept may repeat its own label in another case
|
|
102
|
+
own.add(key)
|
|
103
|
+
const taken = this._labels.get(key)
|
|
104
|
+
if (taken) {
|
|
105
|
+
const other = this.concepts.get(taken.id)
|
|
106
|
+
errors.push(`label "${label}" of ${scheme.id}/${concept.id} duplicates the ${taken.via} "${taken.label}" of ${other.scheme}/${other.id}; labels and altLabels must be unique across all schemes`)
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
this._labels.set(key, { id: concept.id, via, label })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const concept of this.concepts.values()) {
|
|
115
|
+
const where = `${concept.scheme}/${concept.id}`
|
|
116
|
+
const ref = (id, field) => {
|
|
117
|
+
if (!this.concepts.has(id)) {
|
|
118
|
+
errors.push(`${where}: ${field} names ${id}, which is not a concept`)
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
return this.concepts.get(id)
|
|
122
|
+
}
|
|
123
|
+
if (concept.broader) {
|
|
124
|
+
const parent = ref(concept.broader, 'broader')
|
|
125
|
+
if (parent) {
|
|
126
|
+
if (parent.scheme !== concept.scheme) errors.push(`${where}: broader ${parent.id} is in the ${parent.scheme} scheme; broader must stay in the same scheme`)
|
|
127
|
+
if (parent.broader) errors.push(`${where}: broader ${parent.id} has its own broader concept; hierarchies have two levels at most`)
|
|
128
|
+
if (parent.deprecated && !concept.deprecated) errors.push(`${where}: broader ${parent.id} is deprecated`)
|
|
129
|
+
if (!this._narrower.has(parent.id)) this._narrower.set(parent.id, [])
|
|
130
|
+
this._narrower.get(parent.id).push(concept.id)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const id of concept.related) ref(id, 'related')
|
|
134
|
+
for (const id of concept.implies) {
|
|
135
|
+
const target = ref(id, 'implies')
|
|
136
|
+
if (target && target.scheme === concept.scheme) errors.push(`${where}: implies ${id} in the same scheme; use broader instead`)
|
|
137
|
+
if (target && target.deprecated) errors.push(`${where}: implies ${id}, which is deprecated`)
|
|
138
|
+
}
|
|
139
|
+
if (concept.deprecated) {
|
|
140
|
+
for (const id of concept.replacedBy) {
|
|
141
|
+
const target = ref(id, 'replacedBy')
|
|
142
|
+
if (target && target.deprecated) errors.push(`${where}: replacedBy ${id}, which is itself deprecated; point at the live concept`)
|
|
143
|
+
if (target) {
|
|
144
|
+
if (!this._replacing.has(id)) this._replacing.set(id, [])
|
|
145
|
+
this._replacing.get(id).push(concept.id)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} else if (concept.replacedBy.length) {
|
|
149
|
+
errors.push(`${where}: replacedBy is set but the concept is not deprecated`)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// An ID that reads like another concept's label in the same scheme would
|
|
154
|
+
// make lookups by ID and by label disagree.
|
|
155
|
+
for (const concept of this.concepts.values()) {
|
|
156
|
+
const taken = this._labels.get(normalizeKey(concept.id))
|
|
157
|
+
if (!taken || taken.id === concept.id) continue
|
|
158
|
+
const other = this.concepts.get(taken.id)
|
|
159
|
+
if (other.scheme === concept.scheme) errors.push(`${concept.scheme}/${concept.id}: the ID equals the ${taken.via} "${taken.label}" of ${other.id} in the same scheme`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (errors.length) throw new TaxonomyError(errors)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** @returns {Object|undefined} the concept with this exact ID */
|
|
166
|
+
get (id) {
|
|
167
|
+
return this.concepts.get(id)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** prefLabel of an ID, or the ID itself when unknown. */
|
|
171
|
+
label (id) {
|
|
172
|
+
const concept = this.concepts.get(id)
|
|
173
|
+
return concept ? concept.prefLabel : id
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** IDs of the concepts whose broader is this one. */
|
|
177
|
+
narrower (id) {
|
|
178
|
+
return this._narrower.get(id) || []
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** IDs of the concepts in a scheme, in file order. */
|
|
182
|
+
conceptIds (scheme, { includeDeprecated = false } = {}) {
|
|
183
|
+
const entry = this.schemes.get(scheme)
|
|
184
|
+
if (!entry) return []
|
|
185
|
+
return entry.conceptIds.filter((id) => includeDeprecated || !this.concepts.get(id).deprecated)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Find the concept a value names, by ID or by label.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} value
|
|
192
|
+
* @param {Object} [options]
|
|
193
|
+
* @param {Array<string>} [options.schemes] - only accept concepts in these schemes
|
|
194
|
+
* @param {'label'|'id'} [options.prefer='label'] - which to try first. Authored
|
|
195
|
+
* labels (page-categories, technologies) prefer labels; ID-valued attributes
|
|
196
|
+
* (page-topic-type, platforms) prefer IDs.
|
|
197
|
+
* @returns {{concept: Object, via: 'id'|'prefLabel'|'altLabel'}|null}
|
|
198
|
+
*/
|
|
199
|
+
lookup (value, { schemes, prefer = 'label' } = {}) {
|
|
200
|
+
const key = normalizeKey(value)
|
|
201
|
+
if (!key) return null
|
|
202
|
+
const allowed = (concept) => !schemes || schemes.includes(concept.scheme)
|
|
203
|
+
const byLabel = () => {
|
|
204
|
+
const hit = this._labels.get(key)
|
|
205
|
+
const concept = hit && this.concepts.get(hit.id)
|
|
206
|
+
return concept && allowed(concept) ? { concept, via: hit.via } : null
|
|
207
|
+
}
|
|
208
|
+
const byId = () => {
|
|
209
|
+
const id = this._ids.get(key)
|
|
210
|
+
const concept = id && this.concepts.get(id)
|
|
211
|
+
return concept && allowed(concept) ? { concept, via: 'id' } : null
|
|
212
|
+
}
|
|
213
|
+
return prefer === 'id' ? (byId() || byLabel()) : (byLabel() || byId())
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Resolve a value to the live concept IDs it stands for. A deprecated concept
|
|
218
|
+
* resolves to its replacedBy (possibly empty).
|
|
219
|
+
*
|
|
220
|
+
* @returns {{value: string, concept: Object, via: string, deprecated: boolean, ids: Array<string>}|null}
|
|
221
|
+
* null when the value names no concept
|
|
222
|
+
*/
|
|
223
|
+
resolve (value, options) {
|
|
224
|
+
const hit = this.lookup(value, options)
|
|
225
|
+
if (!hit) return null
|
|
226
|
+
const { concept, via } = hit
|
|
227
|
+
return {
|
|
228
|
+
value,
|
|
229
|
+
concept,
|
|
230
|
+
via,
|
|
231
|
+
deprecated: concept.deprecated,
|
|
232
|
+
ids: concept.deprecated ? [...concept.replacedBy] : [concept.id],
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Add what a set of directly assigned concepts implies: every broader
|
|
238
|
+
* ancestor and every `implies` target (and its ancestors). Direct always wins
|
|
239
|
+
* over implied for the same ID.
|
|
240
|
+
*
|
|
241
|
+
* @param {Iterable<string>} directIds
|
|
242
|
+
* @returns {Map<string, 'direct'|'implied'>} in insertion order
|
|
243
|
+
*/
|
|
244
|
+
expand (directIds) {
|
|
245
|
+
const out = new Map()
|
|
246
|
+
for (const id of directIds) if (this.concepts.has(id)) out.set(id, 'direct')
|
|
247
|
+
const imply = (id) => {
|
|
248
|
+
if (!out.has(id)) out.set(id, 'implied')
|
|
249
|
+
}
|
|
250
|
+
for (const id of [...out.keys()]) {
|
|
251
|
+
const concept = this.concepts.get(id)
|
|
252
|
+
if (concept.broader) imply(concept.broader)
|
|
253
|
+
for (const target of concept.implies) {
|
|
254
|
+
imply(target)
|
|
255
|
+
const t = this.concepts.get(target)
|
|
256
|
+
if (t && t.broader) imply(t.broader)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return out
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Classify a list of authored values (for example `page-categories`).
|
|
264
|
+
*
|
|
265
|
+
* @param {Array<string>} values
|
|
266
|
+
* @param {Object} [options] - passed to `lookup`
|
|
267
|
+
* @returns {{concepts: Map<string, 'direct'|'implied'>, resolved: Array<Object>, deprecated: Array<Object>, unknown: Array<string>}}
|
|
268
|
+
*/
|
|
269
|
+
classify (values, options) {
|
|
270
|
+
const direct = []
|
|
271
|
+
const resolved = []
|
|
272
|
+
const deprecated = []
|
|
273
|
+
const unknown = []
|
|
274
|
+
for (const value of values || []) {
|
|
275
|
+
const r = this.resolve(value, options)
|
|
276
|
+
if (!r) {
|
|
277
|
+
unknown.push(value)
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
resolved.push(r)
|
|
281
|
+
if (r.deprecated) deprecated.push({ value, id: r.concept.id, replacedBy: r.ids })
|
|
282
|
+
direct.push(...r.ids)
|
|
283
|
+
}
|
|
284
|
+
return { concepts: this.expand(direct), resolved, deprecated, unknown }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Group concept IDs by scheme.
|
|
289
|
+
*
|
|
290
|
+
* @param {Map<string, string>|Iterable<string>} concepts
|
|
291
|
+
* @returns {Object<string, Array<string>>} one (possibly empty) array per scheme
|
|
292
|
+
*/
|
|
293
|
+
byScheme (concepts) {
|
|
294
|
+
const out = {}
|
|
295
|
+
for (const scheme of SCHEMES) out[scheme] = []
|
|
296
|
+
const ids = concepts instanceof Map ? concepts.keys() : concepts
|
|
297
|
+
for (const id of ids) {
|
|
298
|
+
const concept = this.concepts.get(id)
|
|
299
|
+
if (concept && out[concept.scheme]) out[concept.scheme].push(id)
|
|
300
|
+
}
|
|
301
|
+
return out
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The technology concepts whose `component` is this Antora component.
|
|
306
|
+
* A page in the `connect` component is about Redpanda Connect.
|
|
307
|
+
*/
|
|
308
|
+
technologiesForComponent (componentName) {
|
|
309
|
+
if (!componentName) return []
|
|
310
|
+
return this.conceptIds('technology').filter((id) => this.concepts.get(id).component === componentName)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* A label for this concept that the legacy valid-categories list accepts, so
|
|
315
|
+
* a page authored with new labels still emits the old `categories` values.
|
|
316
|
+
* Tries the concept's own labels, then the deprecated concepts it replaces,
|
|
317
|
+
* in file order.
|
|
318
|
+
*
|
|
319
|
+
* @param {string} id
|
|
320
|
+
* @param {(label: string) => boolean} isLegacy
|
|
321
|
+
* @returns {string|null}
|
|
322
|
+
*/
|
|
323
|
+
legacyLabel (id, isLegacy) {
|
|
324
|
+
const concept = this.concepts.get(id)
|
|
325
|
+
if (!concept) return null
|
|
326
|
+
for (const label of [concept.prefLabel, ...concept.altLabels]) if (isLegacy(label)) return label
|
|
327
|
+
for (const oldId of this._replacing.get(id) || []) {
|
|
328
|
+
const old = this.concepts.get(oldId)
|
|
329
|
+
for (const label of [old.prefLabel, ...old.altLabels]) if (isLegacy(label)) return label
|
|
330
|
+
}
|
|
331
|
+
return null
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Plain JSON for publishing: concept ID -> public fields. */
|
|
335
|
+
toJSON () {
|
|
336
|
+
return {
|
|
337
|
+
schemaVersion: this.schemaVersion,
|
|
338
|
+
concepts: [...this.concepts.values()],
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Parse and validate taxonomy.yml.
|
|
345
|
+
*
|
|
346
|
+
* @param {string|Buffer} text
|
|
347
|
+
* @param {Object} [options]
|
|
348
|
+
* @param {Object} [options.schema] - taxonomy.schema.json, parsed. Without it only
|
|
349
|
+
* the structural checks in the Taxonomy constructor run.
|
|
350
|
+
* @returns {Taxonomy}
|
|
351
|
+
* @throws {TaxonomyError}
|
|
352
|
+
*/
|
|
353
|
+
function parseTaxonomy (text, { schema } = {}) {
|
|
354
|
+
let data
|
|
355
|
+
try {
|
|
356
|
+
data = yaml.load(String(text || ''), { schema: yaml.CORE_SCHEMA })
|
|
357
|
+
} catch (err) {
|
|
358
|
+
throw new TaxonomyError([`YAML does not parse: ${err.message}`])
|
|
359
|
+
}
|
|
360
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new TaxonomyError(['the file is empty or not a mapping'])
|
|
361
|
+
if (schema) {
|
|
362
|
+
const check = compileSchema(schema)
|
|
363
|
+
if (!check(data)) {
|
|
364
|
+
throw new TaxonomyError((check.errors || []).map((e) => `${e.instancePath || '/'} ${e.message}${e.params && e.params.additionalProperty ? ` (${e.params.additionalProperty})` : ''}`))
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return new Taxonomy(data)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** The shared component's attachment with this relative path, or undefined. */
|
|
371
|
+
function findSharedAttachment (contentCatalog, relative) {
|
|
372
|
+
if (!contentCatalog || typeof contentCatalog.findBy !== 'function') return undefined
|
|
373
|
+
return contentCatalog
|
|
374
|
+
.findBy({ component: TAXONOMY_COMPONENT, family: 'attachment' })
|
|
375
|
+
.find((file) => file.src && file.src.module === 'ROOT' && file.src.relative === relative)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const cache = new WeakMap()
|
|
379
|
+
const silentLogger = { info () {}, warn () {}, debug () {}, error () {} }
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Load the taxonomy from the content catalog once per build.
|
|
383
|
+
*
|
|
384
|
+
* @param {Object} contentCatalog
|
|
385
|
+
* @param {Object} [options]
|
|
386
|
+
* @param {Object} [options.logger]
|
|
387
|
+
* @returns {Taxonomy|null} null when taxonomy.yml is not in the shared component
|
|
388
|
+
* @throws {TaxonomyError} when it is present and invalid
|
|
389
|
+
*/
|
|
390
|
+
function loadTaxonomy (contentCatalog, { logger = silentLogger } = {}) {
|
|
391
|
+
if (contentCatalog && cache.has(contentCatalog)) return cache.get(contentCatalog)
|
|
392
|
+
const file = findSharedAttachment(contentCatalog, TAXONOMY_FILENAME)
|
|
393
|
+
let taxonomy = null
|
|
394
|
+
if (!file) {
|
|
395
|
+
logger.warn(`taxonomy: ${SOURCE_PATH} not found; categories are checked against valid-categories.yml only and no taxonomy facets are emitted`)
|
|
396
|
+
} else {
|
|
397
|
+
const schemaFile = findSharedAttachment(contentCatalog, SCHEMA_FILENAME)
|
|
398
|
+
let schema
|
|
399
|
+
if (schemaFile) {
|
|
400
|
+
try {
|
|
401
|
+
schema = JSON.parse(schemaFile.contents.toString('utf8'))
|
|
402
|
+
} catch (err) {
|
|
403
|
+
throw new TaxonomyError([`${SCHEMA_FILENAME} is not valid JSON: ${err.message}`])
|
|
404
|
+
}
|
|
405
|
+
} else {
|
|
406
|
+
logger.warn(`taxonomy: ${SCHEMA_FILENAME} not found next to ${TAXONOMY_FILENAME}; only the structural checks run`)
|
|
407
|
+
}
|
|
408
|
+
taxonomy = parseTaxonomy(file.contents, { schema })
|
|
409
|
+
logger.info(`taxonomy: loaded ${taxonomy.concepts.size} concepts in ${taxonomy.schemes.size} schemes from ${SOURCE_PATH}`)
|
|
410
|
+
}
|
|
411
|
+
if (contentCatalog) cache.set(contentCatalog, taxonomy)
|
|
412
|
+
return taxonomy
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The site-wide taxonomy: `siteCatalog.taxonomy` when an earlier extension has
|
|
417
|
+
* loaded it, otherwise loaded now and stored there.
|
|
418
|
+
*
|
|
419
|
+
* @param {{contentCatalog: Object, siteCatalog?: Object, logger?: Object}} vars
|
|
420
|
+
* @returns {Taxonomy|null}
|
|
421
|
+
*/
|
|
422
|
+
function getTaxonomy ({ contentCatalog, siteCatalog, logger } = {}) {
|
|
423
|
+
if (siteCatalog && siteCatalog.taxonomy !== undefined) return siteCatalog.taxonomy
|
|
424
|
+
const taxonomy = loadTaxonomy(contentCatalog, { logger })
|
|
425
|
+
if (siteCatalog) siteCatalog.taxonomy = taxonomy
|
|
426
|
+
return taxonomy
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const isUrl = (s) => /^https?:\/\//i.test(String(s))
|
|
430
|
+
const siblingOf = (source, name) => (isUrl(source) ? new URL(name, source).toString() : require('path').join(require('path').dirname(source), name))
|
|
431
|
+
|
|
432
|
+
/** Read a local file or fetch a URL; null when it does not exist (ENOENT or 404). */
|
|
433
|
+
async function readSource (source, fetchImpl) {
|
|
434
|
+
if (isUrl(source)) {
|
|
435
|
+
if (typeof fetchImpl !== 'function') throw new Error(`cannot fetch ${source}: no fetch in this Node.js`)
|
|
436
|
+
const res = await fetchImpl(source)
|
|
437
|
+
if (res.status === 404) return null
|
|
438
|
+
if (!res.ok) throw new Error(`fetching ${source} failed with HTTP ${res.status}`)
|
|
439
|
+
// A host that answers a missing file with an HTML page (a soft 404) has
|
|
440
|
+
// not served the file.
|
|
441
|
+
const type = (res.headers && typeof res.headers.get === 'function' && res.headers.get('content-type')) || ''
|
|
442
|
+
if (/text\/html/i.test(type)) return null
|
|
443
|
+
return res.text()
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
return require('fs').readFileSync(source, 'utf8')
|
|
447
|
+
} catch (err) {
|
|
448
|
+
if (err.code === 'ENOENT') return null
|
|
449
|
+
throw err
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Load the taxonomy outside Antora, for doc-tools and agents.
|
|
455
|
+
*
|
|
456
|
+
* With `source` (a path or URL), only that is read. Otherwise the first that
|
|
457
|
+
* exists of:
|
|
458
|
+
* 1. <root>/shared/modules/ROOT/attachments/taxonomy.yml (root is the docs repo checkout)
|
|
459
|
+
* 2. $REDPANDA_DOCS_PATH/shared/modules/ROOT/attachments/taxonomy.yml (a docs checkout elsewhere)
|
|
460
|
+
* 3. the published file, https://docs.redpanda.com/shared/_attachments/taxonomy.yml
|
|
461
|
+
* taxonomy.schema.json is read from beside whichever was used.
|
|
462
|
+
*
|
|
463
|
+
* @param {Object} [options]
|
|
464
|
+
* @param {string} [options.source]
|
|
465
|
+
* @param {string} [options.root=process.cwd()]
|
|
466
|
+
* @param {Object} [options.env=process.env]
|
|
467
|
+
* @param {Function} [options.fetch=globalThis.fetch]
|
|
468
|
+
* @returns {Promise<{taxonomy: Taxonomy, source: string, schemaSource: string|null, warnings: string[]}>}
|
|
469
|
+
* @throws {TaxonomyError} when the file is invalid; Error when none is found
|
|
470
|
+
*/
|
|
471
|
+
async function loadTaxonomyFrom ({ source, root = process.cwd(), env = process.env, fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
472
|
+
const path = require('path')
|
|
473
|
+
const candidates = source
|
|
474
|
+
? [isUrl(source) ? source : path.resolve(root, source)]
|
|
475
|
+
: [
|
|
476
|
+
path.join(root, SOURCE_PATH),
|
|
477
|
+
...(env.REDPANDA_DOCS_PATH ? [path.join(path.resolve(env.REDPANDA_DOCS_PATH), SOURCE_PATH)] : []),
|
|
478
|
+
PUBLIC_URL,
|
|
479
|
+
]
|
|
480
|
+
const tried = []
|
|
481
|
+
for (const candidate of candidates) {
|
|
482
|
+
const text = await readSource(candidate, fetchImpl)
|
|
483
|
+
tried.push(candidate)
|
|
484
|
+
if (text === null) continue
|
|
485
|
+
const warnings = []
|
|
486
|
+
const schemaSource = siblingOf(candidate, SCHEMA_FILENAME)
|
|
487
|
+
const schemaText = await readSource(schemaSource, fetchImpl)
|
|
488
|
+
let schema
|
|
489
|
+
if (schemaText === null) {
|
|
490
|
+
warnings.push(`${SCHEMA_FILENAME} not found beside ${candidate}; only the structural checks ran`)
|
|
491
|
+
} else {
|
|
492
|
+
try {
|
|
493
|
+
schema = JSON.parse(schemaText)
|
|
494
|
+
} catch (err) {
|
|
495
|
+
throw new TaxonomyError([`${schemaSource} is not valid JSON: ${err.message}`])
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return { taxonomy: parseTaxonomy(text, { schema }), source: candidate, schemaSource: schema ? schemaSource : null, warnings }
|
|
499
|
+
}
|
|
500
|
+
throw new Error(`no ${TAXONOMY_FILENAME} found; tried ${tried.join(', ')}`)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Search facets for a page from its authored categories, its deployment type,
|
|
505
|
+
* and its component. Implied concepts are included: a page tagged Replication
|
|
506
|
+
* is also under High Availability for a reader filtering on it.
|
|
507
|
+
*
|
|
508
|
+
* @param {Taxonomy} taxonomy
|
|
509
|
+
* @param {Object} input
|
|
510
|
+
* @param {Array<string>} [input.categories] - authored page-categories values
|
|
511
|
+
* @param {string} [input.deployment] - from the env-* attributes (Kubernetes, Linux, Docker, Redpanda Cloud)
|
|
512
|
+
* @param {string} [input.component] - Antora component name
|
|
513
|
+
* @returns {{topics: string[], topicIds: string[], useCases: string[], useCaseIds: string[], technologies: string[], technologyIds: string[], deployment: string[], deploymentIds: string[]}}
|
|
514
|
+
*/
|
|
515
|
+
function pageFacets (taxonomy, { categories = [], deployment = '', component = '' } = {}) {
|
|
516
|
+
const { concepts } = taxonomy.classify(categories)
|
|
517
|
+
const direct = [...concepts.keys()]
|
|
518
|
+
const deploymentHit = deployment ? taxonomy.resolve(deployment, { schemes: ['deployment'] }) : null
|
|
519
|
+
if (deploymentHit) direct.push(...deploymentHit.ids)
|
|
520
|
+
direct.push(...taxonomy.technologiesForComponent(component))
|
|
521
|
+
const grouped = taxonomy.byScheme(taxonomy.expand(direct))
|
|
522
|
+
const labels = (ids) => ids.map((id) => taxonomy.label(id))
|
|
523
|
+
return {
|
|
524
|
+
topics: labels(grouped.topic),
|
|
525
|
+
topicIds: grouped.topic,
|
|
526
|
+
useCases: labels(grouped['use-case']),
|
|
527
|
+
useCaseIds: grouped['use-case'],
|
|
528
|
+
technologies: labels(grouped.technology),
|
|
529
|
+
technologyIds: grouped.technology,
|
|
530
|
+
deployment: labels(grouped.deployment),
|
|
531
|
+
deploymentIds: grouped.deployment,
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* The authored categories of a page. validate-attributes rewrites
|
|
537
|
+
* page-categories to the legacy list (parents added), and keeps what the
|
|
538
|
+
* author wrote in page-categories-authored, which is what the taxonomy
|
|
539
|
+
* resolves: the added legacy parents (Deployment under Iceberg, for example)
|
|
540
|
+
* are exactly the relations the taxonomy corrects.
|
|
541
|
+
*/
|
|
542
|
+
function authoredCategories (attributes) {
|
|
543
|
+
if (!attributes) return []
|
|
544
|
+
const raw = attributes['page-categories-authored'] !== undefined ? attributes['page-categories-authored'] : attributes['page-categories']
|
|
545
|
+
if (Array.isArray(raw)) return raw.map((c) => String(c).trim()).filter(Boolean)
|
|
546
|
+
if (typeof raw !== 'string') return []
|
|
547
|
+
return raw.split(',').map((c) => c.trim()).filter(Boolean)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
module.exports = {
|
|
551
|
+
SCHEMES,
|
|
552
|
+
TAXONOMY_FILENAME,
|
|
553
|
+
SCHEMA_FILENAME,
|
|
554
|
+
SOURCE_PATH,
|
|
555
|
+
PUBLIC_URL,
|
|
556
|
+
Taxonomy,
|
|
557
|
+
TaxonomyError,
|
|
558
|
+
normalizeKey,
|
|
559
|
+
parseTaxonomy,
|
|
560
|
+
loadTaxonomy,
|
|
561
|
+
loadTaxonomyFrom,
|
|
562
|
+
getTaxonomy,
|
|
563
|
+
pageFacets,
|
|
564
|
+
authoredCategories,
|
|
565
|
+
findSharedAttachment,
|
|
566
|
+
}
|