camelmind 0.2.0 → 0.2.2
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 +40 -0
- package/package.json +1 -1
- 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
|
@@ -135,6 +135,45 @@ async function init(projectName, options) {
|
|
|
135
135
|
`);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
// src/commands/version.ts
|
|
139
|
+
import chalk2 from "chalk";
|
|
140
|
+
import ora2 from "ora";
|
|
141
|
+
async function checkVersion(currentVersion) {
|
|
142
|
+
const spinner = ora2("Checking latest version...").start();
|
|
143
|
+
let latestVersion;
|
|
144
|
+
try {
|
|
145
|
+
const res = await fetch("https://registry.npmjs.org/camelmind/latest");
|
|
146
|
+
if (!res.ok) throw new Error(`Registry responded with ${res.status}`);
|
|
147
|
+
const data = await res.json();
|
|
148
|
+
latestVersion = data.version;
|
|
149
|
+
spinner.stop();
|
|
150
|
+
} catch {
|
|
151
|
+
spinner.fail("Could not reach the npm registry");
|
|
152
|
+
console.log(`
|
|
153
|
+
Installed: ${chalk2.cyan(currentVersion)}
|
|
154
|
+
`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const [curMajor, curMinor, curPatch] = currentVersion.split(".").map(Number);
|
|
158
|
+
const [latMajor, latMinor, latPatch] = latestVersion.split(".").map(Number);
|
|
159
|
+
const isOutdated = latMajor > curMajor || latMajor === curMajor && latMinor > curMinor || latMajor === curMajor && latMinor === curMinor && latPatch > curPatch;
|
|
160
|
+
console.log(`
|
|
161
|
+
Installed ${chalk2.cyan(currentVersion)}
|
|
162
|
+
Latest ${isOutdated ? chalk2.yellow(latestVersion) : chalk2.green(latestVersion)}
|
|
163
|
+
`);
|
|
164
|
+
if (isOutdated) {
|
|
165
|
+
console.log(
|
|
166
|
+
` ${chalk2.yellow("\u26A0")} A new version is available. Run:
|
|
167
|
+
|
|
168
|
+
${chalk2.cyan("npm install -g camelmind@latest")}
|
|
169
|
+
`
|
|
170
|
+
);
|
|
171
|
+
} else {
|
|
172
|
+
console.log(` ${chalk2.green("\u2713")} You're up to date.
|
|
173
|
+
`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
138
177
|
// src/index.ts
|
|
139
178
|
var __dirname2 = path2.dirname(fileURLToPath2(import.meta.url));
|
|
140
179
|
var pkgPath = path2.join(__dirname2, "../package.json");
|
|
@@ -142,4 +181,5 @@ var pkg = JSON.parse(fs2.readFileSync(pkgPath, "utf-8"));
|
|
|
142
181
|
var program = new Command();
|
|
143
182
|
program.name("camelmind").description("CamelMind CLI \u2014 scaffold and manage documentation sites").version(pkg.version);
|
|
144
183
|
program.command("init [project-name]").description("Create a new CamelMind documentation site").option("--no-install", "Skip installing npm dependencies").action(init);
|
|
184
|
+
program.command("version").description("Show installed version and check for updates").action(() => checkVersion(pkg.version));
|
|
145
185
|
program.parse();
|
package/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
|
|
@@ -132,7 +132,7 @@ export function TopNav({ nav, userRoles, userName, authEnabled = false, versions
|
|
|
132
132
|
currentVersionId={currentVersionId}
|
|
133
133
|
currentSlug={currentSlug}
|
|
134
134
|
versionSlugs={versionSlugs}
|
|
135
|
-
isLoggedIn={!!userName}
|
|
135
|
+
isLoggedIn={!!userName || !authEnabled}
|
|
136
136
|
/>
|
|
137
137
|
{authEnabled && (
|
|
138
138
|
userName ? (
|
|
@@ -21,6 +21,14 @@ export function VersionSelector({ versions, currentVersionId, currentSlug, versi
|
|
|
21
21
|
|
|
22
22
|
function navigateToVersion(targetId: string) {
|
|
23
23
|
setOpen(false)
|
|
24
|
+
|
|
25
|
+
// API reference pages use a different URL scheme (/api-reference/[version]/...)
|
|
26
|
+
// so navigate directly to the target version's API reference overview
|
|
27
|
+
if (currentSlug.startsWith("/api-reference")) {
|
|
28
|
+
router.push(`/api-reference/${targetId}`)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
const bare = currentVersionId
|
|
25
33
|
? currentSlug.replace(new RegExp(`^/${currentVersionId}`), "") || "/"
|
|
26
34
|
: currentSlug
|
|
@@ -23,10 +23,12 @@ const defaultIcons: Record<CalloutType, string> = {
|
|
|
23
23
|
export function Callout({
|
|
24
24
|
type = "note",
|
|
25
25
|
icon,
|
|
26
|
+
title,
|
|
26
27
|
children,
|
|
27
28
|
}: {
|
|
28
29
|
type?: CalloutType
|
|
29
30
|
icon?: string
|
|
31
|
+
title?: string
|
|
30
32
|
children: React.ReactNode
|
|
31
33
|
}) {
|
|
32
34
|
const iconName = icon ?? defaultIcons[type]
|
|
@@ -35,7 +37,7 @@ export function Callout({
|
|
|
35
37
|
<div className={`callout ${type} my-4`}>
|
|
36
38
|
<div className="callout-header flex items-center gap-1.5">
|
|
37
39
|
<Icon name={iconName} size={13} />
|
|
38
|
-
{labels[type]}
|
|
40
|
+
{title ?? labels[type]}
|
|
39
41
|
</div>
|
|
40
42
|
<div className="callout-body">{children}</div>
|
|
41
43
|
</div>
|
|
@@ -3,6 +3,8 @@ import { Steps, Step } from "./Steps"
|
|
|
3
3
|
import { Tabs, Tab } from "./Tabs"
|
|
4
4
|
import { Icon } from "./Icon"
|
|
5
5
|
import { Details } from "./Details"
|
|
6
|
+
import { LLMOnly } from "./LLMOnly"
|
|
7
|
+
import { LLMIgnore } from "./LLMIgnore"
|
|
6
8
|
import { CodeBlock } from "@/components/Code/CodeBlock"
|
|
7
9
|
|
|
8
10
|
export const mdxComponents = {
|
|
@@ -13,6 +15,8 @@ export const mdxComponents = {
|
|
|
13
15
|
Tab,
|
|
14
16
|
Icon,
|
|
15
17
|
Details,
|
|
18
|
+
LLMOnly,
|
|
19
|
+
LLMIgnore,
|
|
16
20
|
pre: ({ children }: { children: React.ReactNode }) => {
|
|
17
21
|
// If the child code block has a language class, let CodeBlock handle it
|
|
18
22
|
const child = children as React.ReactElement<{ className?: string }>
|
|
@@ -15,22 +15,21 @@ import type {
|
|
|
15
15
|
HttpMethod,
|
|
16
16
|
} from "./api-types"
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
let _cachedLanguages: string | null = null
|
|
18
|
+
const _specCache = new Map<string, ParsedSpec>()
|
|
20
19
|
|
|
21
20
|
export function loadApiSpec(specPath: string, languages?: string[]): ParsedSpec {
|
|
22
|
-
const
|
|
23
|
-
|
|
21
|
+
const cacheKey = `${specPath}|${JSON.stringify(languages ?? null)}`
|
|
22
|
+
const cached = _specCache.get(cacheKey)
|
|
23
|
+
if (cached) return cached
|
|
24
24
|
const raw = fs.readFileSync(path.join(process.cwd(), specPath), "utf-8")
|
|
25
25
|
const doc = yaml.load(raw) as Record<string, unknown>
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return
|
|
26
|
+
const parsed = parseOpenApi(doc, languages)
|
|
27
|
+
_specCache.set(cacheKey, parsed)
|
|
28
|
+
return parsed
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export function clearApiCache() {
|
|
32
|
-
|
|
33
|
-
_cachedLanguages = null
|
|
32
|
+
_specCache.clear()
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
function slugify(str: string): string {
|
|
@@ -22,14 +22,24 @@ export type SiteFeatures = {
|
|
|
22
22
|
showFeedbackWidget?: boolean
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export type ApiSpecEntry = {
|
|
26
|
+
label: string
|
|
27
|
+
file: string
|
|
28
|
+
}
|
|
29
|
+
|
|
25
30
|
export type ApiReferenceConfig = {
|
|
26
31
|
enabled: boolean
|
|
27
|
-
|
|
32
|
+
specs: Record<string, ApiSpecEntry>
|
|
28
33
|
navLabel?: string
|
|
29
34
|
languages?: string[]
|
|
30
35
|
roles?: string[]
|
|
31
36
|
}
|
|
32
37
|
|
|
38
|
+
export type LlmsConfig = {
|
|
39
|
+
enabled?: boolean
|
|
40
|
+
directive?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
33
43
|
export type CamelMindConfig = {
|
|
34
44
|
title: string
|
|
35
45
|
tagline: string
|
|
@@ -43,4 +53,5 @@ export type CamelMindConfig = {
|
|
|
43
53
|
}
|
|
44
54
|
site?: SiteFeatures
|
|
45
55
|
apiReference?: ApiReferenceConfig
|
|
56
|
+
llms?: LlmsConfig
|
|
46
57
|
}
|
package/template/lib/config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import siteConfig from "@/camelmind.config"
|
|
2
2
|
import type { AuthConfig, CamelMindConfig } from "./config-types"
|
|
3
3
|
|
|
4
|
-
export type { AuthConfig, AuthProvider, CamelMindConfig, OidcConfig } from "./config-types"
|
|
4
|
+
export type { AuthConfig, AuthProvider, CamelMindConfig, LlmsConfig, OidcConfig } from "./config-types"
|
|
5
5
|
|
|
6
6
|
let _config: CamelMindConfig | null = null
|
|
7
7
|
|
package/template/lib/mdx.ts
CHANGED
|
@@ -44,6 +44,19 @@ function extractToc(source: string): TocEntry[] {
|
|
|
44
44
|
return entries
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
export function processForLLM(source: string): string {
|
|
48
|
+
let result = source.replace(/<LLMIgnore>[\s\S]*?<\/LLMIgnore>/g, "")
|
|
49
|
+
result = result.replace(/<LLMOnly>([\s\S]*?)<\/LLMOnly>/g, "$1")
|
|
50
|
+
return result.replace(/\n{3,}/g, "\n\n").trim()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function loadFrontmatterOnly(filePath: string): FrontMatter {
|
|
54
|
+
const fullPath = path.join(process.cwd(), filePath)
|
|
55
|
+
const raw = fs.readFileSync(fullPath, "utf-8")
|
|
56
|
+
const { data } = matter(raw)
|
|
57
|
+
return data as FrontMatter
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
export function loadMdxFile(filePath: string): DocContent {
|
|
48
61
|
const fullPath = path.join(process.cwd(), filePath)
|
|
49
62
|
const raw = fs.readFileSync(fullPath, "utf-8")
|
package/template/lib/nav.ts
CHANGED
|
@@ -8,11 +8,59 @@ export { isNavGroup } from "./nav-types"
|
|
|
8
8
|
|
|
9
9
|
let _navCache: NavConfig | null = null
|
|
10
10
|
|
|
11
|
+
function stripPrefix(slug: string, prefix: string): string {
|
|
12
|
+
if (slug.startsWith(prefix + "/")) return slug.slice(prefix.length)
|
|
13
|
+
if (slug === prefix) return "/"
|
|
14
|
+
return slug
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function stripNavPrefix(nav: NavConfig, prefix: string): NavConfig {
|
|
18
|
+
const s = (slug: string) => stripPrefix(slug, prefix)
|
|
19
|
+
|
|
20
|
+
const walkChildren = (children: NavChild[]): NavChild[] =>
|
|
21
|
+
children.map((c) => ({
|
|
22
|
+
...c,
|
|
23
|
+
slug: s(c.slug),
|
|
24
|
+
children: c.children ? walkChildren(c.children) : undefined,
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
const walkEntries = (entries: NavEntry[]): NavEntry[] =>
|
|
28
|
+
entries.map((e) => ({
|
|
29
|
+
...e,
|
|
30
|
+
slug: s(e.slug),
|
|
31
|
+
section: e.section ? walkChildren(e.section) : undefined,
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
nav: nav.nav.map((item) => {
|
|
36
|
+
if ("dropdown" in item) {
|
|
37
|
+
const group = item as NavGroup
|
|
38
|
+
return {
|
|
39
|
+
...group,
|
|
40
|
+
...(group.slug ? { slug: s(group.slug) } : {}),
|
|
41
|
+
items: group.items ? walkEntries(group.items) : undefined,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const entry = item as NavEntry
|
|
45
|
+
return { ...entry, slug: s(entry.slug) }
|
|
46
|
+
}),
|
|
47
|
+
} as NavConfig
|
|
48
|
+
}
|
|
49
|
+
|
|
11
50
|
export function loadNav(): NavConfig {
|
|
12
51
|
if (process.env.NODE_ENV !== "development" && _navCache) return _navCache
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
52
|
+
|
|
53
|
+
// Derive the unversioned nav from the first stable version in versions.yml,
|
|
54
|
+
// stripping the version prefix from all slugs so /v2/foo becomes /foo.
|
|
55
|
+
const versionsPath = path.join(process.cwd(), "versions.yml")
|
|
56
|
+
const { versions } = yaml.load(fs.readFileSync(versionsPath, "utf-8")) as {
|
|
57
|
+
versions: { id: string; stable: boolean; nav: string }[]
|
|
58
|
+
}
|
|
59
|
+
const latest = versions.find((v) => v.stable) ?? versions[0]
|
|
60
|
+
const navPath = path.join(process.cwd(), latest.nav)
|
|
61
|
+
const nav = yaml.load(fs.readFileSync(navPath, "utf-8")) as NavConfig
|
|
62
|
+
|
|
63
|
+
_navCache = stripNavPrefix(nav, `/${latest.id}`)
|
|
16
64
|
return _navCache
|
|
17
65
|
}
|
|
18
66
|
|
|
@@ -123,3 +171,24 @@ export function getSectionForSlugFromConfig(nav: NavConfig, slug: string): NavEn
|
|
|
123
171
|
export function getSectionForSlug(slug: string): NavEntry | null {
|
|
124
172
|
return getSectionForSlugFromConfig(loadNav(), slug)
|
|
125
173
|
}
|
|
174
|
+
|
|
175
|
+
export function getAllPublicEntries(nav: NavConfig): (NavEntry | NavChild)[] {
|
|
176
|
+
const entries: (NavEntry | NavChild)[] = []
|
|
177
|
+
for (const item of nav.nav) {
|
|
178
|
+
if ("dropdown" in item) {
|
|
179
|
+
const group = item as NavGroup
|
|
180
|
+
for (const entry of (group.items ?? [])) {
|
|
181
|
+
if (entry.roles.length === 0) entries.push(entry)
|
|
182
|
+
if (entry.section) {
|
|
183
|
+
for (const child of flattenChildren(entry.section)) {
|
|
184
|
+
if (child.roles.length === 0) entries.push(child)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} else if ("slug" in item) {
|
|
189
|
+
const entry = item as NavEntry
|
|
190
|
+
if (entry.roles.length === 0) entries.push(entry)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return entries
|
|
194
|
+
}
|
package/template/lib/versions.ts
CHANGED
|
@@ -3,20 +3,24 @@ import path from "path"
|
|
|
3
3
|
import yaml from "js-yaml"
|
|
4
4
|
import { loadNav } from "./nav"
|
|
5
5
|
import type { NavConfig } from "./nav-types"
|
|
6
|
+
import type { ApiReferenceConfig } from "./config-types"
|
|
6
7
|
|
|
7
|
-
export type
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
languages?: string[]
|
|
8
|
+
export type VersionApiReferenceTab = {
|
|
9
|
+
id: string
|
|
10
|
+
spec: string // key into camelmind.config.ts apiReference.specs
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
export type VersionApiReference =
|
|
14
|
+
| { spec: string } // single named spec
|
|
15
|
+
| { tabs: VersionApiReferenceTab[] } // multi-spec tabs
|
|
16
|
+
|
|
13
17
|
export type Version = {
|
|
14
18
|
id: string
|
|
15
19
|
label: string
|
|
16
20
|
stable: boolean
|
|
17
21
|
badge?: string
|
|
18
22
|
nav: string
|
|
19
|
-
// true
|
|
23
|
+
// false = disabled, omitted/true = use first spec in registry, object = explicit config
|
|
20
24
|
api_reference?: boolean | VersionApiReference
|
|
21
25
|
}
|
|
22
26
|
|
|
@@ -24,6 +28,18 @@ export type VersionsConfig = {
|
|
|
24
28
|
versions: Version[]
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
// Resolved output types
|
|
32
|
+
export type ResolvedTab = {
|
|
33
|
+
id: string
|
|
34
|
+
label: string
|
|
35
|
+
file: string
|
|
36
|
+
languages?: string[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ResolvedApiReference =
|
|
40
|
+
| { mode: "single"; label: string; file: string; languages?: string[] }
|
|
41
|
+
| { mode: "tabs"; tabs: ResolvedTab[] }
|
|
42
|
+
|
|
27
43
|
let _versionsCache: VersionsConfig | null = null
|
|
28
44
|
|
|
29
45
|
export function loadVersions(): VersionsConfig {
|
|
@@ -33,12 +49,52 @@ export function loadVersions(): VersionsConfig {
|
|
|
33
49
|
return _versionsCache
|
|
34
50
|
}
|
|
35
51
|
|
|
52
|
+
// Resolve which spec(s) to show for a given version.
|
|
53
|
+
// Resolution: versions.yml api_reference → apiReference.specs registry → first spec in registry
|
|
54
|
+
// Returns null if api_reference is explicitly disabled for this version.
|
|
55
|
+
export function getApiReferenceForVersion(
|
|
56
|
+
versionId: string | null,
|
|
57
|
+
config: ApiReferenceConfig
|
|
58
|
+
): ResolvedApiReference | null {
|
|
59
|
+
const { versions } = loadVersions()
|
|
60
|
+
|
|
61
|
+
let versionAr: boolean | VersionApiReference | undefined
|
|
62
|
+
if (versionId) {
|
|
63
|
+
const v = versions.find((v) => v.id === versionId)
|
|
64
|
+
versionAr = v?.api_reference
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (versionAr === false) return null
|
|
68
|
+
|
|
69
|
+
// Default: use first spec in registry
|
|
70
|
+
if (!versionAr || versionAr === true) {
|
|
71
|
+
const firstEntry = Object.entries(config.specs)[0]
|
|
72
|
+
if (!firstEntry) return null
|
|
73
|
+
const [, entry] = firstEntry
|
|
74
|
+
return { mode: "single", label: entry.label, file: entry.file, languages: config.languages }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Tabs mode
|
|
78
|
+
if ("tabs" in versionAr) {
|
|
79
|
+
const tabs: ResolvedTab[] = versionAr.tabs.flatMap((tab) => {
|
|
80
|
+
const entry = config.specs[tab.spec]
|
|
81
|
+
if (!entry) return []
|
|
82
|
+
return [{ id: tab.id, label: entry.label, file: entry.file, languages: config.languages }]
|
|
83
|
+
})
|
|
84
|
+
return tabs.length ? { mode: "tabs", tabs } : null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Single named spec
|
|
88
|
+
const entry = config.specs[versionAr.spec]
|
|
89
|
+
if (!entry) return null
|
|
90
|
+
return { mode: "single", label: entry.label, file: entry.file, languages: config.languages }
|
|
91
|
+
}
|
|
92
|
+
|
|
36
93
|
export function getVersionFromSlug(slug: string): string | null {
|
|
37
94
|
const { versions } = loadVersions()
|
|
38
95
|
for (const v of versions) {
|
|
39
96
|
if (slug.startsWith(`/${v.id}/`) || slug === `/${v.id}`) return v.id
|
|
40
97
|
}
|
|
41
|
-
// no version prefix = default (latest)
|
|
42
98
|
return null
|
|
43
99
|
}
|
|
44
100
|
|
|
@@ -57,30 +113,10 @@ export function getLatestVersion(): Version {
|
|
|
57
113
|
return versions[0]
|
|
58
114
|
}
|
|
59
115
|
|
|
60
|
-
// Resolve which OpenAPI spec to use for a given version, with site-level fallback.
|
|
61
|
-
// Returns null if api_reference is explicitly disabled for this version.
|
|
62
|
-
export function getApiSpecForVersion(
|
|
63
|
-
versionId: string | null,
|
|
64
|
-
siteSpec: string,
|
|
65
|
-
siteLanguages?: string[]
|
|
66
|
-
): { spec: string; languages?: string[] } | null {
|
|
67
|
-
if (!versionId) return { spec: siteSpec, languages: siteLanguages }
|
|
68
|
-
const { versions } = loadVersions()
|
|
69
|
-
const v = versions.find((v) => v.id === versionId)
|
|
70
|
-
if (!v) return { spec: siteSpec, languages: siteLanguages }
|
|
71
|
-
|
|
72
|
-
const ar = v.api_reference
|
|
73
|
-
if (ar === false) return null
|
|
74
|
-
if (!ar || ar === true) return { spec: siteSpec, languages: siteLanguages }
|
|
75
|
-
return { spec: ar.spec, languages: ar.languages ?? siteLanguages }
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Given a slug like /v1.9/getting-started/overview, return /getting-started/overview
|
|
79
116
|
export function stripVersionPrefix(slug: string, versionId: string): string {
|
|
80
117
|
return slug.replace(new RegExp(`^/${versionId}`), "") || "/"
|
|
81
118
|
}
|
|
82
119
|
|
|
83
|
-
// Given /getting-started/overview and a target version, return /v1.9/getting-started/overview
|
|
84
120
|
export function swapVersion(slug: string, currentVersionId: string | null, targetVersionId: string): string {
|
|
85
121
|
const bare = currentVersionId ? stripVersionPrefix(slug, currentVersionId) : slug
|
|
86
122
|
return `/${targetVersionId}${bare}`
|
package/template/proxy.ts
CHANGED
|
@@ -6,6 +6,14 @@ import { isAuthEnabled, isPrivateSite, isPublicPath } from "@/lib/config"
|
|
|
6
6
|
export async function proxy(req: NextRequest) {
|
|
7
7
|
const { pathname } = req.nextUrl
|
|
8
8
|
|
|
9
|
+
// Rewrite /<slug>.md to /api/llms/<slug> before auth runs.
|
|
10
|
+
// /api/llms is in publicPaths so the handler is always reachable.
|
|
11
|
+
if (pathname.endsWith(".md") && !pathname.startsWith("/api/")) {
|
|
12
|
+
const url = req.nextUrl.clone()
|
|
13
|
+
url.pathname = `/api/llms${pathname.slice(0, -3)}`
|
|
14
|
+
return NextResponse.rewrite(url)
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
// Auth disabled — all traffic passes through
|
|
10
18
|
if (!isAuthEnabled()) {
|
|
11
19
|
return NextResponse.next()
|
|
@@ -46,5 +54,6 @@ export async function proxy(req: NextRequest) {
|
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
export const config = {
|
|
49
|
-
|
|
57
|
+
// Exclude Next.js internals and common static asset extensions served from public/
|
|
58
|
+
matcher: ["/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff2?|ttf|eot|otf)).*)"],
|
|
50
59
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
set -euo pipefail
|
|
12
12
|
|
|
13
|
-
VERSION=${1:-
|
|
13
|
+
VERSION=${1:-latest}
|
|
14
14
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
15
15
|
OUT_DIR="$ROOT/out"
|
|
16
16
|
BUILDS_DIR="$ROOT/offline-builds"
|
|
@@ -21,6 +21,8 @@ SERVER_ROUTES=(
|
|
|
21
21
|
"app/api/raw/route.ts"
|
|
22
22
|
"app/api/download/route.ts"
|
|
23
23
|
"app/api/search/route.ts"
|
|
24
|
+
"app/api/feedback/route.ts"
|
|
25
|
+
"app/api/llms"
|
|
24
26
|
"app/api/auth"
|
|
25
27
|
)
|
|
26
28
|
|
|
@@ -48,7 +50,17 @@ restore_routes() {
|
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
# Always restore on exit (even on error)
|
|
51
|
-
|
|
53
|
+
PAGES_DIR="$ROOT/.pdf-pages"
|
|
54
|
+
|
|
55
|
+
cleanup() {
|
|
56
|
+
restore_routes
|
|
57
|
+
rm -rf "$PAGES_DIR"
|
|
58
|
+
# Kill the static server if still running
|
|
59
|
+
if [[ -n "${STATIC_PID:-}" ]]; then
|
|
60
|
+
kill "$STATIC_PID" 2>/dev/null || true
|
|
61
|
+
fi
|
|
62
|
+
}
|
|
63
|
+
trap cleanup EXIT
|
|
52
64
|
|
|
53
65
|
echo "▶ Building offline package for version: $VERSION"
|
|
54
66
|
|
|
@@ -69,10 +81,14 @@ OFFLINE_MODE=true TARGET_VERSION="$VERSION" npx next build
|
|
|
69
81
|
|
|
70
82
|
cat > "$OUT_DIR/launch.sh" <<'LAUNCHER'
|
|
71
83
|
#!/usr/bin/env bash
|
|
72
|
-
#
|
|
84
|
+
# CamelMind offline launcher (Mac / Linux)
|
|
85
|
+
cd "$(dirname "$0")"
|
|
73
86
|
PORT=8765
|
|
74
87
|
URL="http://localhost:$PORT/home/"
|
|
75
88
|
|
|
89
|
+
# Free the port if a previous server is still running
|
|
90
|
+
lsof -ti:$PORT 2>/dev/null | xargs kill 2>/dev/null || true
|
|
91
|
+
|
|
76
92
|
# Try Python 3 first (available on most systems without internet)
|
|
77
93
|
if command -v python3 &>/dev/null; then
|
|
78
94
|
echo "Starting local server on $URL"
|
|
@@ -113,7 +129,8 @@ chmod +x "$OUT_DIR/launch.sh"
|
|
|
113
129
|
|
|
114
130
|
cat > "$OUT_DIR/launch.bat" <<'LAUNCHER'
|
|
115
131
|
@echo off
|
|
116
|
-
REM
|
|
132
|
+
REM CamelMind offline launcher (Windows)
|
|
133
|
+
cd /D "%~dp0"
|
|
117
134
|
set PORT=8765
|
|
118
135
|
set URL=http://localhost:%PORT%/home/
|
|
119
136
|
|
|
@@ -180,7 +197,28 @@ NOTES
|
|
|
180
197
|
Do not redistribute without authorization.
|
|
181
198
|
EOF
|
|
182
199
|
|
|
183
|
-
# 4.
|
|
200
|
+
# 4. Generate PDF from the static export
|
|
201
|
+
echo " → Generating PDF..."
|
|
202
|
+
STATIC_PORT=8766
|
|
203
|
+
|
|
204
|
+
# Serve the static output on a temporary port
|
|
205
|
+
python3 -m http.server "$STATIC_PORT" --directory "$OUT_DIR" >/dev/null 2>&1 &
|
|
206
|
+
STATIC_PID=$!
|
|
207
|
+
|
|
208
|
+
# Wait up to 10s for the server to be ready
|
|
209
|
+
for i in $(seq 1 20); do
|
|
210
|
+
if curl -s -o /dev/null "http://localhost:$STATIC_PORT/home/"; then break; fi
|
|
211
|
+
sleep 0.5
|
|
212
|
+
done
|
|
213
|
+
|
|
214
|
+
npx tsx scripts/generate-pdfs.ts --port="$STATIC_PORT" --out="$PAGES_DIR" --no-auth
|
|
215
|
+
npx tsx scripts/generate-master-pdf.ts --version="$VERSION" --pages="$PAGES_DIR" --out="$BUILDS_DIR"
|
|
216
|
+
|
|
217
|
+
kill "$STATIC_PID" 2>/dev/null || true
|
|
218
|
+
unset STATIC_PID
|
|
219
|
+
rm -rf "$PAGES_DIR"
|
|
220
|
+
|
|
221
|
+
# 5. Zip the output
|
|
184
222
|
echo " → Packaging..."
|
|
185
223
|
mkdir -p "$BUILDS_DIR"
|
|
186
224
|
cd "$OUT_DIR"
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
set -euo pipefail
|
|
11
11
|
|
|
12
|
-
VERSION=${1:-
|
|
12
|
+
VERSION=${1:-latest}
|
|
13
13
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
14
14
|
PAGES_DIR="$ROOT/.pdf-pages"
|
|
15
15
|
PORT=3000
|
|
@@ -37,5 +37,5 @@ npx tsx scripts/generate-pdfs.ts --port="$PORT" --out="$PAGES_DIR"
|
|
|
37
37
|
echo " → Assembling master PDF..."
|
|
38
38
|
npx tsx scripts/generate-master-pdf.ts --version="$VERSION" --pages="$PAGES_DIR" --out="$ROOT/offline-builds"
|
|
39
39
|
|
|
40
|
-
SIZE=$(du -sh "$ROOT/offline-builds/
|
|
41
|
-
echo "✓ Done: offline-builds/
|
|
40
|
+
SIZE=$(du -sh "$ROOT/offline-builds/camelmind-${VERSION}.pdf" | cut -f1)
|
|
41
|
+
echo "✓ Done: offline-builds/camelmind-${VERSION}.pdf ($SIZE)"
|
|
@@ -10,9 +10,15 @@ import path from "path"
|
|
|
10
10
|
import matter from "gray-matter"
|
|
11
11
|
|
|
12
12
|
const ROOT = path.resolve(process.cwd())
|
|
13
|
-
const NAV_FILE = path.join(ROOT, "nav", "nav.yml")
|
|
14
13
|
const OUT_FILE = path.join(ROOT, "public", "search-index.json")
|
|
15
14
|
|
|
15
|
+
// Resolve the latest stable version's nav file from versions.yml
|
|
16
|
+
const { versions } = yaml.load(fs.readFileSync(path.join(ROOT, "versions.yml"), "utf-8")) as {
|
|
17
|
+
versions: { id: string; stable: boolean; nav: string }[]
|
|
18
|
+
}
|
|
19
|
+
const latestVersion = versions.find((v) => v.stable) ?? versions[0]
|
|
20
|
+
const NAV_FILE = path.join(ROOT, latestVersion.nav)
|
|
21
|
+
|
|
16
22
|
// Minimal YAML parser for our nav structure (avoids pulling in the full Next.js lib graph)
|
|
17
23
|
import yaml from "js-yaml"
|
|
18
24
|
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
import { PDFDocument, StandardFonts, rgb, PDFPage } from "pdf-lib"
|
|
13
13
|
import fs from "fs"
|
|
14
14
|
import path from "path"
|
|
15
|
+
import config from "../camelmind.config"
|
|
16
|
+
|
|
17
|
+
const SITE_TITLE = config.title
|
|
15
18
|
|
|
16
19
|
const ROOT = path.resolve(process.cwd())
|
|
17
20
|
|
|
@@ -49,25 +52,15 @@ async function buildCoverPage(doc: PDFDocument): Promise<PDFPage> {
|
|
|
49
52
|
// Thin accent line
|
|
50
53
|
page.drawRectangle({ x: MARGIN, y: PH * 0.45 - 2, width: PW - MARGIN * 2, height: 2, color: rgb(0.3, 0.35, 0.45) })
|
|
51
54
|
|
|
52
|
-
// "SECOND FRONT SYSTEMS" label
|
|
53
|
-
page.drawText("SECOND FRONT SYSTEMS", {
|
|
54
|
-
x: MARGIN,
|
|
55
|
-
y: PH * 0.45 + 180,
|
|
56
|
-
size: 9,
|
|
57
|
-
font: regularFont,
|
|
58
|
-
color: rgb(0.6, 0.65, 0.75),
|
|
59
|
-
characterSpacing: 2,
|
|
60
|
-
})
|
|
61
|
-
|
|
62
55
|
// Main title
|
|
63
|
-
page.drawText(
|
|
56
|
+
page.drawText(SITE_TITLE, {
|
|
64
57
|
x: MARGIN,
|
|
65
58
|
y: PH * 0.45 + 130,
|
|
66
59
|
size: 42,
|
|
67
60
|
font: boldFont,
|
|
68
61
|
color: WHITE,
|
|
69
62
|
})
|
|
70
|
-
page.drawText("
|
|
63
|
+
page.drawText("Documentation", {
|
|
71
64
|
x: MARGIN,
|
|
72
65
|
y: PH * 0.45 + 78,
|
|
73
66
|
size: 42,
|
|
@@ -94,13 +87,12 @@ async function buildCoverPage(doc: PDFDocument): Promise<PDFPage> {
|
|
|
94
87
|
|
|
95
88
|
// Footer bar
|
|
96
89
|
page.drawRectangle({ x: 0, y: 0, width: PW, height: 36, color: LIGHT })
|
|
97
|
-
page.drawText(
|
|
90
|
+
page.drawText(`${SITE_TITLE.toLowerCase()} documentation`, {
|
|
98
91
|
x: MARGIN,
|
|
99
92
|
y: 12,
|
|
100
93
|
size: 8,
|
|
101
94
|
font: regularFont,
|
|
102
95
|
color: MID,
|
|
103
|
-
characterSpacing: 0.5,
|
|
104
96
|
})
|
|
105
97
|
page.drawText("CONFIDENTIAL — FOR AUTHORIZED USERS ONLY", {
|
|
106
98
|
x: PW - MARGIN - 220,
|
|
@@ -126,7 +118,7 @@ async function buildTocPage(
|
|
|
126
118
|
|
|
127
119
|
const drawPageHeader = (p: PDFPage) => {
|
|
128
120
|
p.drawText("TABLE OF CONTENTS", {
|
|
129
|
-
x: MARGIN, y: PH - MARGIN + 10, size: 8, font: regularFont, color: MID,
|
|
121
|
+
x: MARGIN, y: PH - MARGIN + 10, size: 8, font: regularFont, color: MID,
|
|
130
122
|
})
|
|
131
123
|
p.drawLine({ start: { x: MARGIN, y: PH - MARGIN - 4 }, end: { x: PW - MARGIN, y: PH - MARGIN - 4 }, thickness: 0.5, color: rgb(0.85, 0.85, 0.85) })
|
|
132
124
|
}
|
|
@@ -152,7 +144,7 @@ async function buildTocPage(
|
|
|
152
144
|
if (entry.group !== lastGroup) {
|
|
153
145
|
if (lastGroup !== "") y -= 6
|
|
154
146
|
page.drawText(entry.group.toUpperCase(), {
|
|
155
|
-
x: MARGIN, y, size: 7.5, font: boldFont, color: MID,
|
|
147
|
+
x: MARGIN, y, size: 7.5, font: boldFont, color: MID,
|
|
156
148
|
})
|
|
157
149
|
y -= 18
|
|
158
150
|
lastGroup = entry.group
|
|
@@ -249,7 +241,7 @@ async function main() {
|
|
|
249
241
|
|
|
250
242
|
// ── Write output ───────────────────────────────────────────────────────────
|
|
251
243
|
fs.mkdirSync(OUT_DIR, { recursive: true })
|
|
252
|
-
const outFile = path.join(OUT_DIR, `
|
|
244
|
+
const outFile = path.join(OUT_DIR, `camelmind-${VERSION}.pdf`)
|
|
253
245
|
const pdfBytes = await masterDoc.save()
|
|
254
246
|
fs.writeFileSync(outFile, pdfBytes)
|
|
255
247
|
|
|
@@ -13,14 +13,25 @@ import { SignJWT } from "jose"
|
|
|
13
13
|
import fs from "fs"
|
|
14
14
|
import path from "path"
|
|
15
15
|
import yaml from "js-yaml"
|
|
16
|
+
import config from "../camelmind.config"
|
|
17
|
+
|
|
18
|
+
const SITE_TITLE = config.title
|
|
16
19
|
|
|
17
20
|
const ROOT = path.resolve(process.cwd())
|
|
18
|
-
|
|
21
|
+
|
|
22
|
+
// Resolve the latest stable version's nav file from versions.yml
|
|
23
|
+
const { versions: _versions } = yaml.load(fs.readFileSync(path.join(ROOT, "versions.yml"), "utf-8")) as {
|
|
24
|
+
versions: { id: string; stable: boolean; nav: string }[]
|
|
25
|
+
}
|
|
26
|
+
const _latestVersion = _versions.find((v) => v.stable) ?? _versions[0]
|
|
27
|
+
const NAV_FILE = path.join(ROOT, _latestVersion.nav)
|
|
19
28
|
|
|
20
29
|
const PORT = parseInt(process.argv.find((a) => a.startsWith("--port="))?.split("=")[1] ?? "3000")
|
|
21
30
|
const OUT_DIR = process.argv.find((a) => a.startsWith("--out="))?.split("=")[1]
|
|
22
31
|
? path.resolve(process.argv.find((a) => a.startsWith("--out="))!.split("=")[1])
|
|
23
32
|
: path.join(ROOT, ".pdf-pages")
|
|
33
|
+
// --no-auth: skip the login step (use when rendering a static offline export where auth is bypassed)
|
|
34
|
+
const SKIP_AUTH = process.argv.includes("--no-auth")
|
|
24
35
|
|
|
25
36
|
const CONCURRENCY = 4
|
|
26
37
|
const BASE_URL = `http://localhost:${PORT}`
|
|
@@ -110,7 +121,7 @@ async function generatePage(
|
|
|
110
121
|
headerTemplate: `
|
|
111
122
|
<div style="width:100%;font-family:system-ui,sans-serif;font-size:8px;color:#6b7280;display:flex;justify-content:space-between;padding:0 16mm;">
|
|
112
123
|
<span>${page.group}${page.section ? " › " + page.section : ""}</span>
|
|
113
|
-
<span style="color:#111827;font-weight:600;"
|
|
124
|
+
<span style="color:#111827;font-weight:600;">${SITE_TITLE}</span>
|
|
114
125
|
</div>`,
|
|
115
126
|
footerTemplate: `
|
|
116
127
|
<div style="width:100%;font-family:system-ui,sans-serif;font-size:8px;color:#6b7280;display:flex;justify-content:space-between;padding:0 16mm;">
|
|
@@ -148,16 +159,19 @@ async function main() {
|
|
|
148
159
|
|
|
149
160
|
const browser = await chromium.launch()
|
|
150
161
|
|
|
151
|
-
// Sign in via the dev login UI so we get a real session cookie
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
162
|
+
// Sign in via the dev login UI so we get a real session cookie.
|
|
163
|
+
// Skip when rendering against a static offline export (auth is bypassed there).
|
|
164
|
+
let cookies: Cookie[] = []
|
|
165
|
+
if (!SKIP_AUTH) {
|
|
166
|
+
const authCtx = await browser.newContext()
|
|
167
|
+
const authPage = await authCtx.newPage()
|
|
168
|
+
await authPage.goto(`${BASE_URL}/login`, { waitUntil: "networkidle" })
|
|
169
|
+
await authPage.click("button:has-text('Staff')")
|
|
170
|
+
await authPage.waitForURL(/\/home|\/getting-started/, { timeout: 10000 })
|
|
171
|
+
cookies = await authCtx.cookies()
|
|
172
|
+
await authPage.close()
|
|
173
|
+
await authCtx.close()
|
|
174
|
+
}
|
|
161
175
|
|
|
162
176
|
const results: Array<{ page: PageEntry; file: string }> = []
|
|
163
177
|
|
|
Binary file
|