diff --git a/example-basepath/docs.json b/example-basepath/docs.json new file mode 100644 index 00000000..fb85870b --- /dev/null +++ b/example-basepath/docs.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://unpkg.com/@holocron.so/vite/src/schema.json", + "name": "Base Path Example", + "colors": { + "primary": "#6366f1" + }, + "icons": { "library": "lucide" }, + "navigation": [ + { + "group": "Getting Started", + "pages": ["index", "getting-started"] + } + ] +} diff --git a/example-basepath/package.json b/example-basepath/package.json new file mode 100644 index 00000000..c255049a --- /dev/null +++ b/example-basepath/package.json @@ -0,0 +1,25 @@ +{ + "name": "example-basepath", + "private": true, + "type": "module", + "exports": { + "./dist/rsc": "./dist/rsc/index.js" + }, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "start": "node dist/rsc/index.js" + }, + "dependencies": { + "@holocron.so/vite": "workspace:^", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "spiceflow": "1.24.4-rsc.0", + "vite": "^8.0.10" + }, + "devDependencies": { + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "typescript": "^5.9.3" + } +} diff --git a/example-basepath/src/getting-started.mdx b/example-basepath/src/getting-started.mdx new file mode 100644 index 00000000..9206e414 --- /dev/null +++ b/example-basepath/src/getting-started.mdx @@ -0,0 +1,41 @@ +--- +title: Getting Started +description: Set up a base path holocron site. +--- + +# Getting Started + +## Configuration + +Add `base: '/docs'` to your Vite config: + +```ts +import { defineConfig } from 'vite' +import { holocron } from '@holocron.so/vite' + +export default defineConfig({ + base: '/docs', + plugins: [holocron({ pagesDir: './src' })], +}) +``` + +## Export for embedding + +Add an export in `package.json` so other apps can import the built app: + +```json +{ + "exports": { + "./dist/rsc": "./dist/rsc/index.js" + } +} +``` + +## Build and run + +```bash +vite build +node dist/rsc/index.js +``` + +The docs site will be available at `http://localhost:3000/docs`. diff --git a/example-basepath/src/index.mdx b/example-basepath/src/index.mdx new file mode 100644 index 00000000..7fe8d4a6 --- /dev/null +++ b/example-basepath/src/index.mdx @@ -0,0 +1,12 @@ +--- +title: Welcome +description: Holocron docs served under a base path. +--- + +# Welcome + +This is a minimal Holocron docs site served at `/docs`. It demonstrates the **base path** feature, where the entire site is mounted under a URL prefix. + +## How it works + +Set `base: '/docs'` in your `vite.config.ts` and Holocron handles all routing under that prefix. The built output can be mounted inside another framework (like Next.js) via `app.handle(request)`. diff --git a/example-basepath/tsconfig.json b/example-basepath/tsconfig.json new file mode 100644 index 00000000..88b4a177 --- /dev/null +++ b/example-basepath/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "preserve", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "types": ["vite/client"] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "dist"] +} diff --git a/example-basepath/vite.config.ts b/example-basepath/vite.config.ts new file mode 100644 index 00000000..5fcff675 --- /dev/null +++ b/example-basepath/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite' +import { holocron } from '@holocron.so/vite' + +export default defineConfig({ + clearScreen: false, + base: '/docs', + plugins: [holocron({ pagesDir: './src' })], +}) diff --git a/example-inside-next/next-env.d.ts b/example-inside-next/next-env.d.ts new file mode 100644 index 00000000..c4b7818f --- /dev/null +++ b/example-inside-next/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/example-inside-next/next.config.ts b/example-inside-next/next.config.ts new file mode 100644 index 00000000..e4f5738a --- /dev/null +++ b/example-inside-next/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = {} + +export default nextConfig diff --git a/example-inside-next/package.json b/example-inside-next/package.json new file mode 100644 index 00000000..b1b2e9de --- /dev/null +++ b/example-inside-next/package.json @@ -0,0 +1,22 @@ +{ + "name": "example-inside-next", + "private": true, + "type": "module", + "scripts": { + "build-docs": "pnpm --filter example-basepath build", + "dev": "pnpm build-docs && next dev", + "build": "pnpm build-docs && next build", + "start": "next start" + }, + "dependencies": { + "example-basepath": "workspace:^", + "next": "^16.2.6", + "react": "^19.2.5", + "react-dom": "^19.2.5" + }, + "devDependencies": { + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "typescript": "^5.9.3" + } +} diff --git a/example-inside-next/src/app/docs/[[...slug]]/route.ts b/example-inside-next/src/app/docs/[[...slug]]/route.ts new file mode 100644 index 00000000..5f33c066 --- /dev/null +++ b/example-inside-next/src/app/docs/[[...slug]]/route.ts @@ -0,0 +1,26 @@ +// Catch-all route handler that forwards requests to the Holocron docs app. +// The Holocron app is built with base: '/docs' so it expects requests at /docs/*. +// No URL rewriting needed; Next.js routes /docs/* here and the full URL is forwarded. +// Static assets are served by spiceflow's auto-injected serveStatic middleware. + +type HolocronModule = typeof import('example-basepath/dist/rsc') + +let holocronPromise: Promise | undefined + +function loadHolocron() { + holocronPromise ??= import('example-basepath/dist/rsc') as Promise + return holocronPromise +} + +async function handler(request: Request) { + const { app } = await loadHolocron() + return app.handle(request) +} + +export const GET = handler +export const POST = handler +export const PUT = handler +export const PATCH = handler +export const DELETE = handler +export const HEAD = handler +export const OPTIONS = handler diff --git a/example-inside-next/src/app/layout.tsx b/example-inside-next/src/app/layout.tsx new file mode 100644 index 00000000..35831ae4 --- /dev/null +++ b/example-inside-next/src/app/layout.tsx @@ -0,0 +1,9 @@ +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ) +} diff --git a/example-inside-next/src/app/page.tsx b/example-inside-next/src/app/page.tsx new file mode 100644 index 00000000..b1711bb9 --- /dev/null +++ b/example-inside-next/src/app/page.tsx @@ -0,0 +1,40 @@ +export default function Home() { + return ( +
+

