@anokye-labs/kbexplorer-engine 0.1.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.
@@ -0,0 +1,973 @@
1
+ import { assignIdentity } from './chunk-JNQVSNLC.js';
2
+ import { resolveAccessExclusion, DEFAULT_ACCESS_EXCLUSION, normalizeAccessLabel, coerceAccessLabel, isExcludedByDefault } from '@anokye-labs/kbexplorer-core';
3
+ import { Marked } from 'marked';
4
+ import sanitizeHtml from 'sanitize-html';
5
+ import yaml from 'yaml';
6
+
7
+ var TEMPLATE_SENSITIVE_CLASSIFICATIONS = /* @__PURE__ */ new Set(["restricted", "confidential", "unknown"]);
8
+ var TEMPLATE_SENSITIVE_VISIBILITIES = /* @__PURE__ */ new Set(["private"]);
9
+ var TEMPLATE_KNOWN_CLASSIFICATIONS = /* @__PURE__ */ new Set(["public", "internal", "confidential", "restricted", "unknown"]);
10
+ var TEMPLATE_CORE_EXCLUSION = resolveAccessExclusion(DEFAULT_ACCESS_EXCLUSION);
11
+ function normalizeAccessValue(value) {
12
+ return normalizeAccessLabel(value) ?? coerceAccessLabel(value);
13
+ }
14
+ function isTemplateCoreExcluded(label) {
15
+ if (!label) return false;
16
+ const classification = label.classification?.trim().toLowerCase();
17
+ if (classification && !TEMPLATE_KNOWN_CLASSIFICATIONS.has(classification)) {
18
+ return false;
19
+ }
20
+ return isExcludedByDefault(label, TEMPLATE_CORE_EXCLUSION);
21
+ }
22
+ function isAccessWithheld(node) {
23
+ const access = node.access;
24
+ if (!access) return false;
25
+ const label = normalizeAccessValue(access);
26
+ if (!label) return false;
27
+ const classification = label.classification?.trim().toLowerCase();
28
+ if (classification && TEMPLATE_SENSITIVE_CLASSIFICATIONS.has(classification)) {
29
+ return true;
30
+ }
31
+ const visibility = label.visibility?.trim().toLowerCase();
32
+ if (visibility && TEMPLATE_SENSITIVE_VISIBILITIES.has(visibility)) {
33
+ return true;
34
+ }
35
+ return isTemplateCoreExcluded(label);
36
+ }
37
+ function filterAccessWithheld(nodes) {
38
+ const kept = nodes.filter((n) => !isAccessWithheld(n));
39
+ return kept.length === nodes.length ? nodes : kept;
40
+ }
41
+ function parseAccessLabel(value) {
42
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
43
+ return void 0;
44
+ }
45
+ const label = normalizeAccessLabel(value);
46
+ if (!label) return void 0;
47
+ const sanitized = {};
48
+ if (typeof label.classification === "string" && label.classification.trim()) {
49
+ sanitized.classification = label.classification.trim();
50
+ }
51
+ if (typeof label.visibility === "string" && label.visibility.trim()) {
52
+ sanitized.visibility = label.visibility.trim();
53
+ }
54
+ if (Array.isArray(label.labels)) {
55
+ const labels = label.labels.filter((item) => typeof item === "string" && item.trim() !== "");
56
+ if (labels.length > 0) sanitized.labels = labels;
57
+ }
58
+ return Object.keys(sanitized).length > 0 ? sanitized : void 0;
59
+ }
60
+ var SANITIZE_OPTIONS = {
61
+ allowedTags: [
62
+ // Blocks / structure
63
+ "p",
64
+ "br",
65
+ "hr",
66
+ "h1",
67
+ "h2",
68
+ "h3",
69
+ "h4",
70
+ "h5",
71
+ "h6",
72
+ "ul",
73
+ "ol",
74
+ "li",
75
+ "blockquote",
76
+ "pre",
77
+ "code",
78
+ "div",
79
+ "span",
80
+ // Inline formatting
81
+ "em",
82
+ "strong",
83
+ "del",
84
+ "a",
85
+ "img",
86
+ // Tables (GFM)
87
+ "table",
88
+ "thead",
89
+ "tbody",
90
+ "tr",
91
+ "td",
92
+ "th",
93
+ // Collapsible sections + theme-aware images (used in real issue/PR/README HTML)
94
+ "details",
95
+ "summary",
96
+ "picture",
97
+ "source",
98
+ // GFM task-list checkboxes are markdown-generated (`- [ ]` / `- [x]`)
99
+ "input"
100
+ ],
101
+ allowedAttributes: {
102
+ a: ["href", "title"],
103
+ img: ["src", "alt", "title", "width", "height"],
104
+ source: ["srcset", "media", "type", "sizes"],
105
+ // `start` is markdown-generated for ordered lists that don't begin at 1
106
+ // (`4.` → `<ol start="4">`); `reversed`/`type` are safe presentational
107
+ // siblings. Dropping `start` would silently reset list numbering.
108
+ ol: ["start", "reversed", "type"],
109
+ // GFM column alignment renders as `align` on the header/data cells
110
+ // (`|:-:|` → `<th align="center">`); the rest are safe structural/a11y
111
+ // attributes real HTML tables use. All are presentational — no script sink.
112
+ th: ["align", "colspan", "rowspan", "scope"],
113
+ td: ["align", "colspan", "rowspan"],
114
+ // Only the attributes marked emits for task-list checkboxes — no `on*`,
115
+ // no `src`/`formaction`, so an allowed `<input>` is inert.
116
+ input: ["type", "checked", "disabled"],
117
+ // `class` carries `language-*` on fenced code, which the diagram/mermaid
118
+ // detection and syntax styling read. Kept minimal — no `style`, no `id`.
119
+ code: ["class"],
120
+ pre: ["class"],
121
+ span: ["class"],
122
+ div: ["class"]
123
+ },
124
+ // URL schemes permitted on href/src/srcset after entity + whitespace
125
+ // normalization. Relative targets (no scheme) are always allowed; anything
126
+ // with a `javascript:`/`data:`/`vbscript:`/etc. scheme is dropped.
127
+ allowedSchemes: ["http", "https", "mailto"],
128
+ allowedSchemesAppliedToAttributes: ["href", "src", "srcset"],
129
+ // Reject protocol-relative (`//host/…`) targets — they inherit the page
130
+ // scheme and can point at an arbitrary host.
131
+ allowProtocolRelative: false,
132
+ // Non-allowlisted tags become visible escaped text rather than being dropped,
133
+ // preserving the previous renderer's "hostile markup shows as inert text"
134
+ // property for tags like <script>/<style>/<iframe>/<svg>.
135
+ disallowedTagsMode: "escape"
136
+ };
137
+ var markdown = new Marked();
138
+ function renderSafeMarkdown(body) {
139
+ const html = markdown.parse(body, { async: false });
140
+ return sanitizeHtml(html, SANITIZE_OPTIONS);
141
+ }
142
+
143
+ // src/default-config.ts
144
+ var DEFAULT_CONFIG = {
145
+ title: "kbexplorer",
146
+ subtitle: "Interactive Knowledge Base Explorer",
147
+ author: "Anokye Labs",
148
+ source: { owner: "anokye-labs", repo: "kbexplorer", path: "content", branch: "main" },
149
+ clusters: {
150
+ // Each cluster may also carry an optional `tokens` delta (Fluent token name
151
+ // → CSS value, same shape as theme.tokens) to shift only that cluster's
152
+ // scoped surfaces (cards/badges/reading header). Omitted here so defaults
153
+ // inherit the active global theme unchanged.
154
+ feature: { name: "Feature", color: "#4A9CC8" },
155
+ task: { name: "Task", color: "#8CB050" },
156
+ bug: { name: "Bug", color: "#C04040" },
157
+ epic: { name: "Epic", color: "#E8A838" },
158
+ code: { name: "Code", color: "#9A8A78" },
159
+ docs: { name: "Documentation", color: "#D4A050" },
160
+ "pull-request": { name: "Pull Request", color: "#A86FDF" },
161
+ commits: { name: "Commits", color: "#5A98A8" },
162
+ releases: { name: "Releases", color: "#F78166" }
163
+ },
164
+ visuals: {
165
+ mode: "emoji",
166
+ fallback: "emoji"
167
+ },
168
+ theme: {
169
+ default: "dark",
170
+ font: {
171
+ heading: "'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif",
172
+ body: "'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif",
173
+ mono: "'Cascadia Code', 'Cascadia Mono', Consolas, monospace"
174
+ }
175
+ // brand / tokens / themes are optional, additive overrides (see KBConfig.theme).
176
+ // Left unset by default so the built-in dark/light/sepia themes are unchanged.
177
+ // themesFile (also unset by default) may point at a dedicated theme file in the
178
+ // host repo (e.g. "content/themes/extra.yaml"); when set it is fetched at runtime
179
+ // like config.yaml and its named themes are merged into the THEME_MAP, overriding
180
+ // any inline theme.themes of the same name. Unset means no fetch, no behavior change.
181
+ // moduleUrl (also unset by default) is the most powerful escape hatch: a
182
+ // security-sensitive opt-in that dynamically import()s a host-provided ESM JS
183
+ // module exporting a Fluent Theme / BrandVariants and registers it into the
184
+ // THEME_MAP. Off by default, meaning no import, pure no-op. Only set it for a
185
+ // module you trust (ideally self-hosted in this repo) and tighten CSP accordingly
186
+ // — see the theming docs' CSP note.
187
+ },
188
+ graph: {
189
+ physics: true,
190
+ layout: "force-atlas-2"
191
+ },
192
+ features: {
193
+ hud: true,
194
+ minimap: true,
195
+ readingTools: true,
196
+ keyboardNav: true,
197
+ sparkAnimation: false,
198
+ search: true
199
+ }
200
+ // branding omitted by default — host repos may set branding.logo (a repo-relative
201
+ // image path) to render a logo on the HomePage hero and HUD header, and
202
+ // branding.favicon (a repo-relative image path) to swap the favicon at
203
+ // runtime, and branding.css (a repo-relative path or URL) to inject a raw CSS
204
+ // override sheet last in <head> for full control over --colorNeutral*/
205
+ // --colorBrand*/--kbe-* variables. Text title and the static /favicon.svg are
206
+ // used as graceful fallbacks; branding.css is unset by default so nothing is
207
+ // injected.
208
+ };
209
+
210
+ // src/github-client.ts
211
+ var GITHUB_ENDPOINT_PATTERNS = [
212
+ "contents/",
213
+ "git/trees/",
214
+ "issues",
215
+ "pulls",
216
+ "commits",
217
+ "releases",
218
+ "branches",
219
+ "languages"
220
+ ];
221
+ var DEFAULT_GH_API_BASE = "https://api.github.com";
222
+ function resolveApiBase(env) {
223
+ return env?.VITE_GH_API_BASE ?? DEFAULT_GH_API_BASE;
224
+ }
225
+ async function ghFetch(path, env, etag) {
226
+ const headers = {
227
+ Accept: "application/vnd.github.v3+json"
228
+ };
229
+ const token = env?.GITHUB_TOKEN ?? env?.GH_TOKEN;
230
+ if (token) {
231
+ headers["Authorization"] = `Bearer ${token}`;
232
+ }
233
+ const res = await fetch(`${resolveApiBase(env)}${path}`, { headers });
234
+ if (res.status === 304) {
235
+ throw new NotModifiedError();
236
+ }
237
+ if (res.status === 403 && res.headers.get("X-RateLimit-Remaining") === "0") {
238
+ const reset = res.headers.get("X-RateLimit-Reset");
239
+ throw new RateLimitError(reset ? new Date(Number(reset) * 1e3) : void 0);
240
+ }
241
+ if (!res.ok) {
242
+ throw new GitHubApiError(res.status, await res.text());
243
+ }
244
+ const data = await res.json();
245
+ const responseEtag = res.headers.get("ETag");
246
+ return responseEtag ? { data, etag: responseEtag } : { data };
247
+ }
248
+ var NotModifiedError = class extends Error {
249
+ constructor() {
250
+ super("Not modified");
251
+ this.name = "NotModifiedError";
252
+ }
253
+ };
254
+ var RateLimitError = class extends Error {
255
+ resetAt;
256
+ constructor(resetAt) {
257
+ super(`GitHub API rate limit exceeded${resetAt ? `. Resets at ${resetAt.toISOString()}` : ""}`);
258
+ this.name = "RateLimitError";
259
+ if (resetAt) this.resetAt = resetAt;
260
+ }
261
+ };
262
+ var GitHubApiError = class extends Error {
263
+ status;
264
+ constructor(status, body) {
265
+ super(`GitHub API error ${status}: ${body}`);
266
+ this.name = "GitHubApiError";
267
+ this.status = status;
268
+ }
269
+ };
270
+ async function fetchFile(source, path, env, cache) {
271
+ const branch = source.branch ?? "main";
272
+ const key = `file:${source.owner}/${source.repo}:${path}`;
273
+ const hit = cache?.get(key);
274
+ if (hit !== void 0) return hit;
275
+ const { data } = await ghFetch(
276
+ `/repos/${source.owner}/${source.repo}/contents/${path}?ref=${branch}`,
277
+ env
278
+ );
279
+ const binary = atob(data.content);
280
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
281
+ const decoded = new TextDecoder().decode(bytes);
282
+ cache?.set(key, decoded);
283
+ return decoded;
284
+ }
285
+ async function fetchTree(source, path, env, cache) {
286
+ const branch = source.branch ?? "main";
287
+ const key = `tree:${source.owner}/${source.repo}:${path ?? ""}`;
288
+ const hit = cache?.get(key);
289
+ if (hit !== void 0) return hit;
290
+ const { data } = await ghFetch(
291
+ `/repos/${source.owner}/${source.repo}/git/trees/${branch}?recursive=1`,
292
+ env
293
+ );
294
+ const items = path ? data.tree.filter((item) => item.path.startsWith(path + "/")) : data.tree;
295
+ cache?.set(key, items);
296
+ return items;
297
+ }
298
+ async function fetchIssues(source, env, cache) {
299
+ const key = `issues:${source.owner}/${source.repo}`;
300
+ const hit = cache?.get(key);
301
+ if (hit !== void 0) return hit;
302
+ const allIssues = [];
303
+ let page = 1;
304
+ const perPage = 100;
305
+ while (true) {
306
+ const { data } = await ghFetch(
307
+ `/repos/${source.owner}/${source.repo}/issues?state=all&per_page=${perPage}&page=${page}`,
308
+ env
309
+ );
310
+ const issues = data.filter((i) => !i.pull_request);
311
+ allIssues.push(...issues);
312
+ if (data.length < perPage) break;
313
+ page++;
314
+ }
315
+ cache?.set(key, allIssues);
316
+ return allIssues;
317
+ }
318
+ async function fetchPullRequests(source, env, cache) {
319
+ const key = `prs:${source.owner}/${source.repo}`;
320
+ const hit = cache?.get(key);
321
+ if (hit !== void 0) return hit;
322
+ const allPRs = [];
323
+ let page = 1;
324
+ const perPage = 100;
325
+ while (true) {
326
+ const { data } = await ghFetch(
327
+ `/repos/${source.owner}/${source.repo}/pulls?state=all&per_page=${perPage}&page=${page}`,
328
+ env
329
+ );
330
+ allPRs.push(...data);
331
+ if (data.length < perPage) break;
332
+ page++;
333
+ }
334
+ cache?.set(key, allPRs);
335
+ return allPRs;
336
+ }
337
+ async function fetchCommits(source, count = 30, env, cache) {
338
+ const key = `commits:${source.owner}/${source.repo}`;
339
+ const hit = cache?.get(key);
340
+ if (hit !== void 0) return hit;
341
+ const branch = source.branch ?? "main";
342
+ const { data } = await ghFetch(
343
+ `/repos/${source.owner}/${source.repo}/commits?sha=${branch}&per_page=${count}`,
344
+ env
345
+ );
346
+ cache?.set(key, data);
347
+ return data;
348
+ }
349
+ async function fetchReleases(source, limit = 30, env, cache) {
350
+ const key = `releases:${source.owner}/${source.repo}`;
351
+ const hit = cache?.get(key);
352
+ if (hit !== void 0) return hit;
353
+ const { data } = await ghFetch(`/repos/${source.owner}/${source.repo}/releases?per_page=${limit}`, env);
354
+ const releases = data.filter((r) => !r.draft).sort((a, b) => new Date(b.published_at ?? 0).getTime() - new Date(a.published_at ?? 0).getTime()).slice(0, limit).map((r) => ({
355
+ tag_name: r.tag_name ?? "",
356
+ name: r.name ?? r.tag_name ?? "",
357
+ body: r.body ?? "",
358
+ html_url: r.html_url ?? "",
359
+ published_at: r.published_at ?? "",
360
+ prerelease: r.prerelease ?? false
361
+ }));
362
+ cache?.set(key, releases);
363
+ return releases;
364
+ }
365
+ async function fetchFiles(source, paths, env, cache) {
366
+ const results = /* @__PURE__ */ new Map();
367
+ const settled = await Promise.allSettled(
368
+ paths.map(async (path) => {
369
+ const content = await fetchFile(source, path, env, cache);
370
+ return { path, content };
371
+ })
372
+ );
373
+ for (const result of settled) {
374
+ if (result.status === "fulfilled") {
375
+ results.set(result.value.path, result.value.content);
376
+ }
377
+ }
378
+ return results;
379
+ }
380
+ async function fetchBranches(source, env, cache) {
381
+ const key = `branches:${source.owner}/${source.repo}`;
382
+ const hit = cache?.get(key);
383
+ if (hit !== void 0) return hit;
384
+ const all = [];
385
+ let page = 1;
386
+ const perPage = 100;
387
+ while (true) {
388
+ const { data } = await ghFetch(
389
+ `/repos/${source.owner}/${source.repo}/branches?per_page=${perPage}&page=${page}`,
390
+ env
391
+ );
392
+ all.push(...data.map((b) => ({ name: b.name, protected: b.protected ?? false })));
393
+ if (data.length < perPage) break;
394
+ page++;
395
+ }
396
+ cache?.set(key, all);
397
+ return all;
398
+ }
399
+ async function fetchRepoMetadata(source, env, cache) {
400
+ const key = `repoMetadata:${source.owner}/${source.repo}`;
401
+ const hit = cache?.get(key);
402
+ if (hit !== void 0) return hit;
403
+ const [{ data: repo }, languagesMap] = await Promise.all([
404
+ ghFetch(`/repos/${source.owner}/${source.repo}`, env),
405
+ ghFetch(`/repos/${source.owner}/${source.repo}/languages`, env).then((r) => r.data).catch(() => ({}))
406
+ ]);
407
+ const languages = Object.entries(languagesMap).sort((a, b) => b[1] - a[1]).map(([name, size]) => ({ name, size }));
408
+ const metadata = {
409
+ name: repo.name ?? "",
410
+ description: repo.description ?? "",
411
+ html_url: repo.html_url ?? "",
412
+ homepage: repo.homepage ?? "",
413
+ default_branch: repo.default_branch ?? "main",
414
+ stargazers_count: repo.stargazers_count ?? 0,
415
+ forks_count: repo.forks_count ?? 0,
416
+ private: repo.private ?? false,
417
+ topics: repo.topics ?? [],
418
+ primary_language: repo.language ?? "",
419
+ languages,
420
+ owner: {
421
+ login: repo.owner?.login ?? "",
422
+ avatar_url: repo.owner?.avatar_url ?? ""
423
+ }
424
+ };
425
+ cache?.set(key, metadata);
426
+ return metadata;
427
+ }
428
+ var DATE_FORMAT = { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" };
429
+ function buildPageTheme(fm) {
430
+ const page = {};
431
+ if (typeof fm.accent === "string" && fm.accent.trim()) page.accent = fm.accent.trim();
432
+ if (typeof fm.theme === "string" && fm.theme.trim()) page.theme = fm.theme.trim();
433
+ if (fm.tokens && typeof fm.tokens === "object" && !Array.isArray(fm.tokens)) {
434
+ const tokens = {};
435
+ for (const [k, v] of Object.entries(fm.tokens)) {
436
+ if (typeof v === "string") tokens[k] = v;
437
+ }
438
+ if (Object.keys(tokens).length > 0) page.tokens = tokens;
439
+ }
440
+ return page.accent || page.theme || page.tokens ? page : void 0;
441
+ }
442
+ function asRecord(value) {
443
+ return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
444
+ }
445
+ function normalizeText(value) {
446
+ if (typeof value !== "string") return void 0;
447
+ const trimmed = value.trim();
448
+ return trimmed ? trimmed : void 0;
449
+ }
450
+ function normalizeWeight(value) {
451
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
452
+ if (typeof value !== "string") return void 0;
453
+ const trimmed = value.trim();
454
+ if (!trimmed) return void 0;
455
+ const numeric = Number(trimmed);
456
+ return Number.isFinite(numeric) ? numeric : void 0;
457
+ }
458
+ function parseAuthoredConnections(value) {
459
+ if (!Array.isArray(value)) return [];
460
+ const connections = [];
461
+ for (const item of value) {
462
+ const conn = asRecord(item);
463
+ if (!conn) continue;
464
+ const to = normalizeText(conn.to);
465
+ if (!to) continue;
466
+ const type = normalizeText(conn.type);
467
+ const relation = normalizeText(conn.relation);
468
+ const weight = normalizeWeight(conn.weight);
469
+ connections.push({
470
+ to,
471
+ type: type ?? "frontmatter",
472
+ description: typeof conn.description === "string" ? conn.description : "",
473
+ source: "frontmatter",
474
+ ...weight !== void 0 ? { weight } : {},
475
+ ...relation ? { relation } : {}
476
+ });
477
+ }
478
+ return connections;
479
+ }
480
+ function parseFrontmatter(raw) {
481
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
482
+ if (!match) return { data: {}, content: raw };
483
+ try {
484
+ const data = yaml.parse(match[1]);
485
+ return { data: data ?? {}, content: match[2] };
486
+ } catch {
487
+ return { data: {}, content: raw };
488
+ }
489
+ }
490
+ function parseMarkdownFile(path, raw) {
491
+ const { data, content } = parseFrontmatter(raw);
492
+ const fm = data;
493
+ const id = fm.id ?? path.replace(/\.md$/, "").replace(/.*\//, "");
494
+ const html = renderSafeMarkdown(content);
495
+ const connections = parseAuthoredConnections(fm.connections);
496
+ const connectedTo = new Set(connections.map((c) => c.to));
497
+ for (const m of content.matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)) {
498
+ const target = m[2].trim();
499
+ if (target.startsWith("http") || target.startsWith("#") || target.startsWith("/")) continue;
500
+ if (target.match(/\.(png|jpg|jpeg|gif|svg|webp)$/i)) continue;
501
+ if (connectedTo.has(target)) continue;
502
+ connections.push({ to: target, type: "references", description: m[1], source: "inline" });
503
+ connectedTo.add(target);
504
+ }
505
+ for (const m of content.matchAll(/(?:src|scripts|content|public)\/[\w./-]+\.\w+/g)) {
506
+ const filePath = m[0];
507
+ const fileNodeId = `file-${filePath}`;
508
+ if (connectedTo.has(fileNodeId)) continue;
509
+ connections.push({ to: fileNodeId, type: "references", description: `References ${filePath}`, source: "inferred" });
510
+ connectedTo.add(fileNodeId);
511
+ }
512
+ const sourceFileId = `file-${path}`;
513
+ if (!connectedTo.has(sourceFileId)) {
514
+ connections.push({ to: sourceFileId, type: "derived_from", description: "Derived from", source: "inferred" });
515
+ }
516
+ const node = {
517
+ id,
518
+ title: fm.title ?? id,
519
+ cluster: fm.cluster ?? "default",
520
+ content: html,
521
+ rawContent: content,
522
+ ...fm.emoji !== void 0 ? { emoji: fm.emoji } : {},
523
+ ...fm.image !== void 0 ? { image: fm.image } : {},
524
+ ...fm.sprite !== void 0 ? { sprite: fm.sprite } : {},
525
+ ...fm.parent !== void 0 ? { parent: fm.parent } : {},
526
+ derived: fm.derived === true,
527
+ ...fm.display !== void 0 ? { display: fm.display } : {},
528
+ connections,
529
+ source: { type: "authored", file: path }
530
+ };
531
+ const pageTheme = buildPageTheme(fm);
532
+ if (pageTheme) node.pageTheme = pageTheme;
533
+ const access = parseAccessLabel(fm.access) ?? coerceAccessLabel(fm.access);
534
+ if (access) node.access = access;
535
+ if (typeof fm.identity === "string" && fm.identity.trim()) {
536
+ node.identity = fm.identity.trim();
537
+ } else {
538
+ const identity = assignIdentity(node);
539
+ if (identity !== void 0) node.identity = identity;
540
+ }
541
+ return node;
542
+ }
543
+ async function loadAuthoredContent(source, contentPath, env, cache) {
544
+ const tree = await fetchTree(source, contentPath, env, cache);
545
+ const mdFiles = tree.filter((item) => item.type === "blob" && item.path.endsWith(".md")).map((item) => item.path);
546
+ const files = await fetchFiles(source, mdFiles, env, cache);
547
+ const nodes = [];
548
+ for (const [path, content] of files) {
549
+ try {
550
+ nodes.push(parseMarkdownFile(path, content));
551
+ } catch {
552
+ console.warn(`[kbexplorer] Failed to parse ${path}, skipping`);
553
+ }
554
+ }
555
+ return nodes;
556
+ }
557
+ var ISSUE_TYPE_ICON = {
558
+ epic: "Flag",
559
+ feature: "Sparkle",
560
+ task: "Wrench",
561
+ bug: "Bug",
562
+ enhancement: "Lightbulb",
563
+ documentation: "Document",
564
+ question: "QuestionCircle"
565
+ };
566
+ function issueIcon(labels) {
567
+ for (const label of labels) {
568
+ const lower = label.toLowerCase();
569
+ if (ISSUE_TYPE_ICON[lower]) return ISSUE_TYPE_ICON[lower];
570
+ }
571
+ return "Pin";
572
+ }
573
+ function extractIssueRefs(body) {
574
+ if (!body) return [];
575
+ const matches = body.matchAll(/#(\d+)/g);
576
+ return [...matches].map((m) => Number(m[1]));
577
+ }
578
+ function issueToNode(issue, options = {}) {
579
+ const labels = issue.labels.map((l) => l.name);
580
+ const cluster = "work";
581
+ const body = issue.body ?? "";
582
+ const remappedBody = body.replace(/https?:\/\/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/g, (_m, num) => `issue-${num}`).replace(/https?:\/\/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/g, (_m, num) => `pr-${num}`);
583
+ const refs = extractIssueRefs(body);
584
+ const stateEmoji = issue.state === "open" ? "\u{1F7E2}" : "\u{1F7E3}";
585
+ const labelBadges = labels.map((l) => `\`${l}\``).join(" ");
586
+ const assigneeList = issue.assignees?.length ? issue.assignees.map((a) => `@${a.login}`).join(", ") : "";
587
+ const created = new Date(issue.created_at).toLocaleDateString("en-US", DATE_FORMAT);
588
+ const updated = new Date(issue.updated_at).toLocaleDateString("en-US", DATE_FORMAT);
589
+ const metaLines = [
590
+ `${stateEmoji} **${issue.state.toUpperCase()}** \xB7 #${issue.number}`,
591
+ labelBadges ? `Labels: ${labelBadges}` : "",
592
+ assigneeList ? `Assignees: ${assigneeList}` : "",
593
+ `Created: ${created} \xB7 Updated: ${updated}`,
594
+ `[View on GitHub \u2197](${issue.html_url})`
595
+ ].filter(Boolean).join("\n\n");
596
+ const fullContent = `${metaLines}
597
+
598
+ ---
599
+
600
+ ${remappedBody}`;
601
+ const html = renderSafeMarkdown(fullContent);
602
+ const connections = [];
603
+ const seen = /* @__PURE__ */ new Set();
604
+ const { knownIssueNumbers, knownPrNumbers, repoNodeId } = options;
605
+ for (const n of refs) {
606
+ if (n === issue.number) continue;
607
+ if (knownIssueNumbers && knownIssueNumbers.has(n)) {
608
+ const to = `issue-${n}`;
609
+ if (!seen.has(to)) {
610
+ connections.push({ to, type: "cross_references", description: `References #${n}`, source: "inline" });
611
+ seen.add(to);
612
+ }
613
+ } else if (knownPrNumbers && knownPrNumbers.has(n)) {
614
+ const to = `pr-${n}`;
615
+ if (!seen.has(to)) {
616
+ connections.push({ to, type: "cross_references", description: `References #${n}`, source: "inline" });
617
+ seen.add(to);
618
+ }
619
+ } else if (!knownIssueNumbers && !knownPrNumbers) {
620
+ const to = `issue-${n}`;
621
+ if (!seen.has(to)) {
622
+ connections.push({ to, type: "cross_references", description: `References #${n}`, source: "inline" });
623
+ seen.add(to);
624
+ }
625
+ }
626
+ }
627
+ if (repoNodeId) {
628
+ connections.push({
629
+ to: repoNodeId,
630
+ type: "contains",
631
+ relation: "tracked-in",
632
+ description: "Tracked in repository",
633
+ source: "inferred"
634
+ });
635
+ }
636
+ const node = {
637
+ id: `issue-${issue.number}`,
638
+ title: issue.title,
639
+ cluster,
640
+ content: html,
641
+ rawContent: fullContent,
642
+ emoji: issueIcon(labels),
643
+ connections,
644
+ source: { type: "issue", number: issue.number, state: issue.state, labels }
645
+ };
646
+ if (repoNodeId) node.parent = repoNodeId;
647
+ const issueIdentity = assignIdentity(node);
648
+ if (issueIdentity !== void 0) node.identity = issueIdentity;
649
+ return node;
650
+ }
651
+ function splitIntoSections(parentId, parentTitle, rawContent, cluster, emoji, source, allNodes) {
652
+ const lines = rawContent.split("\n");
653
+ const sections = [];
654
+ let currentSection = null;
655
+ const introLines = [];
656
+ for (const line of lines) {
657
+ const headingMatch = line.match(/^##\s+(.+)/);
658
+ if (headingMatch) {
659
+ if (currentSection) sections.push(currentSection);
660
+ currentSection = { title: headingMatch[1].trim(), lines: [] };
661
+ } else if (currentSection) {
662
+ currentSection.lines.push(line);
663
+ } else {
664
+ introLines.push(line);
665
+ }
666
+ }
667
+ if (currentSection) sections.push(currentSection);
668
+ if (sections.length < 2) return [];
669
+ const result = [];
670
+ const introContent = introLines.join("\n").trim();
671
+ const introHtml = introContent ? renderSafeMarkdown(introContent) : "";
672
+ const sectionIds = sections.map((s, i) => `${parentId}/${slugify(s.title, i)}`);
673
+ const parentNode = {
674
+ id: parentId,
675
+ title: parentTitle,
676
+ cluster,
677
+ content: introHtml,
678
+ rawContent: introContent,
679
+ emoji,
680
+ nodeType: "parent",
681
+ connections: sectionIds.map((sid) => ({ to: sid, type: "contains", description: "Contains", source: "inferred" })),
682
+ source
683
+ };
684
+ const lower = rawContent.toLowerCase();
685
+ for (const n of allNodes) {
686
+ if (n.id === parentId) continue;
687
+ const titleWords = n.title.toLowerCase().split(/\s+/).filter((w) => w.length > 3);
688
+ if (titleWords.length === 0) continue;
689
+ const matchCount = titleWords.filter((w) => lower.includes(w)).length;
690
+ if (matchCount >= Math.ceil(titleWords.length * 0.6)) {
691
+ parentNode.connections.push({ to: n.id, type: "mentions", description: "Mentions", source: "inferred" });
692
+ }
693
+ }
694
+ result.push(parentNode);
695
+ for (let i = 0; i < sections.length; i++) {
696
+ const s = sections[i];
697
+ const sectionId = sectionIds[i];
698
+ const sectionBody = s.lines.join("\n").trim();
699
+ const sectionHtml = sectionBody ? renderSafeMarkdown(sectionBody) : "";
700
+ const sectionNode = {
701
+ id: sectionId,
702
+ title: s.title,
703
+ cluster,
704
+ content: sectionHtml,
705
+ rawContent: sectionBody,
706
+ emoji,
707
+ parent: parentId,
708
+ nodeType: "section",
709
+ connections: [],
710
+ source
711
+ };
712
+ const sLower = sectionBody.toLowerCase();
713
+ const refs = extractIssueRefs(sectionBody);
714
+ for (const num of refs) {
715
+ const refId = `issue-${num}`;
716
+ if (allNodes.some((n) => n.id === refId)) {
717
+ sectionNode.connections.push({ to: refId, type: "cross_references", description: `References #${num}`, source: "inline" });
718
+ }
719
+ }
720
+ for (const n of allNodes) {
721
+ if (n.source.type === "file") {
722
+ const dirName = n.title.replace(/\/$/, "").toLowerCase();
723
+ if (sLower.includes(`${dirName}/`) || sLower.includes(`\`${dirName}\``)) {
724
+ sectionNode.connections.push({ to: n.id, type: "references", description: `References ${n.title}`, source: "inferred" });
725
+ }
726
+ }
727
+ }
728
+ result.push(sectionNode);
729
+ }
730
+ return result;
731
+ }
732
+ function slugify(title, idx) {
733
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
734
+ return slug || `section-${idx}`;
735
+ }
736
+ var KEY_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".md", ".json", ".yaml", ".yml", ".css"]);
737
+ var SKIP_FILES = /* @__PURE__ */ new Set(["package-lock.json", ".gitignore", ".eslintrc.json"]);
738
+ function treeToNodes(tree, repoName, excludePaths) {
739
+ const nodes = [];
740
+ const dirs = /* @__PURE__ */ new Map();
741
+ const excludeSet = new Set(excludePaths ?? []);
742
+ for (const item of tree) {
743
+ if (item.path.startsWith(".")) continue;
744
+ const parts = item.path.split("/");
745
+ if (parts[0].startsWith(".")) continue;
746
+ if (excludeSet.has(parts[0])) continue;
747
+ if (item.type === "tree") continue;
748
+ const dirPath = parts.length > 1 ? parts.slice(0, Math.min(2, parts.length - 1)).join("/") : "";
749
+ if (dirPath) {
750
+ if (!dirs.has(dirPath)) dirs.set(dirPath, []);
751
+ dirs.get(dirPath).push(item);
752
+ }
753
+ }
754
+ const topDirs = [...dirs.keys()].filter((d) => !d.includes("/"));
755
+ const rootFiles = tree.filter((i) => i.type === "blob" && !i.path.includes("/") && !i.path.startsWith("."));
756
+ const rootContent = `## ${repoName}
757
+
758
+ ${topDirs.length} directories, ${rootFiles.length} root files`;
759
+ const rootHtml = renderSafeMarkdown(rootContent);
760
+ const rootNode = {
761
+ id: "repo-root",
762
+ title: repoName,
763
+ cluster: "infra",
764
+ content: rootHtml,
765
+ rawContent: rootContent,
766
+ emoji: "Folder",
767
+ nodeType: "parent",
768
+ connections: [],
769
+ source: { type: "file", path: "/" }
770
+ };
771
+ const rootIdentity = assignIdentity(rootNode);
772
+ if (rootIdentity !== void 0) rootNode.identity = rootIdentity;
773
+ nodes.push(rootNode);
774
+ for (const [dirPath, files] of dirs) {
775
+ const depth = dirPath.split("/").length;
776
+ const parentId = depth === 1 ? "repo-root" : `dir-${dirPath.split("/")[0]}`;
777
+ const fileList = files.slice(0, 15).map((f) => `- \`${f.path}\``).join("\n");
778
+ const content = `## ${dirPath}/
779
+
780
+ ${files.length} files
781
+
782
+ ${fileList}`;
783
+ const html = renderSafeMarkdown(content);
784
+ nodes.push({
785
+ id: `dir-${dirPath}`,
786
+ title: `${dirPath}/`,
787
+ cluster: "infra",
788
+ content: html,
789
+ rawContent: content,
790
+ emoji: "Folder",
791
+ parent: parentId,
792
+ nodeType: depth === 1 ? "parent" : "section",
793
+ connections: [],
794
+ identity: `urn:file:${dirPath}`,
795
+ source: { type: "file", path: dirPath }
796
+ });
797
+ }
798
+ for (const item of tree) {
799
+ if (item.type !== "blob") continue;
800
+ if (item.path.startsWith(".")) continue;
801
+ const parts = item.path.split("/");
802
+ if (parts[0].startsWith(".")) continue;
803
+ if (excludeSet.has(parts[0])) continue;
804
+ if (SKIP_FILES.has(parts[parts.length - 1])) continue;
805
+ const ext = "." + item.path.split(".").pop()?.toLowerCase();
806
+ if (!KEY_EXTENSIONS.has(ext)) continue;
807
+ if (item.path === "README.md") continue;
808
+ const parentDir = parts.length > 2 ? `dir-${parts[0]}/${parts[1]}` : parts.length > 1 ? `dir-${parts[0]}` : "repo-root";
809
+ nodes.push({
810
+ id: `file-${item.path}`,
811
+ title: parts[parts.length - 1],
812
+ cluster: "infra",
813
+ content: `<p><code>${item.path}</code></p>`,
814
+ rawContent: item.path,
815
+ emoji: "Document",
816
+ parent: parentDir,
817
+ nodeType: "section",
818
+ connections: [],
819
+ identity: `urn:file:${item.path}`,
820
+ source: { type: "file", path: item.path }
821
+ });
822
+ }
823
+ return nodes;
824
+ }
825
+ async function loadRepoContent(source, env, cache) {
826
+ const [issues, tree, readme] = await Promise.all([
827
+ fetchIssues(source, env, cache).catch(() => []),
828
+ fetchTree(source, void 0, env, cache).catch(() => []),
829
+ fetchFile(source, "README.md", env, cache).catch(() => null)
830
+ ]);
831
+ const nodes = [];
832
+ const knownIssueNumbers = new Set(issues.filter((i) => !i.pull_request).map((i) => i.number));
833
+ const knownPrNumbers = new Set(issues.filter((i) => i.pull_request).map((i) => i.number));
834
+ const issueNodes = issues.map((i) => issueToNode(i, {
835
+ knownIssueNumbers,
836
+ knownPrNumbers,
837
+ repoNodeId: "repo-meta"
838
+ }));
839
+ const dirNodes = treeToNodes(tree, source.repo);
840
+ nodes.push(...issueNodes);
841
+ nodes.push(...dirNodes);
842
+ if (readme) {
843
+ const readmeConns = [];
844
+ const lower = readme.toLowerCase();
845
+ const issueRefs = extractIssueRefs(readme);
846
+ for (const num of issueRefs) {
847
+ const id = `issue-${num}`;
848
+ if (issueNodes.some((n) => n.id === id)) {
849
+ readmeConns.push({ to: id, type: "cross_references", description: `References #${num}`, source: "inline" });
850
+ }
851
+ }
852
+ for (const node of issueNodes) {
853
+ if (readmeConns.some((c) => c.to === node.id)) continue;
854
+ const titleWords = node.title.toLowerCase().split(/\s+/).filter((w) => w.length > 3);
855
+ if (titleWords.length === 0) continue;
856
+ const matchCount = titleWords.filter((w) => lower.includes(w)).length;
857
+ if (matchCount >= Math.ceil(titleWords.length * 0.6)) {
858
+ readmeConns.push({ to: node.id, type: "mentions", description: "Mentions", source: "inferred" });
859
+ }
860
+ }
861
+ for (const dir of dirNodes) {
862
+ const dirName = dir.title.replace(/\/$/, "");
863
+ if (lower.includes(`${dirName}/`) || lower.includes(`\`${dirName}\``)) {
864
+ readmeConns.push({ to: dir.id, type: "references", description: `References ${dirName}/`, source: "inferred" });
865
+ }
866
+ }
867
+ readmeConns.push({ to: "repo-root", type: "contains", description: "Documents", source: "inferred" });
868
+ const readmeConnectedTo = new Set(readmeConns.map((c) => c.to));
869
+ for (const m of readme.matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)) {
870
+ const target = m[2]?.trim();
871
+ if (!target) continue;
872
+ if (target.startsWith("http") || target.startsWith("#") || target.startsWith("/")) continue;
873
+ if (target.match(/\.(png|jpg|jpeg|gif|svg|webp|md)$/i)) continue;
874
+ if (readmeConnectedTo.has(target)) continue;
875
+ readmeConns.push({ to: target, type: "references", description: m[1] ?? "", source: "inline" });
876
+ readmeConnectedTo.add(target);
877
+ }
878
+ const html = renderSafeMarkdown(readme);
879
+ nodes.push({
880
+ id: "readme",
881
+ title: "README",
882
+ cluster: "docs",
883
+ content: html,
884
+ rawContent: readme,
885
+ emoji: "Document",
886
+ parent: "repo-root",
887
+ identity: "urn:content:readme",
888
+ connections: readmeConns,
889
+ source: { type: "readme" }
890
+ });
891
+ }
892
+ const expandedIssues = [];
893
+ for (const node of issueNodes) {
894
+ const sectionNodes = splitIntoSections(
895
+ node.id,
896
+ node.title,
897
+ node.rawContent,
898
+ node.cluster,
899
+ node.emoji ?? "Pin",
900
+ node.source,
901
+ [...issueNodes, ...dirNodes]
902
+ );
903
+ if (sectionNodes.length > 0) {
904
+ const idx = nodes.indexOf(node);
905
+ if (idx >= 0) nodes.splice(idx, 1);
906
+ expandedIssues.push(...sectionNodes);
907
+ }
908
+ }
909
+ nodes.push(...expandedIssues);
910
+ const dirNames = dirNodes.map((d) => d.title.replace(/\/$/, ""));
911
+ for (const node of issueNodes) {
912
+ for (let i = 0; i < dirNames.length; i++) {
913
+ const dir = dirNames[i];
914
+ const dirNode = dirNodes[i];
915
+ if (!dir || !dirNode) continue;
916
+ if (node.rawContent && (node.rawContent.includes(`${dir}/`) || node.rawContent.includes(`\`${dir}\``) || node.rawContent.toLowerCase().includes(dir.toLowerCase()))) {
917
+ node.connections.push({ to: dirNode.id, type: "references", description: `References ${dir}/`, source: "inferred" });
918
+ }
919
+ }
920
+ }
921
+ return nodes;
922
+ }
923
+ function extractClusters(nodes, config) {
924
+ const configClusters = new Map(
925
+ Object.entries(config.clusters).map(([id, c]) => [id, { id, ...c }])
926
+ );
927
+ const palette = [
928
+ "#E8A838",
929
+ "#4A9CC8",
930
+ "#8CB050",
931
+ "#C07840",
932
+ "#D4A050",
933
+ "#5A98A8",
934
+ "#9A8A78",
935
+ "#C04040",
936
+ "#A86FDF",
937
+ "#39FF14",
938
+ "#FF6B6B",
939
+ "#4ECDC4"
940
+ ];
941
+ let colorIdx = 0;
942
+ const seenIds = /* @__PURE__ */ new Set();
943
+ for (const node of nodes) {
944
+ if (!seenIds.has(node.cluster)) {
945
+ seenIds.add(node.cluster);
946
+ if (!configClusters.has(node.cluster)) {
947
+ configClusters.set(node.cluster, {
948
+ id: node.cluster,
949
+ name: node.cluster.split(/[-_]/).map((w) => w.length <= 3 ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)).join(" "),
950
+ color: palette[colorIdx % palette.length]
951
+ });
952
+ colorIdx++;
953
+ }
954
+ }
955
+ }
956
+ return [...configClusters.values()];
957
+ }
958
+ async function loadConfig(source, env, cache) {
959
+ try {
960
+ const raw = await fetchFile(
961
+ source,
962
+ source.path ? `${source.path}/config.yaml` : "content/config.yaml",
963
+ env,
964
+ cache
965
+ );
966
+ const parsed = yaml.parse(raw);
967
+ return { ...DEFAULT_CONFIG, ...parsed, source };
968
+ } catch {
969
+ return { ...DEFAULT_CONFIG, source };
970
+ }
971
+ }
972
+
973
+ export { DEFAULT_CONFIG, GITHUB_ENDPOINT_PATTERNS, GitHubApiError, NotModifiedError, RateLimitError, extractClusters, extractIssueRefs, fetchBranches, fetchCommits, fetchFile, fetchFiles, fetchIssues, fetchPullRequests, fetchReleases, fetchRepoMetadata, fetchTree, filterAccessWithheld, isAccessWithheld, issueToNode, loadAuthoredContent, loadConfig, loadRepoContent, parseAccessLabel, parseMarkdownFile, renderSafeMarkdown, splitIntoSections, treeToNodes };