camelmind 0.2.1 → 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "camelmind",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Create and manage CamelMind documentation sites",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
@@ -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>
@@ -0,0 +1,4 @@
1
+ // Renders normally for humans — stripped from .md responses served to LLMs.
2
+ export function LLMIgnore({ children }: { children?: React.ReactNode }) {
3
+ return <>{children}</>
4
+ }
@@ -0,0 +1,4 @@
1
+ // Hidden from the rendered page — visible only in .md responses served to LLMs.
2
+ export function LLMOnly({ children }: { children?: React.ReactNode }) {
3
+ return null
4
+ }
@@ -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
- let _cache: ParsedSpec | null = null
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 langKey = JSON.stringify(languages ?? null)
23
- if (_cache && _cachedLanguages === langKey) return _cache
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
- _cache = parseOpenApi(doc, languages)
27
- _cachedLanguages = langKey
28
- return _cache
26
+ const parsed = parseOpenApi(doc, languages)
27
+ _specCache.set(cacheKey, parsed)
28
+ return parsed
29
29
  }
30
30
 
31
31
  export function clearApiCache() {
32
- _cache = null
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
- spec: string
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
  }
@@ -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
 
@@ -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")
@@ -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
- const navPath = path.join(process.cwd(), "nav", "nav.yml")
14
- const raw = fs.readFileSync(navPath, "utf-8")
15
- _navCache = yaml.load(raw) as NavConfig
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
+ }
@@ -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 VersionApiReference = {
8
- spec: string
9
- nav_label?: string
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/omitted = use site-level spec, false = hide, object = version-specific spec
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
- matcher: ["/((?!_next/static|_next/image|favicon.ico|public/).*)"],
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:-v2.0}
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
- trap restore_routes EXIT
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
- # Game Warden Help Center — offline launcher (Mac / Linux)
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 Game Warden Help Center — offline launcher (Windows)
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. Zip the output
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:-v2.0}
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/Game-Warden-Help-Center-${VERSION}.pdf" | cut -f1)
41
- echo "✓ Done: offline-builds/Game-Warden-Help-Center-${VERSION}.pdf ($SIZE)"
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("Game Warden", {
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("Help Center", {
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("game warden help center · secondfront.com", {
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, characterSpacing: 1.5
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, characterSpacing: 1,
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, `Game-Warden-Help-Center-${VERSION}.pdf`)
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
- const NAV_FILE = path.join(ROOT, "nav", "nav.yml")
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;">Game Warden Help Center</span>
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
- const authCtx = await browser.newContext()
153
- const authPage = await authCtx.newPage()
154
- await authPage.goto(`${BASE_URL}/login`, { waitUntil: "networkidle" })
155
- // Click "Staff (vendor + admin)" persona button
156
- await authPage.click("button:has-text('Staff')")
157
- await authPage.waitForURL(/\/home|\/getting-started/, { timeout: 10000 })
158
- const cookies = await authCtx.cookies()
159
- await authPage.close()
160
- await authCtx.close()
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