Next.js + Holocron

+

+ This Next.js app mounts a Holocron docs site at{' '} + + /docs + + . The Holocron app is built separately and served via a catch-all route handler. +

+ + Go to Docs → + +
+ ) +} diff --git a/example-inside-next/tsconfig.json b/example-inside-next/tsconfig.json new file mode 100644 index 00000000..3938e07c --- /dev/null +++ b/example-inside-next/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + "next-env.d.ts", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/integration-tests/e2e/base-path/base-path.test.ts b/integration-tests/e2e/base-path/base-path.test.ts index 44c65145..570b343b 100644 --- a/integration-tests/e2e/base-path/base-path.test.ts +++ b/integration-tests/e2e/base-path/base-path.test.ts @@ -50,6 +50,38 @@ test.describe("raw markdown under base path", () => { }); }); +test.describe("API routes under base path", () => { + test("POST /docs/holocron-api/chat returns non-404", async ({ request }) => { + // The chat endpoint should be reachable under the base path. + // It may return 404 with "Assistant is disabled" body (which is the + // handler's own response, not a routing 404), or 200 if enabled. + const res = await request.post("/docs/holocron-api/chat", { + headers: { "content-type": "application/json" }, + data: JSON.stringify({ message: "hello", modelMessages: [], currentSlug: "/" }), + }); + // The route must exist — a routing-level 404 would mean the base path + // prefix is not wired. The handler itself returns 404 when assistant is + // disabled, but the body says "Assistant is disabled". + const body = await res.text(); + if (res.status() === 404) { + expect(body).toContain("Assistant is disabled"); + } else { + // If assistant is enabled, we just verify it didn't routing-404 + expect(res.status()).not.toBe(404); + } + }); + + test("POST /holocron-api/chat without base prefix is rejected by Vite", async ({ request }) => { + // Vite intercepts non-prefixed routes and returns its own 404 with a + // helpful redirect hint. The client must always use the base-prefixed URL. + const res = await request.post("/holocron-api/chat", { + headers: { "content-type": "application/json" }, + data: JSON.stringify({ message: "hello", modelMessages: [], currentSlug: "/" }), + }); + expect(res.status()).toBe(404); + }); +}); + test.describe("sitemap under base path", () => { test("GET /docs/sitemap.xml returns valid sitemap", async ({ request }) => { const res = await request.get("/docs/sitemap.xml"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01709ea6..0ab60ef0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,34 @@ importers: specifier: ^5.9.3 version: 5.9.3 + example-basepath: + dependencies: + '@holocron.so/vite': + specifier: workspace:^ + version: link:../vite + react: + specifier: ^19.2.5 + version: 19.2.5 + react-dom: + specifier: ^19.2.5 + version: 19.2.5(react@19.2.5) + spiceflow: + specifier: 1.24.4-rsc.0 + version: 1.24.4-rsc.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(zod@4.4.3) + vite: + specifier: ^8.0.10 + version: 8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + devDependencies: + '@types/react': + specifier: ^19.2.7 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + example-cloudflare: dependencies: '@holocron.so/vite': @@ -121,7 +149,7 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.32.2 - version: 1.32.2(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260507.1)(wrangler@4.90.0(@cloudflare/workers-types@4.20260409.1)) + version: 1.32.2(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260507.1)(wrangler@4.90.0) '@cloudflare/workers-types': specifier: ^4.20260408.0 version: 4.20260409.1 @@ -138,6 +166,31 @@ importers: specifier: ^4.90.0 version: 4.90.0(@cloudflare/workers-types@4.20260409.1) + example-inside-next: + dependencies: + example-basepath: + specifier: workspace:^ + version: link:../example-basepath + next: + specifier: ^16.2.6 + version: 16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: + specifier: ^19.2.5 + version: 19.2.5 + react-dom: + specifier: ^19.2.5 + version: 19.2.5(react@19.2.5) + devDependencies: + '@types/react': + specifier: ^19.2.7 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + integration-tests: dependencies: '@holocron.so/vite': @@ -443,7 +496,7 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.32.2 - version: 1.32.2(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260507.1)(wrangler@4.90.0(@cloudflare/workers-types@4.20260409.1)) + version: 1.32.2(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260507.1)(wrangler@4.90.0) '@types/node': specifier: ^25.6.0 version: 25.6.2 @@ -5261,7 +5314,7 @@ snapshots: optionalDependencies: workerd: 1.20260507.1 - '@cloudflare/vite-plugin@1.32.2(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260507.1)(wrangler@4.90.0(@cloudflare/workers-types@4.20260409.1))': + '@cloudflare/vite-plugin@1.32.2(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260507.1)(wrangler@4.90.0)': dependencies: '@cloudflare/unenv-preset': 2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260507.1) miniflare: 4.20260410.0 @@ -5768,8 +5821,7 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@16.2.6': - optional: true + '@next/env@16.2.6': {} '@next/swc-darwin-arm64@16.2.6': optional: true @@ -6648,7 +6700,6 @@ snapshots: '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 - optional: true '@tailwindcss/node@4.2.2': dependencies: @@ -7090,8 +7141,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.28: - optional: true + baseline-browser-mapping@2.10.28: {} better-auth@1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@1.0.0-beta.21(@opentelemetry/api@1.9.0)(@types/mssql@9.1.11(@azure/core-client@1.10.1))(mssql@11.0.1(@azure/core-client@1.10.1))(sql.js@1.14.1)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.4(@opentelemetry/api@1.9.0)(@types/node@25.6.2)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))): dependencies: @@ -7170,8 +7220,7 @@ snapshots: dependencies: run-applescript: 7.1.0 - caniuse-lite@1.0.30001792: - optional: true + caniuse-lite@1.0.30001792: {} ccount@2.0.1: {} @@ -7205,8 +7254,7 @@ snapshots: dependencies: clsx: 2.1.1 - client-only@0.0.1: - optional: true + client-only@0.0.1: {} cliui@9.0.1: dependencies: @@ -8580,7 +8628,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - optional: true nf3@0.3.16: {} @@ -8700,7 +8747,6 @@ snapshots: nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 - optional: true postcss@8.5.14: dependencies: @@ -9135,7 +9181,6 @@ snapshots: dependencies: client-only: 0.0.1 react: 19.2.5 - optional: true stylis@4.3.6: {}