camelmind 0.2.1 → 0.2.3

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/dist/index.js CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
- import { fileURLToPath as fileURLToPath2 } from "url";
6
- import path2 from "path";
7
- import fs2 from "fs";
5
+ import { fileURLToPath as fileURLToPath3 } from "url";
6
+ import path3 from "path";
7
+ import fs3 from "fs";
8
8
 
9
9
  // src/commands/init.ts
10
10
  import path from "path";
@@ -135,11 +135,229 @@ async function init(projectName, options) {
135
135
  `);
136
136
  }
137
137
 
138
- // src/index.ts
138
+ // src/commands/update.ts
139
+ import path2 from "path";
140
+ import { fileURLToPath as fileURLToPath2 } from "url";
141
+ import { execSync as execSync2 } from "child_process";
142
+ import fs2 from "fs-extra";
143
+ import chalk2 from "chalk";
144
+ import ora2 from "ora";
145
+ import prompts2 from "prompts";
139
146
  var __dirname2 = path2.dirname(fileURLToPath2(import.meta.url));
140
- var pkgPath = path2.join(__dirname2, "../package.json");
141
- var pkg = JSON.parse(fs2.readFileSync(pkgPath, "utf-8"));
147
+ var TEMPLATE_DIR2 = path2.join(__dirname2, "../template");
148
+ var FRAMEWORK_DIRS = ["app", "components", "lib", "public", "scripts"];
149
+ var FRAMEWORK_FILES = [
150
+ "next.config.ts",
151
+ "postcss.config.mjs",
152
+ "tsconfig.json",
153
+ "eslint.config.mjs",
154
+ "proxy.ts"
155
+ ];
156
+ async function walkFiles(dir) {
157
+ if (!await fs2.pathExists(dir)) return [];
158
+ const entries = await fs2.readdir(dir, { withFileTypes: true });
159
+ const files = [];
160
+ for (const entry of entries) {
161
+ if (entry.name === "node_modules" || entry.name === ".next" || entry.name === ".git") {
162
+ continue;
163
+ }
164
+ const fullPath = path2.join(dir, entry.name);
165
+ if (entry.isDirectory()) {
166
+ files.push(...await walkFiles(fullPath));
167
+ } else if (entry.isFile()) {
168
+ files.push(fullPath);
169
+ }
170
+ }
171
+ return files;
172
+ }
173
+ function mergePackageJson(current, template) {
174
+ return {
175
+ ...current,
176
+ scripts: {
177
+ ...template.scripts,
178
+ ...current.scripts
179
+ },
180
+ dependencies: {
181
+ ...current.dependencies,
182
+ ...template.dependencies
183
+ },
184
+ devDependencies: {
185
+ ...current.devDependencies,
186
+ ...template.devDependencies
187
+ },
188
+ overrides: {
189
+ ...current.overrides,
190
+ ...template.overrides
191
+ }
192
+ };
193
+ }
194
+ async function copyWithBackup(source, target, backupRoot, projectRoot) {
195
+ if (await fs2.pathExists(target)) {
196
+ await backupFile(target, backupRoot, projectRoot);
197
+ }
198
+ await fs2.ensureDir(path2.dirname(target));
199
+ await fs2.copy(source, target, { overwrite: true });
200
+ }
201
+ async function backupFile(file, backupRoot, projectRoot) {
202
+ const rel = path2.relative(projectRoot, file);
203
+ await fs2.ensureDir(path2.dirname(path2.join(backupRoot, rel)));
204
+ await fs2.copy(file, path2.join(backupRoot, rel), { overwrite: true });
205
+ }
206
+ async function updatePackageJson(projectRoot, backupRoot, dryRun) {
207
+ const projectPkgPath = path2.join(projectRoot, "package.json");
208
+ const templatePkgPath = path2.join(TEMPLATE_DIR2, "_package.json");
209
+ const current = await fs2.readJson(projectPkgPath);
210
+ const template = await fs2.readJson(templatePkgPath);
211
+ const merged = mergePackageJson(current, template);
212
+ if (dryRun) return;
213
+ await backupFile(projectPkgPath, backupRoot, projectRoot);
214
+ await fs2.writeJson(projectPkgPath, merged, { spaces: 2 });
215
+ await fs2.appendFile(projectPkgPath, "\n");
216
+ }
217
+ async function plannedTemplateFiles() {
218
+ const files = [];
219
+ for (const dir of FRAMEWORK_DIRS) {
220
+ const dirPath = path2.join(TEMPLATE_DIR2, dir);
221
+ const dirFiles = await walkFiles(dirPath);
222
+ files.push(...dirFiles.map((file) => path2.relative(TEMPLATE_DIR2, file)));
223
+ }
224
+ files.push(...FRAMEWORK_FILES);
225
+ return files.sort();
226
+ }
227
+ async function update(targetDir, options) {
228
+ const projectRoot = path2.resolve(process.cwd(), targetDir ?? ".");
229
+ const packageJsonPath = path2.join(projectRoot, "package.json");
230
+ const configPath = path2.join(projectRoot, "camelmind.config.ts");
231
+ if (!await fs2.pathExists(TEMPLATE_DIR2)) {
232
+ console.error(chalk2.red("Template directory not found in the installed CamelMind package."));
233
+ process.exit(1);
234
+ }
235
+ if (!await fs2.pathExists(packageJsonPath) || !await fs2.pathExists(configPath)) {
236
+ console.error(chalk2.red("Run this command from a CamelMind project root, or pass the project directory."));
237
+ process.exit(1);
238
+ }
239
+ const files = await plannedTemplateFiles();
240
+ const backupRoot = path2.join(
241
+ projectRoot,
242
+ ".camelmind-update-backup",
243
+ (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")
244
+ );
245
+ console.log(`
246
+ ${chalk2.hex("#D8A15B").bold("CamelMind update")}
247
+
248
+ Project: ${chalk2.cyan(projectRoot)}
249
+ Files: ${chalk2.cyan(String(files.length))} framework files + package.json
250
+ Backup: ${chalk2.cyan(path2.relative(projectRoot, backupRoot))}
251
+ `);
252
+ if (options.dryRun) {
253
+ console.log(chalk2.bold(" Dry run \u2014 files that would be refreshed:\n"));
254
+ for (const file of files) console.log(` ${file}`);
255
+ console.log(" package.json");
256
+ console.log("");
257
+ return;
258
+ }
259
+ if (!options.yes) {
260
+ const { proceed } = await prompts2(
261
+ {
262
+ type: "confirm",
263
+ name: "proceed",
264
+ message: "Refresh CamelMind framework files and merge package updates?",
265
+ initial: true
266
+ },
267
+ { onCancel: () => process.exit(0) }
268
+ );
269
+ if (!proceed) {
270
+ console.log(chalk2.gray("\n Update cancelled.\n"));
271
+ return;
272
+ }
273
+ }
274
+ const spinner = ora2("Refreshing CamelMind framework files...").start();
275
+ try {
276
+ for (const rel of files) {
277
+ await copyWithBackup(
278
+ path2.join(TEMPLATE_DIR2, rel),
279
+ path2.join(projectRoot, rel),
280
+ backupRoot,
281
+ projectRoot
282
+ );
283
+ }
284
+ await updatePackageJson(projectRoot, backupRoot, false);
285
+ spinner.succeed("CamelMind files refreshed");
286
+ } catch (err) {
287
+ spinner.fail("Update failed");
288
+ console.error(chalk2.red(`
289
+ ${String(err)}
290
+ `));
291
+ process.exit(1);
292
+ }
293
+ if (options.install) {
294
+ const installSpinner = ora2("Installing updated dependencies...").start();
295
+ try {
296
+ execSync2("npm install", {
297
+ cwd: projectRoot,
298
+ stdio: "ignore"
299
+ });
300
+ installSpinner.succeed("Dependencies installed");
301
+ } catch {
302
+ installSpinner.warn("Dependency install failed \u2014 run npm install inside your project");
303
+ }
304
+ }
305
+ console.log(`
306
+ ${chalk2.green("Done.")} Review the changes, then run:
307
+
308
+ ${chalk2.cyan("npm run lint")}
309
+ ${chalk2.cyan("npm run build")}
310
+
311
+ Backup saved in ${chalk2.cyan(path2.relative(projectRoot, backupRoot))}
312
+ `);
313
+ }
314
+
315
+ // src/commands/version.ts
316
+ import chalk3 from "chalk";
317
+ import ora3 from "ora";
318
+ async function checkVersion(currentVersion) {
319
+ const spinner = ora3("Checking latest version...").start();
320
+ let latestVersion;
321
+ try {
322
+ const res = await fetch("https://registry.npmjs.org/camelmind/latest");
323
+ if (!res.ok) throw new Error(`Registry responded with ${res.status}`);
324
+ const data = await res.json();
325
+ latestVersion = data.version;
326
+ spinner.stop();
327
+ } catch {
328
+ spinner.fail("Could not reach the npm registry");
329
+ console.log(`
330
+ Installed: ${chalk3.cyan(currentVersion)}
331
+ `);
332
+ return;
333
+ }
334
+ const [curMajor, curMinor, curPatch] = currentVersion.split(".").map(Number);
335
+ const [latMajor, latMinor, latPatch] = latestVersion.split(".").map(Number);
336
+ const isOutdated = latMajor > curMajor || latMajor === curMajor && latMinor > curMinor || latMajor === curMajor && latMinor === curMinor && latPatch > curPatch;
337
+ console.log(`
338
+ Installed ${chalk3.cyan(currentVersion)}
339
+ Latest ${isOutdated ? chalk3.yellow(latestVersion) : chalk3.green(latestVersion)}
340
+ `);
341
+ if (isOutdated) {
342
+ console.log(
343
+ ` ${chalk3.yellow("\u26A0")} A new version is available. To update an existing site, run:
344
+
345
+ ${chalk3.cyan("npx camelmind@latest update")}
346
+ `
347
+ );
348
+ } else {
349
+ console.log(` ${chalk3.green("\u2713")} You're up to date.
350
+ `);
351
+ }
352
+ }
353
+
354
+ // src/index.ts
355
+ var __dirname3 = path3.dirname(fileURLToPath3(import.meta.url));
356
+ var pkgPath = path3.join(__dirname3, "../package.json");
357
+ var pkg = JSON.parse(fs3.readFileSync(pkgPath, "utf-8"));
142
358
  var program = new Command();
143
359
  program.name("camelmind").description("CamelMind CLI \u2014 scaffold and manage documentation sites").version(pkg.version);
144
360
  program.command("init [project-name]").description("Create a new CamelMind documentation site").option("--no-install", "Skip installing npm dependencies").action(init);
361
+ program.command("version").description("Show installed version and check for updates").action(() => checkVersion(pkg.version));
362
+ program.command("update [project-dir]").description("Update an existing CamelMind site to the latest platform files").option("--no-install", "Skip installing npm dependencies").option("--dry-run", "Show what would change without writing files").option("-y, --yes", "Skip the confirmation prompt").action(update);
145
363
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "camelmind",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Create and manage CamelMind documentation sites",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,6 +25,10 @@
25
25
  "rehype-slug": "^6.0.0",
26
26
  "remark-gfm": "^4.0.1"
27
27
  },
28
+ "overrides": {
29
+ "js-yaml": "^4.2.0",
30
+ "postcss": "^8.5.10"
31
+ },
28
32
  "devDependencies": {
29
33
  "@tailwindcss/postcss": "^4",
30
34
  "@types/js-yaml": "^4.0.9",
@@ -71,7 +71,7 @@ export default async function DocPage({ params }: Props) {
71
71
  const currentVersion = versions.find((v) => v.id === versionId)
72
72
  const versionShowsApiRef = currentVersion ? (currentVersion.api_reference !== false) : true
73
73
  const apiRef = apiRefConfig?.enabled && versionShowsApiRef
74
- ? { label: apiRefConfig.navLabel ?? "API Reference", href: "/api-reference", roles: apiRefConfig.roles ?? [] }
74
+ ? { label: apiRefConfig.navLabel ?? "API Reference", href: versionId ? `/api-reference/${versionId}` : "/api-reference", roles: apiRefConfig.roles ?? [] }
75
75
  : null
76
76
 
77
77
  if (shouldRedirectToLogin(navEntry.roles, session)) {
@@ -0,0 +1,34 @@
1
+ import { NextRequest, NextResponse } from "next/server"
2
+ import { loadNav, getEntryBySlugFromConfig } from "@/lib/nav"
3
+ import { loadMdxFile, processForLLM } from "@/lib/mdx"
4
+ import { getConfig } from "@/lib/config"
5
+
6
+ export async function GET(
7
+ _req: NextRequest,
8
+ { params }: { params: Promise<{ slug: string[] }> }
9
+ ) {
10
+ const { slug: slugParts } = await params
11
+ const slug = "/" + slugParts.join("/")
12
+
13
+ const nav = loadNav()
14
+ const entry = getEntryBySlugFromConfig(nav, slug)
15
+
16
+ if (!entry) return new NextResponse("Not Found", { status: 404 })
17
+
18
+ if (entry.roles.length > 0) {
19
+ return new NextResponse("Unauthorized", { status: 401 })
20
+ }
21
+
22
+ const { source: rawSource } = loadMdxFile(entry.file)
23
+ const source = processForLLM(rawSource)
24
+ const config = getConfig()
25
+ const baseUrl = config.url.replace(/\/$/, "")
26
+
27
+ const directive =
28
+ config.llms?.directive ??
29
+ `For a complete documentation index, see ${baseUrl}/llms.txt. To read any public page as Markdown, append .md to the URL.`
30
+
31
+ return new NextResponse(`> ${directive}\n\n${source}`, {
32
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
33
+ })
34
+ }
@@ -1,8 +1,33 @@
1
1
  import { NextRequest, NextResponse } from "next/server"
2
2
  import fs from "fs"
3
3
  import path from "path"
4
- import { getSession } from "@/lib/auth"
4
+ import { getSession, hasAccess } from "@/lib/auth"
5
5
  import { isAuthEnabled } from "@/lib/config"
6
+ import { loadNav, flattenChildren } from "@/lib/nav"
7
+ import { loadVersions, getNavForVersion } from "@/lib/versions"
8
+ import type { NavConfig, NavEntry, NavChild } from "@/lib/nav-types"
9
+
10
+ function getAllNavEntries(nav: NavConfig): (NavEntry | NavChild)[] {
11
+ const entries: (NavEntry | NavChild)[] = []
12
+ for (const item of nav.nav) {
13
+ if ("dropdown" in item) {
14
+ for (const entry of (item.items ?? [])) {
15
+ entries.push(entry)
16
+ if (entry.section) entries.push(...flattenChildren(entry.section))
17
+ }
18
+ } else if ("file" in item) {
19
+ entries.push(item as NavEntry)
20
+ }
21
+ }
22
+ return entries
23
+ }
24
+
25
+ function getNavEntriesForFile(file: string): (NavEntry | NavChild)[] {
26
+ const normalized = path.normalize(file)
27
+ const configs: NavConfig[] = [loadNav()]
28
+ for (const v of loadVersions().versions) configs.push(getNavForVersion(v.id))
29
+ return configs.flatMap(getAllNavEntries).filter((e) => path.normalize(e.file) === normalized)
30
+ }
6
31
 
7
32
  export async function GET(req: NextRequest) {
8
33
  const session = await getSession()
@@ -24,6 +49,14 @@ export async function GET(req: NextRequest) {
24
49
  return new NextResponse("Forbidden", { status: 403 })
25
50
  }
26
51
 
52
+ // RBAC: file must appear in the nav and the user must satisfy at least one entry's roles
53
+ if (isAuthEnabled()) {
54
+ const navEntries = getNavEntriesForFile(file)
55
+ if (navEntries.length === 0 || !navEntries.some((e) => hasAccess(e.roles, session!.roles))) {
56
+ return new NextResponse("Forbidden", { status: 403 })
57
+ }
58
+ }
59
+
27
60
  if (!fs.existsSync(resolved)) {
28
61
  return new NextResponse("Not found", { status: 404 })
29
62
  }
@@ -2,7 +2,8 @@ import { notFound, redirect } from "next/navigation"
2
2
  import { getConfig, isAuthEnabled } from "@/lib/config"
3
3
  import { loadApiSpec } from "@/lib/api-reference"
4
4
  import { getSession, hasAccess } from "@/lib/auth"
5
- import { loadVersions, getNavForVersion, getApiSpecForVersion } from "@/lib/versions"
5
+ import { loadVersions, getNavForVersion, getApiReferenceForVersion } from "@/lib/versions"
6
+ import type { ResolvedTab } from "@/lib/versions"
6
7
  import { getSlugsFromConfig } from "@/lib/nav"
7
8
  import { TopNav } from "@/components/Nav/TopNav"
8
9
  import { ApiSidebar } from "@/components/ApiReference/ApiSidebar"
@@ -13,9 +14,6 @@ type Props = {
13
14
  params: Promise<{ slug?: string[] }>
14
15
  }
15
16
 
16
- // Detect if the first slug segment is a known version ID.
17
- // /api-reference/v2.0/applications/create → versionId="v2.0", rest=["applications","create"]
18
- // /api-reference/applications/create → versionId=null, rest=["applications","create"]
19
17
  function parseVersionFromSlug(
20
18
  slug: string[] | undefined,
21
19
  versionIds: string[]
@@ -36,24 +34,37 @@ export async function generateStaticParams() {
36
34
  const params: Array<{ slug: string[] | undefined }> = [{ slug: undefined }]
37
35
 
38
36
  for (const version of versions) {
39
- const resolved = getApiSpecForVersion(version.id, apiRef.spec, apiRef.languages)
37
+ const resolved = getApiReferenceForVersion(version.id, apiRef)
40
38
  if (!resolved) continue
41
39
 
42
- let spec
43
- try { spec = loadApiSpec(resolved.spec, resolved.languages) } catch { continue }
44
-
45
40
  params.push({ slug: [version.id] })
46
41
 
47
- for (const op of spec.allOperations) {
48
- params.push({ slug: [version.id, op.tagSlug, op.operationId] })
42
+ if (resolved.mode === "single") {
43
+ let spec
44
+ try { spec = loadApiSpec(resolved.file, resolved.languages) } catch { continue }
45
+ for (const op of spec.allOperations) {
46
+ params.push({ slug: [version.id, op.tagSlug, op.operationId] })
47
+ }
48
+ } else {
49
+ for (const tab of resolved.tabs) {
50
+ params.push({ slug: [version.id, tab.id] })
51
+ let spec
52
+ try { spec = loadApiSpec(tab.file, tab.languages) } catch { continue }
53
+ for (const op of spec.allOperations) {
54
+ params.push({ slug: [version.id, tab.id, op.tagSlug, op.operationId] })
55
+ }
56
+ }
49
57
  }
50
58
  }
51
59
 
52
- // Also enumerate no-version-prefix routes using the default spec
53
- let defaultSpec
54
- try { defaultSpec = loadApiSpec(apiRef.spec, apiRef.languages) } catch { return params }
55
- for (const op of defaultSpec.allOperations) {
56
- params.push({ slug: [op.tagSlug, op.operationId] })
60
+ // No-version-prefix routes using the default (first) spec
61
+ const defaultResolved = getApiReferenceForVersion(null, apiRef)
62
+ if (defaultResolved?.mode === "single") {
63
+ let defaultSpec
64
+ try { defaultSpec = loadApiSpec(defaultResolved.file, defaultResolved.languages) } catch { return params }
65
+ for (const op of defaultSpec.allOperations) {
66
+ params.push({ slug: [op.tagSlug, op.operationId] })
67
+ }
57
68
  }
58
69
 
59
70
  return params
@@ -68,14 +79,37 @@ export default async function ApiReferencePage({ params }: Props) {
68
79
  const { versions } = loadVersions()
69
80
  const versionIds = versions.map((v) => v.id)
70
81
 
71
- const { versionId, rest: slug } = parseVersionFromSlug(rawSlug, versionIds)
72
- const base = versionId ? `/api-reference/${versionId}` : "/api-reference"
82
+ const { versionId, rest: afterVersion } = parseVersionFromSlug(rawSlug, versionIds)
73
83
 
74
- const resolved = getApiSpecForVersion(versionId, apiRef.spec, apiRef.languages)
84
+ const resolved = getApiReferenceForVersion(versionId, apiRef)
75
85
  if (!resolved) return notFound()
76
86
 
87
+ // Resolve active tab and remaining slug segments
88
+ let activeTab: ResolvedTab | null = null
89
+ let slug: string[]
90
+
91
+ if (resolved.mode === "tabs") {
92
+ const tabIds = resolved.tabs.map((t) => t.id)
93
+ if (afterVersion.length && tabIds.includes(afterVersion[0])) {
94
+ activeTab = resolved.tabs.find((t) => t.id === afterVersion[0])!
95
+ slug = afterVersion.slice(1)
96
+ } else {
97
+ const versionBase = versionId ? `/api-reference/${versionId}` : "/api-reference"
98
+ redirect(`${versionBase}/${resolved.tabs[0].id}`)
99
+ }
100
+ } else {
101
+ slug = afterVersion
102
+ }
103
+
104
+ // Load spec for current tab or single spec
105
+ const specFile = resolved.mode === "tabs" ? activeTab!.file : resolved.file
106
+ const specLanguages = resolved.mode === "tabs" ? activeTab!.languages : resolved.languages
77
107
  let spec
78
- try { spec = loadApiSpec(resolved.spec, resolved.languages) } catch { return notFound() }
108
+ try { spec = loadApiSpec(specFile, specLanguages) } catch { return notFound() }
109
+
110
+ // Base URL: /api-reference[/versionId][/tabId]
111
+ const versionBase = versionId ? `/api-reference/${versionId}` : "/api-reference"
112
+ const base = resolved.mode === "tabs" ? `${versionBase}/${activeTab!.id}` : versionBase
79
113
 
80
114
  const session = await getSession()
81
115
  const authEnabled = isAuthEnabled()
@@ -105,6 +139,13 @@ export default async function ApiReferencePage({ params }: Props) {
105
139
  const apiRefProp = { label: apiRef.navLabel ?? "API Reference", href: "/api-reference", roles }
106
140
  const currentSlug = slug.length ? `${base}/${slug.join("/")}` : base
107
141
 
142
+ // Tab switcher props for sidebar
143
+ const sidebarTabs = resolved.mode === "tabs"
144
+ ? resolved.tabs.map((t) => ({ id: t.id, label: t.label, href: `${versionBase}/${t.id}` }))
145
+ : undefined
146
+ const activeTabId = activeTab?.id
147
+
148
+ // Overview page
108
149
  if (!slug.length) {
109
150
  return (
110
151
  <div className="flex flex-col h-screen">
@@ -115,12 +156,12 @@ export default async function ApiReferencePage({ params }: Props) {
115
156
  authEnabled={authEnabled}
116
157
  versions={versions}
117
158
  currentVersionId={versionId}
118
- currentSlug={base}
159
+ currentSlug={versionBase}
119
160
  versionSlugs={versionSlugs}
120
161
  apiRef={apiRefProp}
121
162
  />
122
163
  <div className="flex flex-1 overflow-hidden">
123
- <ApiSidebar spec={spec} currentSlug={base} base={base} />
164
+ <ApiSidebar spec={spec} currentSlug={base} base={base} tabs={sidebarTabs} activeTabId={activeTabId} />
124
165
  <main className="flex-1 overflow-y-auto bg-white dark:bg-gray-950">
125
166
  <ApiOverview spec={spec} base={base} />
126
167
  </main>
@@ -129,12 +170,14 @@ export default async function ApiReferencePage({ params }: Props) {
129
170
  )
130
171
  }
131
172
 
173
+ // Tag-only slug → redirect to first endpoint
132
174
  if (slug.length === 1) {
133
175
  const tag = spec.tags.find((t) => t.slug === slug[0])
134
176
  if (!tag || !tag.operations.length) return notFound()
135
177
  redirect(`${base}/${tag.slug}/${tag.operations[0].operationId}`)
136
178
  }
137
179
 
180
+ // Endpoint page
138
181
  const [tagSlug, operationId] = slug
139
182
  const op = spec.allOperations.find(
140
183
  (o) => o.tagSlug === tagSlug && o.operationId === operationId
@@ -155,7 +198,7 @@ export default async function ApiReferencePage({ params }: Props) {
155
198
  apiRef={apiRefProp}
156
199
  />
157
200
  <div className="flex flex-1 overflow-hidden">
158
- <ApiSidebar spec={spec} currentSlug={currentSlug} base={base} />
201
+ <ApiSidebar spec={spec} currentSlug={currentSlug} base={base} tabs={sidebarTabs} activeTabId={activeTabId} />
159
202
  <main className="flex-1 overflow-hidden bg-white dark:bg-gray-950 flex">
160
203
  <EndpointPage operation={op} spec={spec} />
161
204
  </main>
@@ -90,13 +90,12 @@
90
90
  font-weight: 700;
91
91
  font-size: 0.85rem;
92
92
  padding: 0.4rem 0.75rem;
93
- text-transform: uppercase;
94
93
  letter-spacing: 0.04em;
95
94
  color: #1f1f1f;
96
95
  }
97
96
 
98
97
  .callout-body {
99
- padding: 0.75rem;
98
+ padding: 0.5rem 0.75rem 0.75rem;
100
99
  font-size: 0.95rem;
101
100
  color: #333;
102
101
  }
@@ -0,0 +1,36 @@
1
+ import { loadNav, getAllPublicEntries } from "@/lib/nav"
2
+ import { loadFrontmatterOnly } from "@/lib/mdx"
3
+ import { getConfig } from "@/lib/config"
4
+
5
+ export const revalidate = 3600
6
+
7
+ export async function GET() {
8
+ const config = getConfig()
9
+
10
+ if (!config.llms?.enabled) {
11
+ return new Response("Not Found", { status: 404 })
12
+ }
13
+
14
+ const nav = loadNav()
15
+ const entries = getAllPublicEntries(nav)
16
+ const baseUrl = config.url.replace(/\/$/, "")
17
+
18
+ const lines = entries.map((entry) => {
19
+ const frontmatter = loadFrontmatterOnly(entry.file)
20
+ const desc = frontmatter.description ?? frontmatter.title
21
+ return `- [${frontmatter.title}](${baseUrl}${entry.slug}): ${desc}`
22
+ })
23
+
24
+ const body = [
25
+ `# ${config.title}`,
26
+ "",
27
+ ...(config.tagline ? [`> ${config.tagline}`, ""] : []),
28
+ "## Docs",
29
+ "",
30
+ ...lines,
31
+ ].join("\n")
32
+
33
+ return new Response(body, {
34
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
35
+ })
36
+ }
@@ -1,19 +1,84 @@
1
1
  "use client"
