@redpanda-data/docs-extensions-and-macros 5.55.0 → 5.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/bin/doc-tools.js +315 -7
  2. package/cli-utils/docs-links-to-xrefs.js +174 -0
  3. package/cli-utils/generated-marker.js +43 -0
  4. package/cli-utils/helm-commented-values.js +37 -3
  5. package/extension-utils/llms-utils.js +6 -2
  6. package/extension-utils/metadata-candidates.js +304 -0
  7. package/extension-utils/metadata-check.js +174 -0
  8. package/extension-utils/metadata-suggest.js +573 -0
  9. package/extension-utils/taxonomy-categories.js +106 -0
  10. package/extension-utils/taxonomy.js +566 -0
  11. package/extensions/REFERENCE.adoc +90 -4
  12. package/extensions/algolia-indexer/generate-index.js +13 -1
  13. package/extensions/algolia-indexer/index.js +4 -2
  14. package/extensions/convert-sitemap-to-markdown.js +47 -12
  15. package/extensions/convert-to-markdown.js +176 -0
  16. package/extensions/generate-fields-only-pages.js +14 -0
  17. package/extensions/modify-connect-tag-playbook.js +2 -2
  18. package/extensions/url-to-xref.js +92 -22
  19. package/extensions/validate-attributes.js +10 -1
  20. package/package.json +2 -1
  21. package/tools/check-metadata/index.js +523 -0
  22. package/tools/cloud-regions/cloud-regions-table-adoc.hbs +1 -2
  23. package/tools/cloud-regions/cloud-regions-table-md.hbs +2 -2
  24. package/tools/cloud-regions/render-cloud-regions.js +3 -0
  25. package/tools/dispatch-graph/check-dispatch-graph.js +790 -0
  26. package/tools/dispatch-graph/classify-runs.js +128 -0
  27. package/tools/dispatch-graph/parse-workflows.js +499 -0
  28. package/tools/enterprise-features/detect-gating-changes.js +393 -0
  29. package/tools/enterprise-features/verify.js +2 -1
  30. package/tools/gen-rpk-ascii.py +7 -1
  31. package/tools/lint-strings/fixtures/connect/lint_connect.go +8 -0
  32. package/tools/lint-strings/fixtures/crd/lint_types.go +4 -1
  33. package/tools/lint-strings/fixtures/rpk-pr461/delete.base.go +42 -0
  34. package/tools/lint-strings/fixtures/rpk-pr461/delete.head.go +45 -0
  35. package/tools/lint-strings/fixtures/rpk-pr461/reviewer-comments.json +1 -0
  36. package/tools/lint-strings/index.js +133 -3
  37. package/tools/lint-strings/rules/verbatim-asciidoc.js +166 -1
  38. package/tools/lint-strings/surfaces/connect.js +72 -12
  39. package/tools/lint-strings/surfaces/crd.js +10 -6
  40. package/tools/metrics/metrics.py +11 -0
  41. package/tools/property-extractor/generate-handlebars-docs.js +9 -1
  42. package/tools/property-extractor/helpers/applyPropertyLinks.js +35 -0
  43. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +29 -8
  44. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +19 -24
  45. package/tools/redpanda-connect/templates/bloblang-function.hbs +1 -1
  46. package/tools/redpanda-connect/templates/descriptions-partials.hbs +1 -1
  47. package/tools/redpanda-connect/templates/examples-partials.hbs +1 -1
  48. package/tools/redpanda-connect/templates/fields-partials.hbs +2 -2
  49. package/tools/redpanda-connect/templates/metadata-partials.hbs +1 -1
  50. package/tools/rpk-docs/generate-rpk-docs.js +2 -0
  51. package/tools/rpk-docs/generate-x-env-partial.js +7 -4
  52. package/tools/rpk-docs/helpers/index.js +4 -1
  53. package/tools/rpk-docs/templates/command.hbs +1 -1
@@ -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
+ }