@gxp-dev/tools 2.0.71 → 2.0.72

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.
@@ -0,0 +1,263 @@
1
+ /**
2
+ * GxP documentation fetcher + searcher.
3
+ *
4
+ * Source of truth: https://docs.gxp.dev (Docusaurus, statically pre-rendered).
5
+ * We discover pages from `/sitemap.xml`, extract title + heading list +
6
+ * article body text from each page's HTML with a handful of regexes (no
7
+ * HTML parser dep), and cache everything in-memory.
8
+ *
9
+ * Two caches:
10
+ * - sitemap : 1 hour TTL
11
+ * - pages : 30 min TTL, keyed by URL
12
+ *
13
+ * Search is eager: the first `searchPages` call (per cache window) fetches
14
+ * every page in the sitemap with a concurrency cap. ~77 pages of ~10KB each
15
+ * = well under 1 MB, acceptable for an MCP server session.
16
+ *
17
+ * Injection points for tests: __setSitemapForTest, __setPageForTest.
18
+ */
19
+
20
+ const DOCS_BASE = "https://docs.gxp.dev"
21
+ const SITEMAP_URL = `${DOCS_BASE}/sitemap.xml`
22
+
23
+ const SITEMAP_TTL = 60 * 60 * 1000 // 1 hour
24
+ const PAGE_TTL = 30 * 60 * 1000 // 30 minutes
25
+ const FETCH_CONCURRENCY = 6
26
+
27
+ const sitemapCache = { urls: null, fetchedAt: 0 }
28
+ /** @type {Map<string, { title, headings, body, fetchedAt }>} */
29
+ const pageCache = new Map()
30
+
31
+ /* -------------------------------- fetching ------------------------------- */
32
+
33
+ async function fetchSitemap({ refresh = false } = {}) {
34
+ const now = Date.now()
35
+ if (
36
+ !refresh &&
37
+ sitemapCache.urls &&
38
+ now - sitemapCache.fetchedAt < SITEMAP_TTL
39
+ ) {
40
+ return sitemapCache.urls
41
+ }
42
+ const res = await fetch(SITEMAP_URL)
43
+ if (!res.ok) {
44
+ throw new Error(
45
+ `Failed to fetch sitemap from ${SITEMAP_URL}: HTTP ${res.status}`,
46
+ )
47
+ }
48
+ const xml = await res.text()
49
+ const urls = parseSitemap(xml)
50
+ sitemapCache.urls = urls
51
+ sitemapCache.fetchedAt = now
52
+ return urls
53
+ }
54
+
55
+ function parseSitemap(xml) {
56
+ const out = []
57
+ const re = /<loc>([^<]+)<\/loc>/g
58
+ let m
59
+ while ((m = re.exec(xml)) !== null) {
60
+ const url = m[1].trim()
61
+ if (url) out.push(url)
62
+ }
63
+ return out
64
+ }
65
+
66
+ async function fetchPageText(url, { refresh = false } = {}) {
67
+ const now = Date.now()
68
+ const cached = pageCache.get(url)
69
+ if (!refresh && cached && now - cached.fetchedAt < PAGE_TTL) {
70
+ return cached
71
+ }
72
+
73
+ const res = await fetch(url, { redirect: "follow" })
74
+ if (!res.ok) {
75
+ throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`)
76
+ }
77
+ const html = await res.text()
78
+ const extracted = extractFromHtml(html)
79
+ const record = { ...extracted, url, fetchedAt: now }
80
+ pageCache.set(url, record)
81
+ return record
82
+ }
83
+
84
+ /* -------------------------- regex-based extraction ----------------------- */
85
+
86
+ function stripTags(s) {
87
+ return s.replace(/<[^>]+>/g, "")
88
+ }
89
+
90
+ function decodeEntities(s) {
91
+ return s
92
+ .replace(/&amp;/g, "&")
93
+ .replace(/&lt;/g, "<")
94
+ .replace(/&gt;/g, ">")
95
+ .replace(/&quot;/g, '"')
96
+ .replace(/&#39;/g, "'")
97
+ .replace(/&nbsp;/g, " ")
98
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
99
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, h) =>
100
+ String.fromCharCode(parseInt(h, 16)),
101
+ )
102
+ }
103
+
104
+ function clean(text) {
105
+ return decodeEntities(stripTags(text)).replace(/\s+/g, " ").trim()
106
+ }
107
+
108
+ function extractFromHtml(html) {
109
+ // Title: prefer the first <h1> inside the article body; fall back to <title>.
110
+ const titleH1 = /<h1[^>]*>([\s\S]*?)<\/h1>/i.exec(html)
111
+ const titleTag = /<title>([\s\S]*?)<\/title>/i.exec(html)
112
+ const title = clean((titleH1?.[1] ?? titleTag?.[1] ?? "").toString())
113
+
114
+ // Article body; fall back to full doc if Docusaurus structure changes.
115
+ const articleMatch = /<article[^>]*>([\s\S]*?)<\/article>/i.exec(html)
116
+ const bodyHtml = articleMatch ? articleMatch[1] : html
117
+
118
+ const headings = []
119
+ const headingRe = /<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi
120
+ let m
121
+ while ((m = headingRe.exec(bodyHtml)) !== null) {
122
+ const text = clean(m[2])
123
+ if (text) headings.push({ level: Number(m[1]), text })
124
+ }
125
+
126
+ const body = clean(bodyHtml)
127
+
128
+ return { title, headings, body }
129
+ }
130
+
131
+ /* --------------------------------- search -------------------------------- */
132
+
133
+ function scorePage(page, terms) {
134
+ const titleL = page.title.toLowerCase()
135
+ const bodyL = page.body.toLowerCase()
136
+ const headingsText = page.headings.map((h) => h.text.toLowerCase()).join(" ")
137
+
138
+ let score = 0
139
+ for (const t of terms) {
140
+ const titleHits = occurrences(titleL, t)
141
+ const headingHits = occurrences(headingsText, t)
142
+ const bodyHits = occurrences(bodyL, t)
143
+ score += titleHits * 3 + headingHits * 2 + bodyHits
144
+ }
145
+ return score
146
+ }
147
+
148
+ function occurrences(haystack, needle) {
149
+ if (!needle) return 0
150
+ let count = 0
151
+ let idx = 0
152
+ while ((idx = haystack.indexOf(needle, idx)) !== -1) {
153
+ count++
154
+ idx += needle.length
155
+ }
156
+ return count
157
+ }
158
+
159
+ function snippet(body, terms, chars = 200) {
160
+ const lower = body.toLowerCase()
161
+ let first = -1
162
+ for (const t of terms) {
163
+ const i = lower.indexOf(t)
164
+ if (i !== -1 && (first === -1 || i < first)) first = i
165
+ }
166
+ if (first === -1) return body.slice(0, chars)
167
+ const start = Math.max(0, first - Math.floor(chars / 4))
168
+ const end = Math.min(body.length, start + chars)
169
+ const prefix = start > 0 ? "…" : ""
170
+ const suffix = end < body.length ? "…" : ""
171
+ return prefix + body.slice(start, end) + suffix
172
+ }
173
+
174
+ /**
175
+ * Fetch all sitemap URLs with a concurrency cap and return the pages array.
176
+ * Pages that fail to fetch are silently omitted.
177
+ */
178
+ async function fetchAllPages({ refresh = false } = {}) {
179
+ const urls = await fetchSitemap({ refresh })
180
+ const queue = [...urls]
181
+ const results = []
182
+
183
+ async function worker() {
184
+ while (queue.length) {
185
+ const url = queue.shift()
186
+ try {
187
+ results.push(await fetchPageText(url, { refresh }))
188
+ } catch {
189
+ // Skip transient fetch failures — one bad page shouldn't sink the whole search.
190
+ }
191
+ }
192
+ }
193
+
194
+ const workers = Array.from({ length: FETCH_CONCURRENCY }, () => worker())
195
+ await Promise.all(workers)
196
+ return results
197
+ }
198
+
199
+ async function searchPages(query, { limit = 10, refresh = false } = {}) {
200
+ const terms = String(query || "")
201
+ .toLowerCase()
202
+ .split(/\s+/)
203
+ .filter(Boolean)
204
+ if (!terms.length) return []
205
+
206
+ const pages = await fetchAllPages({ refresh })
207
+ const scored = []
208
+ for (const page of pages) {
209
+ const score = scorePage(page, terms)
210
+ if (score > 0) {
211
+ scored.push({
212
+ url: page.url,
213
+ title: page.title,
214
+ score,
215
+ snippet: snippet(page.body, terms),
216
+ })
217
+ }
218
+ }
219
+ scored.sort((a, b) => b.score - a.score)
220
+ return scored.slice(0, limit)
221
+ }
222
+
223
+ /* --------------------------------- resolve -------------------------------- */
224
+
225
+ function resolvePageUrl(input) {
226
+ if (!input) throw new Error("url_or_slug is required")
227
+ if (/^https?:\/\//i.test(input)) return input
228
+ const slug = input.replace(/^\/+/, "").replace(/\/+$/, "")
229
+ return `${DOCS_BASE}/${slug}`
230
+ }
231
+
232
+ /* ------------------------------- test seams ------------------------------ */
233
+
234
+ function __setSitemapForTest(urls) {
235
+ sitemapCache.urls = urls
236
+ sitemapCache.fetchedAt = Date.now()
237
+ }
238
+
239
+ function __setPageForTest(url, { title = "", headings = [], body = "" }) {
240
+ pageCache.set(url, { url, title, headings, body, fetchedAt: Date.now() })
241
+ }
242
+
243
+ function __resetCacheForTest() {
244
+ sitemapCache.urls = null
245
+ sitemapCache.fetchedAt = 0
246
+ pageCache.clear()
247
+ }
248
+
249
+ module.exports = {
250
+ DOCS_BASE,
251
+ fetchSitemap,
252
+ fetchPageText,
253
+ fetchAllPages,
254
+ searchPages,
255
+ parseSitemap,
256
+ extractFromHtml,
257
+ scorePage,
258
+ snippet,
259
+ resolvePageUrl,
260
+ __setSitemapForTest,
261
+ __setPageForTest,
262
+ __resetCacheForTest,
263
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Shared OpenAPI / AsyncAPI / Webhook spec fetching with an in-memory cache.
3
+ * Used by both the main MCP server and the extended api-tools module.
4
+ *
5
+ * Environment detection: reads VITE_API_ENV/API_ENV from the project's .env
6
+ * file first, then falls back to process.env, then defaults to "develop".
7
+ *
8
+ * Cache: 5-minute TTL, shared across all callers in the same process.
9
+ */
10
+
11
+ const fs = require("fs")
12
+ const path = require("path")
13
+
14
+ const ENVIRONMENT_URLS = {
15
+ production: {
16
+ apiBaseUrl: "https://api.gramercy.cloud",
17
+ openApiSpec: "https://api.gramercy.cloud/api-specs/openapi.json",
18
+ asyncApiSpec: "https://api.gramercy.cloud/api-specs/asyncapi.json",
19
+ webhookSpec: "https://api.gramercy.cloud/api-specs/webhooks.json",
20
+ },
21
+ staging: {
22
+ apiBaseUrl: "https://api.efz-staging.env.eventfinity.app",
23
+ openApiSpec:
24
+ "https://api.efz-staging.env.eventfinity.app/api-specs/openapi.json",
25
+ asyncApiSpec:
26
+ "https://api.efz-staging.env.eventfinity.app/api-specs/asyncapi.json",
27
+ webhookSpec:
28
+ "https://api.efz-staging.env.eventfinity.app/api-specs/webhooks.json",
29
+ },
30
+ testing: {
31
+ apiBaseUrl: "https://api.zenith-develop-testing.env.eventfinity.app",
32
+ openApiSpec:
33
+ "https://api.zenith-develop-testing.env.eventfinity.app/api-specs/openapi.json",
34
+ asyncApiSpec:
35
+ "https://api.zenith-develop-testing.env.eventfinity.app/api-specs/asyncapi.json",
36
+ webhookSpec:
37
+ "https://api.zenith-develop-testing.env.eventfinity.app/api-specs/webhooks.json",
38
+ },
39
+ develop: {
40
+ apiBaseUrl: "https://api.zenith-develop.env.eventfinity.app",
41
+ openApiSpec:
42
+ "https://api.zenith-develop.env.eventfinity.app/api-specs/openapi.json",
43
+ asyncApiSpec:
44
+ "https://api.zenith-develop.env.eventfinity.app/api-specs/asyncapi.json",
45
+ webhookSpec:
46
+ "https://api.zenith-develop.env.eventfinity.app/api-specs/webhooks.json",
47
+ },
48
+ local: {
49
+ apiBaseUrl: "https://dashboard.eventfinity.test",
50
+ openApiSpec: "https://api.eventfinity.test/api-specs/openapi.json",
51
+ asyncApiSpec: "https://api.eventfinity.test/api-specs/asyncapi.json",
52
+ webhookSpec: "https://api.eventfinity.test/api-specs/webhooks.json",
53
+ },
54
+ }
55
+
56
+ const CACHE_TTL = 5 * 60 * 1000
57
+ const specCache = {
58
+ openapi: null,
59
+ asyncapi: null,
60
+ webhooks: null,
61
+ lastFetch: null,
62
+ }
63
+
64
+ function getEnvironment() {
65
+ const envPath = path.join(process.cwd(), ".env")
66
+ if (fs.existsSync(envPath)) {
67
+ const envContent = fs.readFileSync(envPath, "utf-8")
68
+ const match = envContent.match(/VITE_API_ENV=(\w+)/)
69
+ if (match) {
70
+ return match[1]
71
+ }
72
+ }
73
+ return process.env.VITE_API_ENV || process.env.API_ENV || "develop"
74
+ }
75
+
76
+ function getEnvUrls() {
77
+ const env = getEnvironment()
78
+ return ENVIRONMENT_URLS[env] || ENVIRONMENT_URLS.develop
79
+ }
80
+
81
+ async function fetchSpec(specType) {
82
+ const urls = getEnvUrls()
83
+ const urlMap = {
84
+ openapi: urls.openApiSpec,
85
+ asyncapi: urls.asyncApiSpec,
86
+ webhooks: urls.webhookSpec,
87
+ }
88
+ const url = urlMap[specType]
89
+ if (!url) {
90
+ throw new Error(`Unknown spec type: ${specType}`)
91
+ }
92
+
93
+ const now = Date.now()
94
+ if (
95
+ specCache[specType] &&
96
+ specCache.lastFetch &&
97
+ now - specCache.lastFetch < CACHE_TTL
98
+ ) {
99
+ return specCache[specType]
100
+ }
101
+
102
+ const res = await fetch(url)
103
+ if (!res.ok) {
104
+ throw new Error(`Failed to fetch ${specType} spec: ${res.status}`)
105
+ }
106
+ const spec = await res.json()
107
+ specCache[specType] = spec
108
+ specCache.lastFetch = now
109
+ return spec
110
+ }
111
+
112
+ /**
113
+ * Test seam: allow tests to inject a fixed spec into the cache and freeze it.
114
+ * Returns a restore function.
115
+ */
116
+ function __setCacheForTest(overrides) {
117
+ const prev = {
118
+ openapi: specCache.openapi,
119
+ asyncapi: specCache.asyncapi,
120
+ webhooks: specCache.webhooks,
121
+ lastFetch: specCache.lastFetch,
122
+ }
123
+ Object.assign(specCache, overrides, {
124
+ lastFetch: Date.now(),
125
+ })
126
+ return () => Object.assign(specCache, prev)
127
+ }
128
+
129
+ module.exports = {
130
+ ENVIRONMENT_URLS,
131
+ getEnvironment,
132
+ getEnvUrls,
133
+ fetchSpec,
134
+ __setCacheForTest,
135
+ }