2
2
 
3
+ import { useState, useRef, useEffect } from "react"
3
4
  import Link from "next/link"
5
+ import { ChevronDown } from "lucide-react"
4
6
  import type { ParsedSpec } from "@/lib/api-types"
5
7
  import { MethodBadge } from "./MethodBadge"
6
8
 
9
+ type SidebarTab = {
10
+ id: string
11
+ label: string
12
+ href: string
13
+ }
14
+
7
15
  type Props = {
8
16
  spec: ParsedSpec
9
17
  currentSlug: string
10
18
  base?: string
19
+ tabs?: SidebarTab[]
20
+ activeTabId?: string
11
21
  }
12
22
 
13
- export function ApiSidebar({ spec, currentSlug, base = "/api-reference" }: Props) {
23
+ export function ApiSidebar({ spec, currentSlug, base = "/api-reference", tabs, activeTabId }: Props) {
24
+ const [dropdownOpen, setDropdownOpen] = useState(false)
25
+ const dropdownRef = useRef<HTMLDivElement>(null)
26
+ const activeTab = tabs?.find((t) => t.id === activeTabId)
27
+
28
+ useEffect(() => {
29
+ function handleClickOutside(e: MouseEvent) {
30
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
31
+ setDropdownOpen(false)
32
+ }
33
+ }
34
+ if (dropdownOpen) document.addEventListener("mousedown", handleClickOutside)
35
+ return () => document.removeEventListener("mousedown", handleClickOutside)
36
+ }, [dropdownOpen])
37
+
14
38
  return (
15
39
  <aside className="hidden md:block w-64 shrink-0 border-r border-gray-200 dark:border-gray-800 overflow-y-auto bg-white dark:bg-gray-950">
16
40
  <div className="px-4 py-4">
41
+
42
+ {/* Spec dropdown */}
43
+ {tabs && tabs.length > 1 && (
44
+ <div ref={dropdownRef} className="relative mb-4">
45
+ <button
46
+ onClick={() => setDropdownOpen((o) => !o)}
47
+ className="w-full flex items-center justify-between px-3 py-2 text-xs font-medium rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
48
+ >
49
+ <span style={{ color: "var(--sf-active)" }}>{activeTab?.label ?? "Select API"}</span>
50
+ <ChevronDown
51
+ size={12}
52
+ className={`ml-2 shrink-0 text-gray-400 transition-transform ${dropdownOpen ? "rotate-180" : ""}`}
53
+ />
54
+ </button>
55
+
56
+ {dropdownOpen && (
57
+ <div className="absolute top-full left-0 right-0 mt-1 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-lg z-10 py-1">
58
+ {tabs.map((tab) => {
59
+ const isActive = tab.id === activeTabId
60
+ return (
61
+ <Link
62
+ key={tab.id}
63
+ href={tab.href}
64
+ onClick={() => setDropdownOpen(false)}
65
+ style={isActive ? { color: "var(--sf-active)" } : {}}
66
+ className={`flex items-center justify-between px-3 py-2 text-xs transition-colors ${
67
+ isActive
68
+ ? "font-medium bg-black/5 dark:bg-white/10"
69
+ : "text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
70
+ }`}
71
+ >
72
+ <span>{tab.label}</span>
73
+ {isActive && <span className="text-gray-400">✓</span>}
74
+ </Link>
75
+ )
76
+ })}
77
+ </div>
78
+ )}
79
+ </div>
80
+ )}
81
+
17
82
  {/* Overview link */}
18
83
  <div className="mb-4">
19
84
  <Link
@@ -32,17 +97,13 @@ export function ApiSidebar({ spec, currentSlug, base = "/api-reference" }: Props
32
97
  {/* Tags */}
33
98
  {spec.tags.map((tag) => (
34
99
  <div key={tag.slug} className="mb-4">
35
- {/* Tag header — ALL CAPS */}
36
100
  <p className="px-2 pb-1 text-xs font-semibold uppercase tracking-widest text-gray-400 dark:text-gray-500 select-none">
37
101
  {tag.name}
38
102
  </p>
39
-
40
- {/* Operations */}
41
103
  <ul className="space-y-0.5">
42
104
  {tag.operations.map((op) => {
43
105
  const href = `${base}/${tag.slug}/${op.operationId}`
44
106
  const isActive = currentSlug === href
45
-
46
107
  return (
47
108
  <li key={op.operationId}>
48
109
  <Link