camelmind 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +142 -0
- package/package.json +51 -0
- package/template/_env.example +16 -0
- package/template/_gitignore +32 -0
- package/template/_package.json +39 -0
- package/template/app/[...slug]/page.tsx +150 -0
- package/template/app/api/auth/callback/route.ts +47 -0
- package/template/app/api/auth/login/route.ts +39 -0
- package/template/app/api/auth/logout/route.ts +8 -0
- package/template/app/api/auth/signin/route.ts +64 -0
- package/template/app/api/download/route.ts +67 -0
- package/template/app/api/raw/route.ts +43 -0
- package/template/app/api/search/route.ts +138 -0
- package/template/app/favicon.ico +0 -0
- package/template/app/globals.css +524 -0
- package/template/app/home/page.tsx +136 -0
- package/template/app/icon.svg +29 -0
- package/template/app/layout.tsx +44 -0
- package/template/app/login/LoginForm.tsx +105 -0
- package/template/app/login/page.tsx +9 -0
- package/template/app/page.tsx +5 -0
- package/template/camelmind.config.ts +33 -0
- package/template/components/Breadcrumbs/Breadcrumbs.tsx +41 -0
- package/template/components/Code/CodeBlock.tsx +55 -0
- package/template/components/DocActions/DocActions.tsx +62 -0
- package/template/components/Nav/MobileDrawer.tsx +221 -0
- package/template/components/Nav/ThemeToggle.tsx +62 -0
- package/template/components/Nav/TopNav.tsx +173 -0
- package/template/components/Nav/VersionSelector.tsx +107 -0
- package/template/components/PageNav/PageNav.tsx +68 -0
- package/template/components/Search/SearchModal.tsx +278 -0
- package/template/components/SectionCards/SectionCards.tsx +33 -0
- package/template/components/Sidebar/Sidebar.tsx +196 -0
- package/template/components/Toc/Toc.tsx +57 -0
- package/template/components/ZoomImages/ZoomImages.tsx +24 -0
- package/template/components/mdx/Callout.tsx +43 -0
- package/template/components/mdx/Details.tsx +65 -0
- package/template/components/mdx/Icon.tsx +25 -0
- package/template/components/mdx/Steps.tsx +33 -0
- package/template/components/mdx/Tabs.tsx +69 -0
- package/template/components/mdx/index.tsx +34 -0
- package/template/content/getting-started/installation.mdx +51 -0
- package/template/content/getting-started/overview.mdx +39 -0
- package/template/content/guides/writing-docs.mdx +72 -0
- package/template/eslint.config.mjs +18 -0
- package/template/lib/auth-providers/dev-mock.ts +45 -0
- package/template/lib/auth-providers/oidc.ts +95 -0
- package/template/lib/auth-roles.ts +28 -0
- package/template/lib/auth.ts +76 -0
- package/template/lib/config-types.ts +30 -0
- package/template/lib/config.ts +29 -0
- package/template/lib/mdx.ts +53 -0
- package/template/lib/nav-types.ts +31 -0
- package/template/lib/nav.ts +125 -0
- package/template/lib/versions.ts +61 -0
- package/template/nav/nav.yml +20 -0
- package/template/next.config.ts +35 -0
- package/template/postcss.config.mjs +7 -0
- package/template/proxy.ts +50 -0
- package/template/public/2f-logo.png +0 -0
- package/template/public/favicon.png +0 -0
- package/template/public/file.svg +1 -0
- package/template/public/globe.svg +1 -0
- package/template/public/images/gwa-app-central-main.png +0 -0
- package/template/public/next.svg +1 -0
- package/template/public/window.svg +1 -0
- package/template/scripts/build-offline.sh +189 -0
- package/template/scripts/build-pdf.sh +41 -0
- package/template/scripts/build-search-index.ts +90 -0
- package/template/scripts/generate-master-pdf.ts +260 -0
- package/template/scripts/generate-pdfs.ts +185 -0
- package/template/tsconfig.json +34 -0
- package/template/versions.yml +5 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
6
|
+
import path2 from "path";
|
|
7
|
+
import fs2 from "fs";
|
|
8
|
+
|
|
9
|
+
// src/commands/init.ts
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
import { execSync } from "child_process";
|
|
13
|
+
import fs from "fs-extra";
|
|
14
|
+
import chalk from "chalk";
|
|
15
|
+
import ora from "ora";
|
|
16
|
+
import prompts from "prompts";
|
|
17
|
+
var __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
var TEMPLATE_DIR = path.join(__dirname, "../template");
|
|
19
|
+
var BANNER = `
|
|
20
|
+
${chalk.hex("#D8A15B").bold("\u{1F42A} CamelMind")} ${chalk.hex("#F4D6A7")("Doc site spun up in minutes.")}
|
|
21
|
+
`;
|
|
22
|
+
function validateProjectName(name) {
|
|
23
|
+
if (!/^[a-z0-9][a-z0-9-_]*$/.test(name)) {
|
|
24
|
+
return "Use lowercase letters, numbers, hyphens, or underscores (must start with a letter or digit)";
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
async function init(projectName, options) {
|
|
29
|
+
console.log(BANNER);
|
|
30
|
+
if (!projectName) {
|
|
31
|
+
const response = await prompts(
|
|
32
|
+
{
|
|
33
|
+
type: "text",
|
|
34
|
+
name: "name",
|
|
35
|
+
message: "What do you want to call your doc site?",
|
|
36
|
+
initial: "my-docs",
|
|
37
|
+
validate: validateProjectName
|
|
38
|
+
},
|
|
39
|
+
{ onCancel: () => process.exit(0) }
|
|
40
|
+
);
|
|
41
|
+
projectName = response.name;
|
|
42
|
+
} else {
|
|
43
|
+
const valid = validateProjectName(projectName);
|
|
44
|
+
if (valid !== true) {
|
|
45
|
+
console.error(chalk.red(` Error: ${valid}`));
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const targetDir = path.resolve(process.cwd(), projectName);
|
|
50
|
+
const displayDir = `./${path.relative(process.cwd(), targetDir)}`;
|
|
51
|
+
if (await fs.pathExists(targetDir)) {
|
|
52
|
+
const { overwrite } = await prompts(
|
|
53
|
+
{
|
|
54
|
+
type: "confirm",
|
|
55
|
+
name: "overwrite",
|
|
56
|
+
message: `${chalk.yellow(displayDir)} already exists \u2014 clear it and start fresh?`,
|
|
57
|
+
initial: false
|
|
58
|
+
},
|
|
59
|
+
{ onCancel: () => process.exit(0) }
|
|
60
|
+
);
|
|
61
|
+
if (!overwrite) {
|
|
62
|
+
console.log(chalk.gray("\n Safe travels. Come back when you're ready.\n"));
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
await fs.remove(targetDir);
|
|
66
|
+
}
|
|
67
|
+
console.log(
|
|
68
|
+
` Packing your caravan in ${chalk.cyan(displayDir)}
|
|
69
|
+
`
|
|
70
|
+
);
|
|
71
|
+
const copySpinner = ora("Loading up the camel...").start();
|
|
72
|
+
try {
|
|
73
|
+
if (!await fs.pathExists(TEMPLATE_DIR)) {
|
|
74
|
+
copySpinner.fail(
|
|
75
|
+
"Template directory not found. Run `npm run sync-template` from the monorepo root first."
|
|
76
|
+
);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
await fs.copy(TEMPLATE_DIR, targetDir, {
|
|
80
|
+
filter: (src) => !src.includes("node_modules") && !src.includes(".next") && !src.includes(".git")
|
|
81
|
+
});
|
|
82
|
+
const pkgSrc = path.join(targetDir, "_package.json");
|
|
83
|
+
const pkgDest = path.join(targetDir, "package.json");
|
|
84
|
+
if (await fs.pathExists(pkgSrc)) {
|
|
85
|
+
let content = await fs.readFile(pkgSrc, "utf-8");
|
|
86
|
+
content = content.replace(/\{\{projectName\}\}/g, projectName);
|
|
87
|
+
await fs.writeFile(pkgDest, content, "utf-8");
|
|
88
|
+
await fs.remove(pkgSrc);
|
|
89
|
+
}
|
|
90
|
+
const gitignoreSrc = path.join(targetDir, "_gitignore");
|
|
91
|
+
if (await fs.pathExists(gitignoreSrc)) {
|
|
92
|
+
await fs.move(gitignoreSrc, path.join(targetDir, ".gitignore"));
|
|
93
|
+
}
|
|
94
|
+
const envSrc = path.join(targetDir, "_env.example");
|
|
95
|
+
if (await fs.pathExists(envSrc)) {
|
|
96
|
+
await fs.move(envSrc, path.join(targetDir, ".env.example"));
|
|
97
|
+
}
|
|
98
|
+
copySpinner.succeed("Caravan loaded");
|
|
99
|
+
} catch (err) {
|
|
100
|
+
copySpinner.fail("Failed to load the caravan");
|
|
101
|
+
console.error(chalk.red(`
|
|
102
|
+
${String(err)}
|
|
103
|
+
`));
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
if (options.install) {
|
|
107
|
+
const installSpinner = ora("Gathering supplies...").start();
|
|
108
|
+
try {
|
|
109
|
+
execSync("npm install", {
|
|
110
|
+
cwd: targetDir,
|
|
111
|
+
stdio: "ignore"
|
|
112
|
+
});
|
|
113
|
+
installSpinner.succeed("Supplies ready");
|
|
114
|
+
} catch {
|
|
115
|
+
installSpinner.warn(
|
|
116
|
+
"Supply run failed \u2014 try npm install inside your project"
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const cdCmd = chalk.cyan(`cd ${displayDir}`);
|
|
121
|
+
const installCmd = options.install ? "" : `
|
|
122
|
+
${chalk.cyan("npm install")}`;
|
|
123
|
+
const devCmd = chalk.cyan("npm run dev");
|
|
124
|
+
console.log(`
|
|
125
|
+
${chalk.hex("#D8A15B")("Your documentation journey starts here.")}
|
|
126
|
+
|
|
127
|
+
${chalk.bold("Next steps:")}
|
|
128
|
+
${cdCmd}${installCmd}
|
|
129
|
+
${devCmd}
|
|
130
|
+
|
|
131
|
+
Then open ${chalk.underline("http://localhost:3000")} in your browser.
|
|
132
|
+
`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/index.ts
|
|
136
|
+
var __dirname2 = path2.dirname(fileURLToPath2(import.meta.url));
|
|
137
|
+
var pkgPath = path2.join(__dirname2, "../package.json");
|
|
138
|
+
var pkg = JSON.parse(fs2.readFileSync(pkgPath, "utf-8"));
|
|
139
|
+
var program = new Command();
|
|
140
|
+
program.name("camelmind").description("CamelMind CLI \u2014 scaffold and manage documentation sites").version(pkg.version);
|
|
141
|
+
program.command("init [project-name]").description("Create a new CamelMind documentation site").option("--no-install", "Skip installing npm dependencies").action(init);
|
|
142
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "camelmind",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Create and manage CamelMind documentation sites",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"camelmind": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"template"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup",
|
|
17
|
+
"dev": "tsup --watch",
|
|
18
|
+
"prepublishOnly": "npm run build"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"chalk": "^5.3.0",
|
|
22
|
+
"commander": "^12.1.0",
|
|
23
|
+
"fs-extra": "^11.2.0",
|
|
24
|
+
"ora": "^8.1.1",
|
|
25
|
+
"prompts": "^2.4.2"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/fs-extra": "^11.0.4",
|
|
29
|
+
"@types/node": "^20",
|
|
30
|
+
"@types/prompts": "^2.4.9",
|
|
31
|
+
"tsup": "^8.3.5",
|
|
32
|
+
"typescript": "^5.7.3"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"documentation",
|
|
39
|
+
"docs",
|
|
40
|
+
"mdx",
|
|
41
|
+
"nextjs",
|
|
42
|
+
"camelmind"
|
|
43
|
+
],
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "https://github.com/conniehnguyen/camelmind"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Site URL (used for absolute links; defaults to http://localhost:3000)
|
|
2
|
+
CAMELMIND_URL=http://localhost:3000
|
|
3
|
+
|
|
4
|
+
# Auth — disabled by default. Set to "true" to enable.
|
|
5
|
+
CAMELMIND_AUTH_ENABLED=false
|
|
6
|
+
CAMELMIND_AUTH_REQUIRE_LOGIN=false
|
|
7
|
+
CAMELMIND_AUTH_PROVIDER=dev-mock
|
|
8
|
+
|
|
9
|
+
# OIDC / SSO (required when CAMELMIND_AUTH_PROVIDER=oidc)
|
|
10
|
+
# OIDC_ISSUER=https://your-idp.example.com/realms/your-realm
|
|
11
|
+
# OIDC_CLIENT_ID=your-client-id
|
|
12
|
+
# OIDC_CLIENT_SECRET=your-client-secret
|
|
13
|
+
# OIDC_ROLES_CLAIM=realm_access.roles
|
|
14
|
+
|
|
15
|
+
# Session secret (required when auth is enabled)
|
|
16
|
+
# SESSION_SECRET=change-me-to-a-random-32-char-string
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
|
|
4
|
+
# Next.js
|
|
5
|
+
.next/
|
|
6
|
+
out/
|
|
7
|
+
|
|
8
|
+
# Build outputs
|
|
9
|
+
offline-builds/
|
|
10
|
+
public/search-index.json
|
|
11
|
+
|
|
12
|
+
# Environment variables
|
|
13
|
+
.env.local
|
|
14
|
+
.env.*.local
|
|
15
|
+
|
|
16
|
+
# Editor
|
|
17
|
+
.vscode/
|
|
18
|
+
.idea/
|
|
19
|
+
*.suo
|
|
20
|
+
*.ntvs*
|
|
21
|
+
*.njsproj
|
|
22
|
+
*.sln
|
|
23
|
+
|
|
24
|
+
# OS
|
|
25
|
+
.DS_Store
|
|
26
|
+
Thumbs.db
|
|
27
|
+
|
|
28
|
+
# Logs
|
|
29
|
+
*.log
|
|
30
|
+
npm-debug.log*
|
|
31
|
+
yarn-debug.log*
|
|
32
|
+
yarn-error.log*
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectName}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start",
|
|
9
|
+
"lint": "eslint"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@tailwindcss/typography": "^0.5.20",
|
|
13
|
+
"gray-matter": "^4.0.3",
|
|
14
|
+
"jose": "^6.2.3",
|
|
15
|
+
"js-yaml": "^4.2.0",
|
|
16
|
+
"lucide-react": "^1.18.0",
|
|
17
|
+
"next": "16.2.9",
|
|
18
|
+
"next-mdx-remote": "^6.0.0",
|
|
19
|
+
"next-themes": "^0.4.6",
|
|
20
|
+
"pdf-lib": "^1.17.1",
|
|
21
|
+
"playwright": "^1.61.0",
|
|
22
|
+
"react": "19.2.4",
|
|
23
|
+
"react-dom": "19.2.4",
|
|
24
|
+
"rehype-autolink-headings": "^7.1.0",
|
|
25
|
+
"rehype-slug": "^6.0.0",
|
|
26
|
+
"remark-gfm": "^4.0.1"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@tailwindcss/postcss": "^4",
|
|
30
|
+
"@types/js-yaml": "^4.0.9",
|
|
31
|
+
"@types/node": "^20",
|
|
32
|
+
"@types/react": "^19",
|
|
33
|
+
"@types/react-dom": "^19",
|
|
34
|
+
"eslint": "^9",
|
|
35
|
+
"eslint-config-next": "16.2.9",
|
|
36
|
+
"tailwindcss": "^4",
|
|
37
|
+
"typescript": "^5"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { notFound, redirect } from "next/navigation"
|
|
2
|
+
import { MDXRemote } from "next-mdx-remote/rsc"
|
|
3
|
+
import remarkGfm from "remark-gfm"
|
|
4
|
+
import rehypeSlug from "rehype-slug"
|
|
5
|
+
import rehypeAutolinkHeadings from "rehype-autolink-headings"
|
|
6
|
+
import {
|
|
7
|
+
getAllSlugs,
|
|
8
|
+
getSlugsFromConfig,
|
|
9
|
+
getEntryBySlugFromConfig,
|
|
10
|
+
getGroupForSlugFromConfig,
|
|
11
|
+
getSectionForSlugFromConfig,
|
|
12
|
+
} from "@/lib/nav"
|
|
13
|
+
import { loadMdxFile } from "@/lib/mdx"
|
|
14
|
+
import { getSession, hasAccess, pageRequiresAuth, shouldRedirectToLogin } from "@/lib/auth"
|
|
15
|
+
import { isAuthEnabled } from "@/lib/config"
|
|
16
|
+
import { loadVersions, getVersionFromSlug, getNavForVersion } from "@/lib/versions"
|
|
17
|
+
import { TopNav } from "@/components/Nav/TopNav"
|
|
18
|
+
import { Sidebar } from "@/components/Sidebar/Sidebar"
|
|
19
|
+
import { Toc } from "@/components/Toc/Toc"
|
|
20
|
+
import { Breadcrumbs } from "@/components/Breadcrumbs/Breadcrumbs"
|
|
21
|
+
import { PageNav } from "@/components/PageNav/PageNav"
|
|
22
|
+
import { SectionCards } from "@/components/SectionCards/SectionCards"
|
|
23
|
+
import { DocActions } from "@/components/DocActions/DocActions"
|
|
24
|
+
import { ZoomImages } from "@/components/ZoomImages/ZoomImages"
|
|
25
|
+
import { mdxComponents } from "@/components/mdx"
|
|
26
|
+
import type { NavGroup, NavEntry } from "@/lib/nav-types"
|
|
27
|
+
|
|
28
|
+
type Props = {
|
|
29
|
+
params: Promise<{ slug: string[] }>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function generateStaticParams() {
|
|
33
|
+
// Default (unversioned) slugs — full depth
|
|
34
|
+
const defaultSlugs = getAllSlugs().map((slug) => ({
|
|
35
|
+
slug: slug.replace(/^\//, "").split("/"),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
// Versioned slugs — full depth from each version's nav
|
|
39
|
+
const { versions } = loadVersions()
|
|
40
|
+
const versionedSlugs = versions.flatMap((v) => {
|
|
41
|
+
const nav = getNavForVersion(v.id)
|
|
42
|
+
return getSlugsFromConfig(nav).map((slug) => ({
|
|
43
|
+
slug: slug.replace(/^\//, "").split("/"),
|
|
44
|
+
}))
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
return [...defaultSlugs, ...versionedSlugs]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default async function DocPage({ params }: Props) {
|
|
51
|
+
const { slug } = await params
|
|
52
|
+
const fullSlug = "/" + slug.join("/")
|
|
53
|
+
|
|
54
|
+
const versionId = getVersionFromSlug(fullSlug)
|
|
55
|
+
const nav = getNavForVersion(versionId) // correct nav for this version (or default)
|
|
56
|
+
const { versions } = loadVersions()
|
|
57
|
+
|
|
58
|
+
const versionSlugs: Record<string, string[]> = Object.fromEntries(
|
|
59
|
+
versions.map((v) => [v.id, getSlugsFromConfig(getNavForVersion(v.id))])
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
// All lookups use the version-appropriate nav
|
|
63
|
+
const navEntry = getEntryBySlugFromConfig(nav, fullSlug)
|
|
64
|
+
if (!navEntry) return notFound()
|
|
65
|
+
|
|
66
|
+
const session = await getSession()
|
|
67
|
+
const authEnabled = isAuthEnabled()
|
|
68
|
+
|
|
69
|
+
if (shouldRedirectToLogin(navEntry.roles, session)) {
|
|
70
|
+
redirect(`/login?returnTo=${encodeURIComponent(fullSlug)}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (pageRequiresAuth(navEntry.roles) && session && !hasAccess(navEntry.roles, session.roles)) {
|
|
74
|
+
return (
|
|
75
|
+
<div className="flex flex-col h-screen">
|
|
76
|
+
<TopNav nav={nav.nav} userRoles={session.roles} userName={session.name} authEnabled={authEnabled} versions={versions} currentVersionId={versionId} currentSlug={fullSlug} versionSlugs={versionSlugs} />
|
|
77
|
+
<div className="flex flex-1 items-center justify-center bg-[var(--cm-bg-primary)]">
|
|
78
|
+
<div className="text-center">
|
|
79
|
+
<h1 className="text-2xl font-bold text-[var(--cm-text-primary)] mb-2">Access Restricted</h1>
|
|
80
|
+
<p className="text-[var(--cm-text-secondary)]">You don't have permission to view this page.</p>
|
|
81
|
+
<p className="text-xs text-[var(--cm-text-muted)] mt-2">
|
|
82
|
+
Required roles: {navEntry.roles.join(", ")} · Your roles: {session.roles.join(", ") || "none"}
|
|
83
|
+
</p>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const activeGroup = getGroupForSlugFromConfig(nav, fullSlug) as NavGroup | null
|
|
91
|
+
const sectionEntry = getSectionForSlugFromConfig(nav, fullSlug) as NavEntry | null
|
|
92
|
+
const { frontmatter, source, toc } = loadMdxFile(navEntry.file)
|
|
93
|
+
const isSectionRoot = sectionEntry?.slug === fullSlug
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="flex flex-col h-screen">
|
|
97
|
+
<TopNav nav={nav.nav} userRoles={session?.roles ?? []} userName={session?.name ?? null} authEnabled={authEnabled} versions={versions} currentVersionId={versionId} currentSlug={fullSlug} versionSlugs={versionSlugs} />
|
|
98
|
+
<div className="flex flex-1 overflow-hidden">
|
|
99
|
+
<Sidebar activeGroup={activeGroup} currentSlug={fullSlug} userRoles={session?.roles ?? []} authEnabled={authEnabled} />
|
|
100
|
+
<main className="flex-1 overflow-y-auto bg-white dark:bg-gray-950">
|
|
101
|
+
<div className="flex max-w-5xl mx-auto">
|
|
102
|
+
<article className="flex-1 px-4 md:px-10 py-6 md:py-8 min-w-0">
|
|
103
|
+
<div data-print="hide">
|
|
104
|
+
<Breadcrumbs
|
|
105
|
+
activeGroup={activeGroup}
|
|
106
|
+
sectionEntry={sectionEntry}
|
|
107
|
+
currentEntry={navEntry as NavEntry}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
<h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-50">{frontmatter.title}</h1>
|
|
111
|
+
{frontmatter.description && (
|
|
112
|
+
<p className="text-gray-500 dark:text-gray-400 text-lg mb-6 leading-relaxed">{frontmatter.description}</p>
|
|
113
|
+
)}
|
|
114
|
+
<div data-print="hide">
|
|
115
|
+
<DocActions file={navEntry.file} downloadPdf={frontmatter.download_pdf} offline={process.env.OFFLINE_MODE === "true"} />
|
|
116
|
+
</div>
|
|
117
|
+
<ZoomImages />
|
|
118
|
+
<div className="prose prose-gray mt-6">
|
|
119
|
+
<MDXRemote
|
|
120
|
+
source={source}
|
|
121
|
+
components={mdxComponents}
|
|
122
|
+
options={{
|
|
123
|
+
// blockJS must be false so JSX prop expressions like n={1} are not stripped
|
|
124
|
+
blockJS: false,
|
|
125
|
+
mdxOptions: {
|
|
126
|
+
remarkPlugins: [remarkGfm],
|
|
127
|
+
rehypePlugins: [
|
|
128
|
+
rehypeSlug,
|
|
129
|
+
[rehypeAutolinkHeadings, { behavior: "wrap" }],
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
}}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
{isSectionRoot && sectionEntry && (
|
|
136
|
+
<div data-print="hide">
|
|
137
|
+
<SectionCards entry={sectionEntry} />
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
<div data-print="hide">
|
|
141
|
+
<PageNav activeGroup={activeGroup} currentSlug={fullSlug} />
|
|
142
|
+
</div>
|
|
143
|
+
</article>
|
|
144
|
+
<Toc entries={toc} />
|
|
145
|
+
</div>
|
|
146
|
+
</main>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server"
|
|
2
|
+
import { createSession, COOKIE_NAME } from "@/lib/auth"
|
|
3
|
+
import { getAuthConfig, isAuthEnabled } from "@/lib/config"
|
|
4
|
+
import { exchangeCodeForSession } from "@/lib/auth-providers/oidc"
|
|
5
|
+
|
|
6
|
+
export async function GET(req: NextRequest) {
|
|
7
|
+
if (!isAuthEnabled()) {
|
|
8
|
+
return NextResponse.redirect("/home")
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { provider } = getAuthConfig()
|
|
12
|
+
if (provider !== "oidc") {
|
|
13
|
+
return NextResponse.redirect("/login")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const code = req.nextUrl.searchParams.get("code")
|
|
17
|
+
const stateParam = req.nextUrl.searchParams.get("state") ?? ""
|
|
18
|
+
const storedState = req.cookies.get("oidc_state")?.value
|
|
19
|
+
|
|
20
|
+
if (!code) {
|
|
21
|
+
return NextResponse.redirect("/login?error=missing_code")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const [state, encodedReturnTo] = stateParam.split(":")
|
|
25
|
+
if (!storedState || state !== storedState) {
|
|
26
|
+
return NextResponse.redirect("/login?error=invalid_state")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const user = await exchangeCodeForSession(code)
|
|
31
|
+
const token = await createSession(user)
|
|
32
|
+
const returnTo = encodedReturnTo ? decodeURIComponent(encodedReturnTo) : "/home"
|
|
33
|
+
const redirect = returnTo.startsWith("/") ? returnTo : "/home"
|
|
34
|
+
|
|
35
|
+
const res = NextResponse.redirect(redirect)
|
|
36
|
+
res.cookies.set(COOKIE_NAME, token, {
|
|
37
|
+
httpOnly: true,
|
|
38
|
+
sameSite: "lax",
|
|
39
|
+
path: "/",
|
|
40
|
+
maxAge: 60 * 60 * 8,
|
|
41
|
+
})
|
|
42
|
+
res.cookies.set("oidc_state", "", { maxAge: 0, path: "/" })
|
|
43
|
+
return res
|
|
44
|
+
} catch {
|
|
45
|
+
return NextResponse.redirect("/login?error=oidc_failed")
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server"
|
|
2
|
+
import { createSession, COOKIE_NAME } from "@/lib/auth"
|
|
3
|
+
import { getAuthConfig, isAuthEnabled } from "@/lib/config"
|
|
4
|
+
import { getDemoUser } from "@/lib/auth-providers/dev-mock"
|
|
5
|
+
|
|
6
|
+
function sanitizeReturnTo(returnTo: unknown): string {
|
|
7
|
+
const sanitized = typeof returnTo === "string" ? returnTo.replace(/^\/\/+/, "/") : ""
|
|
8
|
+
return sanitized.startsWith("/") ? sanitized : "/home"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Legacy dev-mock login endpoint — kept for backwards compatibility. */
|
|
12
|
+
export async function POST(req: NextRequest) {
|
|
13
|
+
if (!isAuthEnabled()) {
|
|
14
|
+
return NextResponse.json({ error: "Auth is not enabled" }, { status: 404 })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { provider } = getAuthConfig()
|
|
18
|
+
if (provider !== "dev-mock") {
|
|
19
|
+
return NextResponse.json({ error: "Use GET /api/auth/signin for OIDC login" }, { status: 400 })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { persona, returnTo } = await req.json()
|
|
23
|
+
const user = getDemoUser(persona)
|
|
24
|
+
if (!user) {
|
|
25
|
+
return NextResponse.json({ error: "Unknown persona" }, { status: 400 })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const token = await createSession(user)
|
|
29
|
+
const redirect = sanitizeReturnTo(returnTo)
|
|
30
|
+
|
|
31
|
+
const res = NextResponse.json({ ok: true, redirectTo: redirect })
|
|
32
|
+
res.cookies.set(COOKIE_NAME, token, {
|
|
33
|
+
httpOnly: true,
|
|
34
|
+
sameSite: "lax",
|
|
35
|
+
path: "/",
|
|
36
|
+
maxAge: 60 * 60 * 8,
|
|
37
|
+
})
|
|
38
|
+
return res
|
|
39
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server"
|
|
2
|
+
import { createSession, COOKIE_NAME } from "@/lib/auth"
|
|
3
|
+
import { getAuthConfig, isAuthEnabled } from "@/lib/config"
|
|
4
|
+
import { getDemoUser } from "@/lib/auth-providers/dev-mock"
|
|
5
|
+
import { getOidcAuthorizationUrl, isOidcConfigured } from "@/lib/auth-providers/oidc"
|
|
6
|
+
import crypto from "crypto"
|
|
7
|
+
|
|
8
|
+
function sanitizeReturnTo(returnTo: unknown): string {
|
|
9
|
+
const sanitized = typeof returnTo === "string" ? returnTo.replace(/^\/\/+/, "/") : ""
|
|
10
|
+
return sanitized.startsWith("/") ? sanitized : "/home"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Initiate sign-in — OIDC redirect or dev-mock instructions. */
|
|
14
|
+
export async function GET(req: NextRequest) {
|
|
15
|
+
if (!isAuthEnabled()) {
|
|
16
|
+
return NextResponse.json({ error: "Auth is not enabled" }, { status: 404 })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const returnTo = sanitizeReturnTo(req.nextUrl.searchParams.get("returnTo"))
|
|
20
|
+
const { provider } = getAuthConfig()
|
|
21
|
+
|
|
22
|
+
if (provider === "oidc") {
|
|
23
|
+
if (!isOidcConfigured()) {
|
|
24
|
+
return NextResponse.json({ error: "OIDC is not configured" }, { status: 500 })
|
|
25
|
+
}
|
|
26
|
+
const state = crypto.randomBytes(16).toString("hex")
|
|
27
|
+
const url = await getOidcAuthorizationUrl(returnTo, state)
|
|
28
|
+
const res = NextResponse.redirect(url)
|
|
29
|
+
res.cookies.set("oidc_state", state, { httpOnly: true, sameSite: "lax", path: "/", maxAge: 600 })
|
|
30
|
+
return res
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return NextResponse.redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Dev-mock sign-in via persona selection. */
|
|
37
|
+
export async function POST(req: NextRequest) {
|
|
38
|
+
if (!isAuthEnabled()) {
|
|
39
|
+
return NextResponse.json({ error: "Auth is not enabled" }, { status: 404 })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { provider } = getAuthConfig()
|
|
43
|
+
if (provider !== "dev-mock") {
|
|
44
|
+
return NextResponse.json({ error: "Use GET /api/auth/signin for OIDC login" }, { status: 400 })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { persona, returnTo } = await req.json()
|
|
48
|
+
const user = getDemoUser(persona)
|
|
49
|
+
if (!user) {
|
|
50
|
+
return NextResponse.json({ error: "Unknown persona" }, { status: 400 })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const token = await createSession(user)
|
|
54
|
+
const redirect = sanitizeReturnTo(returnTo)
|
|
55
|
+
|
|
56
|
+
const res = NextResponse.json({ ok: true, redirectTo: redirect })
|
|
57
|
+
res.cookies.set(COOKIE_NAME, token, {
|
|
58
|
+
httpOnly: true,
|
|
59
|
+
sameSite: "lax",
|
|
60
|
+
path: "/",
|
|
61
|
+
maxAge: 60 * 60 * 8,
|
|
62
|
+
})
|
|
63
|
+
return res
|
|
64
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server"
|
|
2
|
+
import fs from "fs"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import { getSession } from "@/lib/auth"
|
|
5
|
+
import { isAuthEnabled } from "@/lib/config"
|
|
6
|
+
import { loadVersions } from "@/lib/versions"
|
|
7
|
+
|
|
8
|
+
export async function GET(req: NextRequest) {
|
|
9
|
+
const session = await getSession()
|
|
10
|
+
if (isAuthEnabled() && !session) {
|
|
11
|
+
return new NextResponse("Unauthorized", { status: 401 })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const versionId = req.nextUrl.searchParams.get("version")
|
|
15
|
+
const rawType = req.nextUrl.searchParams.get("type") ?? "zip"
|
|
16
|
+
if (rawType !== "zip" && rawType !== "pdf") {
|
|
17
|
+
return new NextResponse("Invalid type parameter", { status: 400 })
|
|
18
|
+
}
|
|
19
|
+
const type = rawType
|
|
20
|
+
|
|
21
|
+
if (!versionId) {
|
|
22
|
+
return new NextResponse("Missing version parameter", { status: 400 })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Validate against known versions
|
|
26
|
+
const { versions } = loadVersions()
|
|
27
|
+
const version = versions.find((v) => v.id === versionId)
|
|
28
|
+
if (!version) {
|
|
29
|
+
return new NextResponse("Unknown version", { status: 404 })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Only stable versions are available for download
|
|
33
|
+
if (!version.stable) {
|
|
34
|
+
return new NextResponse("Download not available for pre-release versions", { status: 403 })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (type === "pdf") {
|
|
38
|
+
const pdfPath = path.join(process.cwd(), "offline-builds", `camelmind-${versionId}.pdf`)
|
|
39
|
+
if (!fs.existsSync(pdfPath)) {
|
|
40
|
+
return new NextResponse("PDF not yet built for this version", { status: 404 })
|
|
41
|
+
}
|
|
42
|
+
const stat = fs.statSync(pdfPath)
|
|
43
|
+
const buffer = fs.readFileSync(pdfPath)
|
|
44
|
+
return new NextResponse(buffer, {
|
|
45
|
+
headers: {
|
|
46
|
+
"Content-Type": "application/pdf",
|
|
47
|
+
"Content-Disposition": `attachment; filename="camelmind-${versionId}.pdf"`,
|
|
48
|
+
"Content-Length": stat.size.toString(),
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Default: ZIP
|
|
54
|
+
const zipPath = path.join(process.cwd(), "offline-builds", `camelmind-${versionId}-offline.zip`)
|
|
55
|
+
if (!fs.existsSync(zipPath)) {
|
|
56
|
+
return new NextResponse("Offline package not yet built for this version", { status: 404 })
|
|
57
|
+
}
|
|
58
|
+
const stat = fs.statSync(zipPath)
|
|
59
|
+
const buffer = fs.readFileSync(zipPath)
|
|
60
|
+
return new NextResponse(buffer, {
|
|
61
|
+
headers: {
|
|
62
|
+
"Content-Type": "application/zip",
|
|
63
|
+
"Content-Disposition": `attachment; filename="camelmind-${versionId}-offline.zip"`,
|
|
64
|
+
"Content-Length": stat.size.toString(),
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
}
|