@redpanda-data/docs-extensions-and-macros 5.35.1 → 5.37.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 +262 -0
- package/cli-utils/kapa-credentials.js +109 -0
- package/docs-data/kapa-source-groups.json +119 -0
- package/docs-data/kapa-source-groups.schema.json +73 -0
- package/extensions/kapa-source-groups.js +180 -0
- package/package.json +7 -3
- package/tools/generate-cli-docs.js +19 -4
- package/tools/kapa-source-groups/fetch-with-deadline.js +33 -0
- package/tools/kapa-source-groups/generate-kapa-source-groups.js +319 -0
- package/tools/kapa-source-groups/kapa-source-versions.js +83 -0
- package/tools/kapa-source-groups/published-segments.js +98 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/* Publishes the Kapa source-group mapping to the UI as an AsciiDoc attribute.
|
|
2
|
+
*
|
|
3
|
+
* Example use in the playbook:
|
|
4
|
+
* antora:
|
|
5
|
+
* extensions:
|
|
6
|
+
* - require: '@redpanda-data/docs-extensions-and-macros/extensions/kapa-source-groups'
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS EXISTS
|
|
9
|
+
* ---------------
|
|
10
|
+
* docs-ui's get-kapa-source-groups helper needs to turn the page's version
|
|
11
|
+
* segment into a Kapa source group id, so Ask AI retrieval is scoped to the docs
|
|
12
|
+
* version the reader is actually on (DOC-1807, DOC-2450). docs-ui does not depend
|
|
13
|
+
* on this package and must not carry a second copy of the mapping, which would
|
|
14
|
+
* drift silently. So the mapping travels as an attribute: generated here by
|
|
15
|
+
* `doc-tools generate kapa-source-groups`, committed to docs-data, and merged
|
|
16
|
+
* onto every component version at build time.
|
|
17
|
+
*
|
|
18
|
+
* Set on every component version rather than only the versioned ones, because the
|
|
19
|
+
* helper needs the mapping on unversioned pages too. Those resolve to the
|
|
20
|
+
* mapping's default_segment, and sending no filter there is exactly what caused
|
|
21
|
+
* DOC-2450: the reporter was on a page with no version of its own.
|
|
22
|
+
*
|
|
23
|
+
* DEGRADATION
|
|
24
|
+
* -----------
|
|
25
|
+
* Every failure sets nothing and warns. The helper treats a missing attribute as
|
|
26
|
+
* "send no source group", which is the pre-DOC-2450 behaviour of searching every
|
|
27
|
+
* version. That is the right way to fail: a wrong group is worse than no group,
|
|
28
|
+
* because scoping to a group that does not hold the reader's version returns only
|
|
29
|
+
* Kapa's global sources, silently and with no error.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('fs')
|
|
33
|
+
const path = require('path')
|
|
34
|
+
const { raiseListenerLimit } = require('./util/raise-listener-limit')
|
|
35
|
+
|
|
36
|
+
const ATTRIBUTE_NAME = 'kapa-source-groups'
|
|
37
|
+
// Sits beside assets/widgets/, which proxy-api-docs.js already fetches from the
|
|
38
|
+
// site origin, so the /api/ path needs no new hostname or auth.
|
|
39
|
+
const ASSET_DIR = 'assets/data'
|
|
40
|
+
const ASSET_FILENAME = 'kapa-source-groups.json'
|
|
41
|
+
const DEFAULT_MAPPING_PATH = path.join(__dirname, '..', 'docs-data', 'kapa-source-groups.json')
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validate the shape the helper depends on, so a malformed mapping is caught at
|
|
45
|
+
* build time with a named reason rather than producing pages that quietly lose
|
|
46
|
+
* version scoping.
|
|
47
|
+
*
|
|
48
|
+
* @param {*} mapping
|
|
49
|
+
* @returns {string|null} Reason it is unusable, or null when it is fine
|
|
50
|
+
*/
|
|
51
|
+
function validateMapping (mapping) {
|
|
52
|
+
if (!mapping || typeof mapping !== 'object') return 'not an object'
|
|
53
|
+
if (!mapping.segments || typeof mapping.segments !== 'object') return 'no segments object'
|
|
54
|
+
const segments = Object.keys(mapping.segments)
|
|
55
|
+
if (segments.length === 0) return 'segments is empty'
|
|
56
|
+
if (!mapping.default_segment) return 'no default_segment'
|
|
57
|
+
if (!mapping.segments[mapping.default_segment]) {
|
|
58
|
+
// The helper falls back to default_segment for every unversioned page, so a
|
|
59
|
+
// dangling default silently disables scoping across most of the site.
|
|
60
|
+
return `default_segment "${mapping.default_segment}" is not one of the segments (${segments.join(', ')})`
|
|
61
|
+
}
|
|
62
|
+
const missing = segments.filter((s) => !mapping.segments[s] || !mapping.segments[s].group_id)
|
|
63
|
+
if (missing.length) return `segments missing group_id: ${missing.join(', ')}`
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports.register = function ({ config = {} } = {}) {
|
|
68
|
+
const logger = this.getLogger('kapa-source-groups-extension')
|
|
69
|
+
|
|
70
|
+
// Three listeners on a generator context that already carries 30+ extensions,
|
|
71
|
+
// which trips Node's default max-listeners warning. Raised before subscribing,
|
|
72
|
+
// per __tests__/extensions/raise-listener-limit.test.js.
|
|
73
|
+
raiseListenerLimit(this)
|
|
74
|
+
const mappingPath = config.mapping_file ? path.resolve(config.mapping_file) : DEFAULT_MAPPING_PATH
|
|
75
|
+
|
|
76
|
+
// Loaded on first use and reused by all three listeners, so a missing or
|
|
77
|
+
// broken mapping warns once per build rather than once per event.
|
|
78
|
+
let loaded = false
|
|
79
|
+
let cached = null
|
|
80
|
+
function load () {
|
|
81
|
+
if (loaded) return cached
|
|
82
|
+
loaded = true
|
|
83
|
+
cached = readMapping()
|
|
84
|
+
return cached
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Read + validate, or return null having warned with the consequence. */
|
|
88
|
+
function readMapping () {
|
|
89
|
+
let mapping
|
|
90
|
+
try {
|
|
91
|
+
if (!fs.existsSync(mappingPath)) {
|
|
92
|
+
logger.warn(
|
|
93
|
+
`Kapa source-group mapping not found at ${mappingPath}. Ask AI will search every docs version. ` +
|
|
94
|
+
'Generate it with: doc-tools generate kapa-source-groups'
|
|
95
|
+
)
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
mapping = JSON.parse(fs.readFileSync(mappingPath, 'utf8'))
|
|
99
|
+
} catch (err) {
|
|
100
|
+
logger.warn(`Could not read the Kapa source-group mapping at ${mappingPath}: ${err.message}. Ask AI will search every docs version.`)
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
const problem = validateMapping(mapping)
|
|
104
|
+
if (problem) {
|
|
105
|
+
logger.warn(`Kapa source-group mapping at ${mappingPath} is unusable (${problem}). Ask AI will search every docs version.`)
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
return mapping
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// site.keys reaches EVERY page, including ones with no component: the 404 page
|
|
112
|
+
// renders the Ask AI panel but has no page.component or page.componentVersion,
|
|
113
|
+
// so a component-version attribute alone leaves it unscoped. Its layout pulls
|
|
114
|
+
// in head and header, both of which already read site.keys, so this is the one
|
|
115
|
+
// channel that covers it. Set in playbookBuilt because the site UI model is
|
|
116
|
+
// derived from the playbook.
|
|
117
|
+
this.on('playbookBuilt', ({ playbook }) => {
|
|
118
|
+
const mapping = load()
|
|
119
|
+
if (!mapping) return
|
|
120
|
+
playbook.site = playbook.site || {}
|
|
121
|
+
playbook.site.keys = playbook.site.keys || {}
|
|
122
|
+
if (playbook.site.keys[ATTRIBUTE_NAME] === undefined) {
|
|
123
|
+
playbook.site.keys[ATTRIBUTE_NAME] = JSON.stringify(mapping)
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// The /api/ reference is Bump.sh HTML assembled by docs-site's proxy-api-docs
|
|
128
|
+
// edge function, which has no Antora page context and cannot import from
|
|
129
|
+
// node_modules. Publishing the mapping as a site asset lets that function read
|
|
130
|
+
// the default group at request time, in parallel with the widget fetches it
|
|
131
|
+
// already does. The alternative -- a UUID pasted into docs-ui's static widget
|
|
132
|
+
// context or a Netlify env var -- is a second copy of the mapping that drifts
|
|
133
|
+
// silently the first time a group is recreated.
|
|
134
|
+
this.on('beforePublish', ({ siteCatalog }) => {
|
|
135
|
+
const mapping = load()
|
|
136
|
+
if (!mapping) return
|
|
137
|
+
siteCatalog.addFile({
|
|
138
|
+
contents: Buffer.from(JSON.stringify(mapping, null, 2) + '\n', 'utf8'),
|
|
139
|
+
out: { path: path.join(ASSET_DIR, ASSET_FILENAME) },
|
|
140
|
+
})
|
|
141
|
+
logger.info(`Kapa source groups: published ${ASSET_DIR}/${ASSET_FILENAME} for the /api/ proxy.`)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
this.on('contentClassified', async ({ contentCatalog }) => {
|
|
145
|
+
const mapping = load()
|
|
146
|
+
if (!mapping) return
|
|
147
|
+
|
|
148
|
+
// Serialised once. The helper accepts a string or an object, but a string is
|
|
149
|
+
// what survives Antora's attribute handling unchanged, and it keeps the
|
|
150
|
+
// attribute value comparable between builds.
|
|
151
|
+
const serialised = JSON.stringify(mapping)
|
|
152
|
+
|
|
153
|
+
let versions = 0
|
|
154
|
+
const components = await contentCatalog.getComponents()
|
|
155
|
+
for (const component of components) {
|
|
156
|
+
component.versions.forEach(({ asciidoc }) => {
|
|
157
|
+
if (!asciidoc) return
|
|
158
|
+
asciidoc.attributes = asciidoc.attributes || {}
|
|
159
|
+
// Do not clobber an explicit override: a playbook or antora.yml that
|
|
160
|
+
// already sets this wins, matching how add-global-attributes.js lets
|
|
161
|
+
// component attributes beat the shared file.
|
|
162
|
+
if (asciidoc.attributes[ATTRIBUTE_NAME] === undefined) {
|
|
163
|
+
asciidoc.attributes[ATTRIBUTE_NAME] = serialised
|
|
164
|
+
}
|
|
165
|
+
versions += 1
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
logger.info(
|
|
170
|
+
`Kapa source groups: set ${ATTRIBUTE_NAME} on ${versions} component version(s); ` +
|
|
171
|
+
`${Object.keys(mapping.segments).length} version segments, default "${mapping.default_segment}".`
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports.validateMapping = validateMapping
|
|
177
|
+
module.exports.ATTRIBUTE_NAME = ATTRIBUTE_NAME
|
|
178
|
+
module.exports.DEFAULT_MAPPING_PATH = DEFAULT_MAPPING_PATH
|
|
179
|
+
module.exports.ASSET_DIR = ASSET_DIR
|
|
180
|
+
module.exports.ASSET_FILENAME = ASSET_FILENAME
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redpanda-data/docs-extensions-and-macros",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.37.0",
|
|
4
4
|
"description": "Antora extensions and macros developed for Redpanda documentation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"antora",
|
|
@@ -92,7 +92,9 @@
|
|
|
92
92
|
"./macros/enterprise": "./macros/enterprise.js",
|
|
93
93
|
"./macros/prop": "./macros/prop.js",
|
|
94
94
|
"./extensions/property-page-index": "./extensions/property-page-index.js",
|
|
95
|
-
"./extensions/render-property-descriptions": "./extensions/render-property-descriptions.js"
|
|
95
|
+
"./extensions/render-property-descriptions": "./extensions/render-property-descriptions.js",
|
|
96
|
+
"./extensions/kapa-source-groups": "./extensions/kapa-source-groups.js",
|
|
97
|
+
"./docs-data/kapa-source-groups.json": "./docs-data/kapa-source-groups.json"
|
|
96
98
|
},
|
|
97
99
|
"files": [
|
|
98
100
|
"extensions",
|
|
@@ -104,7 +106,9 @@
|
|
|
104
106
|
"tools",
|
|
105
107
|
"docker-compose",
|
|
106
108
|
"mcp",
|
|
107
|
-
"docs-data/rpk-overrides.schema.json"
|
|
109
|
+
"docs-data/rpk-overrides.schema.json",
|
|
110
|
+
"docs-data/kapa-source-groups.json",
|
|
111
|
+
"docs-data/kapa-source-groups.schema.json"
|
|
108
112
|
],
|
|
109
113
|
"license": "ISC",
|
|
110
114
|
"engines": {
|
|
@@ -138,11 +138,19 @@ function parseJSDocComments(sourceFile) {
|
|
|
138
138
|
// was documented with the first command's prose. That is exactly what
|
|
139
139
|
// happened to `generate migrate-rpcn-descriptions`, which shipped a
|
|
140
140
|
// regenerated section describing migrate-rpcn-metadata.
|
|
141
|
-
|
|
141
|
+
//
|
|
142
|
+
// The group is captured too, because a command NAME is not unique: the same
|
|
143
|
+
// name can be registered under both `automation` (generate) and `validation`
|
|
144
|
+
// (validate). `kapa-source-groups` is, and keying on the bare name alone made
|
|
145
|
+
// the later registration silently overwrite the earlier, so the
|
|
146
|
+
// `generate kapa-source-groups` section shipped describing the validate
|
|
147
|
+
// command, complete with validate examples under a generate usage line.
|
|
148
|
+
// Same failure as the migrate-rpcn one above, one level up.
|
|
149
|
+
const pattern = /\/\*\*\s*((?:(?!\*\/)[\s\S])*?)\s*\*\/\s*(programCli|automation|validation)\s*\.command\(['"]([^'"]+)['"]\)/g;
|
|
142
150
|
|
|
143
151
|
let match;
|
|
144
152
|
while ((match = pattern.exec(content)) !== null) {
|
|
145
|
-
const [, commentText, commandName] = match;
|
|
153
|
+
const [, commentText, group, commandName] = match;
|
|
146
154
|
|
|
147
155
|
// Parse the comment into sections
|
|
148
156
|
const parsed = {
|
|
@@ -189,7 +197,12 @@ function parseJSDocComments(sourceFile) {
|
|
|
189
197
|
.trim();
|
|
190
198
|
}
|
|
191
199
|
|
|
192
|
-
|
|
200
|
+
// Keyed by the CLI path a reader would type, so `generate x` and
|
|
201
|
+
// `validate x` cannot collide. The bare name is kept as well, for the
|
|
202
|
+
// top-level commands registered straight onto programCli.
|
|
203
|
+
const groupPrefix = { automation: 'generate ', validation: 'validate ', programCli: '' }[group]
|
|
204
|
+
comments[`${groupPrefix}${commandName}`] = parsed;
|
|
205
|
+
if (comments[commandName] === undefined) comments[commandName] = parsed;
|
|
193
206
|
}
|
|
194
207
|
|
|
195
208
|
return comments;
|
|
@@ -290,7 +303,9 @@ IMPORTANT: This documentation is auto-generated. Do not edit manually. Run \`npm
|
|
|
290
303
|
const label = commandPath.join(' ');
|
|
291
304
|
console.log(`${' '.repeat(level - 1)}Generating docs for: ${label}`);
|
|
292
305
|
const data = parseHelp(getHelpOutput(label));
|
|
293
|
-
|
|
306
|
+
// Prefer the full command path, falling back to the bare name so that
|
|
307
|
+
// commands registered directly on programCli still resolve.
|
|
308
|
+
const jsdoc = jsdocs[label] || jsdocs[commandPath[commandPath.length - 1]];
|
|
294
309
|
doc += generateCommandDoc(label, data, jsdoc, level);
|
|
295
310
|
for (const child of data.commands) {
|
|
296
311
|
// Commander lists `help [command]` in every group; it is not a command
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetch() with a deadline that covers the WHOLE exchange, body included.
|
|
3
|
+
*
|
|
4
|
+
* The obvious shape, `try { res = await fetch(url, { signal }) } finally
|
|
5
|
+
* { clearTimeout(timer) }` and then `await res.text()`, only bounds the time to
|
|
6
|
+
* headers. fetch resolves as soon as headers arrive, the timer is cleared, and a
|
|
7
|
+
* server that then stalls mid-body holds the caller forever. For a scheduled
|
|
8
|
+
* check that is a job that never finishes rather than one that exits 2, so
|
|
9
|
+
* nobody is told and the next run queues behind it.
|
|
10
|
+
*
|
|
11
|
+
* @param {typeof fetch} fetchImpl
|
|
12
|
+
* @param {string} url
|
|
13
|
+
* @param {RequestInit} init - Merged with the abort signal
|
|
14
|
+
* @param {number} timeoutMs
|
|
15
|
+
* @param {(res: Response, signal: AbortSignal) => Promise<*>} read
|
|
16
|
+
* Consumes the body while the timer is still armed. Return null for a
|
|
17
|
+
* response you do not want to read (a non-OK status, say) so the caller can
|
|
18
|
+
* inspect res.status first.
|
|
19
|
+
* @returns {Promise<{res: Response, body: *}>}
|
|
20
|
+
*/
|
|
21
|
+
async function fetchWithDeadline (fetchImpl, url, init, timeoutMs, read) {
|
|
22
|
+
const controller = new AbortController()
|
|
23
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetchImpl(url, { ...init, signal: controller.signal })
|
|
26
|
+
const body = await read(res, controller.signal)
|
|
27
|
+
return { res, body }
|
|
28
|
+
} finally {
|
|
29
|
+
clearTimeout(timer)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { fetchWithDeadline }
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// Standalone module to read Kapa source groups and emit the mapping that tells
|
|
2
|
+
// each docs surface which group to scope a query to.
|
|
3
|
+
// Usage: generateKapaSourceGroups({ apiKey, projectId, defaultSegment })
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS
|
|
6
|
+
// ---------------
|
|
7
|
+
// Kapa indexes several separately crawled copies of the Self-Managed docs (one
|
|
8
|
+
// per version). Without scoping, a reader on a 25.1 page gets answers drawn from
|
|
9
|
+
// any of them: one measured call returned the same architecture page six times at
|
|
10
|
+
// six different versions. Source groups fix that at retrieval time, but the group
|
|
11
|
+
// UUIDs live in the Kapa dashboard while the version list lives in Antora, so
|
|
12
|
+
// something has to join the two. This generator is that join.
|
|
13
|
+
//
|
|
14
|
+
// The Kapa side is read-only by design: Kapa publishes no endpoint to create a
|
|
15
|
+
// group or assign a source to one ("we do not provide an API endpoint for
|
|
16
|
+
// uploading or managing sources"). So this reads the state a human created in the
|
|
17
|
+
// dashboard and turns it into a committed artifact the build and the browser can
|
|
18
|
+
// both consume. It never mutates Kapa.
|
|
19
|
+
//
|
|
20
|
+
// RESPONSE SHAPES (observed live 2026-09-04, project 97f44223)
|
|
21
|
+
// -----------------------------------------------------------
|
|
22
|
+
// GET /ingestion/v1/projects/:id/source-groups/
|
|
23
|
+
// { count, next, previous, results: [
|
|
24
|
+
// { id, name, type: 'product'|'version', description, sub_groups: [...], created_at, updated_at }
|
|
25
|
+
// ]}
|
|
26
|
+
//
|
|
27
|
+
// GET /ingestion/v1/projects/:id/sources/
|
|
28
|
+
// { count, next, previous, results: [
|
|
29
|
+
// { id, name, type: 'scrape'|'openapi'|'custom_qa'|'youtube'|..., source_groups: [...], ... }
|
|
30
|
+
// ]}
|
|
31
|
+
//
|
|
32
|
+
// Kapa's published OpenAPI schema declares BOTH `sub_groups` and `source_groups`
|
|
33
|
+
// as `"type":"string"`. That is a spec bug: live responses return arrays. The
|
|
34
|
+
// parsers below accept an array, a single object, a comma-separated string, or
|
|
35
|
+
// null, so a schema fix on Kapa's side cannot break us either way.
|
|
36
|
+
//
|
|
37
|
+
// "GLOBAL" IS NOT A GROUP
|
|
38
|
+
// ----------------------
|
|
39
|
+
// The dashboard shows a "Global sources" row, and Kapa's own docs call it "a
|
|
40
|
+
// special Global group that exists by default" with an ID. Both are misleading.
|
|
41
|
+
// The API returns no such group, and an unassigned source reports
|
|
42
|
+
// `source_groups: []`. Global is simply "assigned to no group". Assigning a
|
|
43
|
+
// source to a version group is what takes it out of the global set; unassigned
|
|
44
|
+
// sources stay global and are returned alongside whichever group a query scopes
|
|
45
|
+
// to. That is what keeps Agentic Data Plane content reachable from every page.
|
|
46
|
+
|
|
47
|
+
const { KAPA_API_BASE, kapaAuthHeaders } = require('../../cli-utils/kapa-credentials')
|
|
48
|
+
const { fetchWithDeadline } = require('./fetch-with-deadline')
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Raised when Kapa's group tree itself is unusable: no groups, no version
|
|
52
|
+
* sub-groups, a duplicated group name, a group with no sources, or the default
|
|
53
|
+
* segment's group missing.
|
|
54
|
+
*
|
|
55
|
+
* `generate` treats all of these as fatal and names the reason, because every
|
|
56
|
+
* one of them describes a change someone made in the dashboard that the mapping
|
|
57
|
+
* cannot represent. The scheduled `validate` command does not call `generate`;
|
|
58
|
+
* it reads the sources endpoint directly and reports its own four gaps.
|
|
59
|
+
*/
|
|
60
|
+
class KapaGroupTreeError extends Error {
|
|
61
|
+
constructor (message) {
|
|
62
|
+
super(message)
|
|
63
|
+
this.name = 'KapaGroupTreeError'
|
|
64
|
+
this.code = 'KAPA_GROUP_TREE'
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const REQUEST_TIMEOUT_MS = 15000
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Normalise the fields Kapa mis-declares as strings. Accepts an array, a single
|
|
72
|
+
* object, a comma-separated string of ids, or null/undefined.
|
|
73
|
+
*
|
|
74
|
+
* @param {*} value
|
|
75
|
+
* @returns {Array<object|string>}
|
|
76
|
+
*/
|
|
77
|
+
function toList (value) {
|
|
78
|
+
if (Array.isArray(value)) return value
|
|
79
|
+
if (value === null || value === undefined || value === '') return []
|
|
80
|
+
if (typeof value === 'string') {
|
|
81
|
+
return value.split(',').map((s) => s.trim()).filter(Boolean)
|
|
82
|
+
}
|
|
83
|
+
if (typeof value === 'object') return [value]
|
|
84
|
+
return []
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Pull an id out of either an object or a bare id string. */
|
|
88
|
+
function idOf (entry) {
|
|
89
|
+
if (typeof entry === 'string') return entry
|
|
90
|
+
return entry && typeof entry === 'object' ? entry.id || null : null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Fetch every page of a Kapa list endpoint.
|
|
95
|
+
*
|
|
96
|
+
* Both ingestion endpoints return count/next/previous but declare NO pagination
|
|
97
|
+
* query parameters, so the only supported way through is to follow `next`. At
|
|
98
|
+
* 22 sources this project fits in one page today; follow the cursor anyway so it
|
|
99
|
+
* does not quietly truncate later.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} url - Absolute first-page URL
|
|
102
|
+
* @param {string} apiKey
|
|
103
|
+
* @param {Function} [fetchImpl] - Injectable for tests
|
|
104
|
+
* @returns {Promise<object[]>} Concatenated results
|
|
105
|
+
*/
|
|
106
|
+
async function fetchAllPages (url, apiKey, fetchImpl = globalThis.fetch) {
|
|
107
|
+
const out = []
|
|
108
|
+
let next = url
|
|
109
|
+
let guard = 0
|
|
110
|
+
|
|
111
|
+
while (next) {
|
|
112
|
+
if (++guard > 50) throw new Error(`Kapa pagination did not terminate after ${guard - 1} pages: ${url}`)
|
|
113
|
+
|
|
114
|
+
// The deadline covers the body read too; see fetch-with-deadline.js for
|
|
115
|
+
// why clearing the timer after headers alone is not a timeout.
|
|
116
|
+
const { res, body } = await fetchWithDeadline(
|
|
117
|
+
fetchImpl, next, { headers: kapaAuthHeaders(apiKey) }, REQUEST_TIMEOUT_MS,
|
|
118
|
+
(r) => (r.ok ? r.json() : null)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
// 401/403 is the overwhelmingly likely failure and deserves its own hint:
|
|
123
|
+
// a wrong-project key returns 403, not an empty list.
|
|
124
|
+
const hint = res.status === 401 || res.status === 403
|
|
125
|
+
? ' Check that KAPA_API_KEY is valid and belongs to the project in KAPA_PROJECT_ID.'
|
|
126
|
+
: ''
|
|
127
|
+
throw new Error(`Kapa API request failed: ${res.status} ${res.statusText} for ${next}.${hint}`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!Array.isArray(body?.results)) {
|
|
131
|
+
throw new Error(`Kapa API returned an unexpected shape for ${next}: no results array.`)
|
|
132
|
+
}
|
|
133
|
+
out.push(...body.results)
|
|
134
|
+
next = body.next || null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return out
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Flatten the group tree into parent + children.
|
|
142
|
+
*
|
|
143
|
+
* Kapa's hierarchy is two levels and is expressed top-down: a group carries
|
|
144
|
+
* `sub_groups`, and there is no parent_id/parent/level field anywhere in the
|
|
145
|
+
* schema. So children are only discoverable by descending from parents.
|
|
146
|
+
*
|
|
147
|
+
* @param {object[]} groups - `results` from the source-groups endpoint
|
|
148
|
+
* @returns {{parents: object[], childrenByParentId: Map<string, object[]>}}
|
|
149
|
+
*/
|
|
150
|
+
function flattenGroups (groups) {
|
|
151
|
+
const parents = []
|
|
152
|
+
const childrenByParentId = new Map()
|
|
153
|
+
|
|
154
|
+
for (const group of groups) {
|
|
155
|
+
const subs = toList(group.sub_groups).filter((s) => typeof s === 'object')
|
|
156
|
+
parents.push(group)
|
|
157
|
+
childrenByParentId.set(group.id, subs)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { parents, childrenByParentId }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Build the segment -> group mapping.
|
|
165
|
+
*
|
|
166
|
+
* @param {object} options
|
|
167
|
+
* @param {string} options.apiKey
|
|
168
|
+
* @param {string} options.projectId
|
|
169
|
+
* @param {string} [options.defaultSegment='current'] - Segment used for pages with no version
|
|
170
|
+
* @param {string} [options.parentGroupName] - Restrict to one parent by name (default: the only parent that has children)
|
|
171
|
+
* @param {Function} [options.fetchImpl]
|
|
172
|
+
* @returns {Promise<string>} Pretty-printed JSON, newline-terminated
|
|
173
|
+
*/
|
|
174
|
+
async function generateKapaSourceGroups ({
|
|
175
|
+
apiKey,
|
|
176
|
+
projectId,
|
|
177
|
+
defaultSegment = 'current',
|
|
178
|
+
parentGroupName,
|
|
179
|
+
fetchImpl,
|
|
180
|
+
} = {}) {
|
|
181
|
+
if (!apiKey) throw new Error('generateKapaSourceGroups requires an apiKey')
|
|
182
|
+
if (!projectId) throw new Error('generateKapaSourceGroups requires a projectId')
|
|
183
|
+
|
|
184
|
+
const base = `${KAPA_API_BASE}/ingestion/v1/projects/${encodeURIComponent(projectId)}`
|
|
185
|
+
const [groups, sources] = await Promise.all([
|
|
186
|
+
fetchAllPages(`${base}/source-groups/`, apiKey, fetchImpl),
|
|
187
|
+
fetchAllPages(`${base}/sources/`, apiKey, fetchImpl),
|
|
188
|
+
])
|
|
189
|
+
|
|
190
|
+
if (groups.length === 0) {
|
|
191
|
+
// An empty list looks identical to "the dashboard work has not been done
|
|
192
|
+
// yet". Refuse rather than writing an empty mapping that would make every
|
|
193
|
+
// consumer silently fall back to unscoped retrieval.
|
|
194
|
+
throw new KapaGroupTreeError(
|
|
195
|
+
'Kapa returned no source groups. Create the parent group and its version sub groups in the ' +
|
|
196
|
+
'Kapa dashboard (Sources > Manage groups) before running this generator.'
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const { parents, childrenByParentId } = flattenGroups(groups)
|
|
201
|
+
|
|
202
|
+
const candidates = parentGroupName
|
|
203
|
+
? parents.filter((p) => p.name === parentGroupName)
|
|
204
|
+
: parents.filter((p) => (childrenByParentId.get(p.id) || []).length > 0)
|
|
205
|
+
|
|
206
|
+
if (candidates.length === 0) {
|
|
207
|
+
const names = parents.map((p) => `"${p.name}"`).join(', ')
|
|
208
|
+
throw new KapaGroupTreeError(
|
|
209
|
+
parentGroupName
|
|
210
|
+
? `No source group named "${parentGroupName}". Groups present: ${names}.`
|
|
211
|
+
: 'No source group has any sub groups, so there are no version groups to map. ' +
|
|
212
|
+
`Add version sub groups under a parent in the Kapa dashboard. Groups present: ${names}.`
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
if (candidates.length > 1) {
|
|
216
|
+
const names = candidates.map((p) => `"${p.name}"`).join(', ')
|
|
217
|
+
throw new KapaGroupTreeError(
|
|
218
|
+
`Ambiguous parent group: ${names} all have sub groups. Pass --parent-group to choose one.`
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const parent = candidates[0]
|
|
223
|
+
const children = childrenByParentId.get(parent.id) || []
|
|
224
|
+
|
|
225
|
+
// Which sources are assigned to each group. A version group with no sources
|
|
226
|
+
// is worse than no group at all: scoping to it returns only the global set, so
|
|
227
|
+
// the reader silently gets no version-specific content. Surface it.
|
|
228
|
+
const sourcesByGroupId = new Map()
|
|
229
|
+
for (const source of sources) {
|
|
230
|
+
for (const entry of toList(source.source_groups)) {
|
|
231
|
+
const gid = idOf(entry)
|
|
232
|
+
if (!gid) continue
|
|
233
|
+
if (!sourcesByGroupId.has(gid)) sourcesByGroupId.set(gid, [])
|
|
234
|
+
sourcesByGroupId.get(gid).push({ id: source.id, name: source.name })
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const segments = {}
|
|
239
|
+
const empty = []
|
|
240
|
+
for (const child of children) {
|
|
241
|
+
const assigned = (sourcesByGroupId.get(child.id) || [])
|
|
242
|
+
.slice()
|
|
243
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
244
|
+
if (assigned.length === 0) empty.push(child.name)
|
|
245
|
+
if (Object.prototype.hasOwnProperty.call(segments, child.name)) {
|
|
246
|
+
// Silently overwriting makes half that version's sources unreachable at
|
|
247
|
+
// retrieval, and which group_id survives depends on the order Kapa
|
|
248
|
+
// returns sub_groups in, which it does not guarantee. So the committed
|
|
249
|
+
// file flaps between runs and the drift issue reads only "group id
|
|
250
|
+
// changed", with no hint that two groups share a name. Duplicating a name
|
|
251
|
+
// is an easy dashboard slip: re-creating a group instead of editing it.
|
|
252
|
+
throw new KapaGroupTreeError(
|
|
253
|
+
`Two sub groups under "${parent.name}" are both named "${child.name}" ` +
|
|
254
|
+
`(${segments[child.name].group_id} and ${child.id}). Rename or delete one in the Kapa ` +
|
|
255
|
+
'dashboard: a segment can map to only one group, and which one won here would depend on ' +
|
|
256
|
+
'the order Kapa happens to return them in.'
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
segments[child.name] = {
|
|
260
|
+
group_id: child.id,
|
|
261
|
+
group_name: child.name,
|
|
262
|
+
// Sorted so a reordering upstream cannot produce a docs diff.
|
|
263
|
+
source_ids: assigned.map((s) => s.id).sort(),
|
|
264
|
+
source_names: assigned.map((s) => s.name),
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!segments[defaultSegment]) {
|
|
269
|
+
throw new KapaGroupTreeError(
|
|
270
|
+
`Default segment "${defaultSegment}" is not one of the version groups ` +
|
|
271
|
+
`(${Object.keys(segments).sort().join(', ') || 'none'}). Pages with no version would have ` +
|
|
272
|
+
'nothing to scope to, so this must be fixed before the mapping is usable.'
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (empty.length) {
|
|
277
|
+
throw new KapaGroupTreeError(
|
|
278
|
+
`These version groups have no sources assigned: ${empty.sort().join(', ')}. ` +
|
|
279
|
+
'Scoping a query to an empty group returns only global sources, so the reader would get no ' +
|
|
280
|
+
'version-specific content at all. Assign each Documentation (X) source to its group in the ' +
|
|
281
|
+
'Kapa dashboard (Sources > Configure), then re-run.'
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Sources deliberately left unassigned. These are the global set: always
|
|
286
|
+
// returned alongside whichever group a query scopes to. Recorded because the
|
|
287
|
+
// whole design depends on it (Agentic Data Plane must stay reachable from
|
|
288
|
+
// every page), so a future reader can see it was a choice, not an oversight.
|
|
289
|
+
const globalSources = sources
|
|
290
|
+
.filter((s) => toList(s.source_groups).length === 0)
|
|
291
|
+
.map((s) => s.name)
|
|
292
|
+
.sort((a, b) => a.localeCompare(b))
|
|
293
|
+
|
|
294
|
+
// Keys sorted throughout: this file is diff-checked in CI, so a stable order
|
|
295
|
+
// is what makes "no changes" mean "nothing changed upstream". Note there is
|
|
296
|
+
// deliberately no generated-at timestamp -- that is exactly what stops
|
|
297
|
+
// cloud-regions from being diff-checkable.
|
|
298
|
+
const mapping = {
|
|
299
|
+
project_id: projectId,
|
|
300
|
+
parent_group: { id: parent.id, name: parent.name, type: parent.type || null },
|
|
301
|
+
default_segment: defaultSegment,
|
|
302
|
+
segments: Object.fromEntries(
|
|
303
|
+
Object.keys(segments).sort((a, b) => a.localeCompare(b, 'en', { numeric: true })).map((k) => [k, segments[k]])
|
|
304
|
+
),
|
|
305
|
+
global_sources: globalSources,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return `${JSON.stringify(mapping, null, 2)}\n`
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
module.exports = {
|
|
312
|
+
KapaGroupTreeError,
|
|
313
|
+
generateKapaSourceGroups,
|
|
314
|
+
// Exported for unit tests.
|
|
315
|
+
toList,
|
|
316
|
+
idOf,
|
|
317
|
+
flattenGroups,
|
|
318
|
+
fetchAllPages,
|
|
319
|
+
}
|