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 +224 -6
- package/package.json +1 -1
- package/template/_package.json +4 -0
- package/template/app/[...slug]/page.tsx +1 -1
- package/template/app/api/llms/[...slug]/route.ts +34 -0
- package/template/app/api/raw/route.ts +34 -1
- package/template/app/api-reference/[[...slug]]/page.tsx +65 -22
- package/template/app/globals.css +1 -2
- package/template/app/llms.txt/route.ts +36 -0
- package/template/components/ApiReference/ApiSidebar.tsx +66 -5
- package/template/components/Nav/TopNav.tsx +1 -1
- package/template/components/Nav/VersionSelector.tsx +8 -0
- package/template/components/mdx/Callout.tsx +3 -1
- package/template/components/mdx/LLMIgnore.tsx +4 -0
- package/template/components/mdx/LLMOnly.tsx +4 -0
- package/template/components/mdx/index.tsx +4 -0
- package/template/lib/api-reference.ts +8 -9
- package/template/lib/config-types.ts +12 -1
- package/template/lib/config.ts +1 -1
- package/template/lib/mdx.ts +13 -0
- package/template/lib/nav.ts +72 -3
- package/template/lib/versions.ts +62 -26
- package/template/proxy.ts +10 -1
- package/template/scripts/build-offline.sh +43 -5
- package/template/scripts/build-pdf.sh +3 -3
- package/template/scripts/build-search-index.ts +7 -1
- package/template/scripts/generate-master-pdf.ts +9 -17
- package/template/scripts/generate-pdfs.ts +26 -12
- package/template/public/2f-logo.png +0 -0
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
|
|
6
|
-
import
|
|
7
|
-
import
|
|
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/
|
|
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
|
|
141
|
-
var
|
|
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
package/template/_package.json
CHANGED
|
@@ -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,
|
|
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 =
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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:
|
|
72
|
-
const base = versionId ? `/api-reference/${versionId}` : "/api-reference"
|
|
82
|
+
const { versionId, rest: afterVersion } = parseVersionFromSlug(rawSlug, versionIds)
|
|
73
83
|
|
|
74
|
-
const resolved =
|
|
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(
|
|
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={
|
|
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>
|
package/template/app/globals.css
CHANGED
|
@@ -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
|