doccupine 0.0.93 → 0.0.94

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/README.md CHANGED
@@ -14,6 +14,7 @@
14
14
  - **MCP server** - exposes `search_docs`, `get_doc`, and `list_docs` tools for AI agents
15
15
  - **Custom fonts** - Google Fonts or local fonts via `fonts.json`
16
16
  - **Static assets** - `public/` directory is watched and synced to the generated app
17
+ - **Password protection** - gate the whole site behind a shared password with `SITE_PASSWORD`
17
18
  - **Zero config to start** - `npx doccupine` scaffolds everything and starts the server
18
19
 
19
20
  ## Quick Start
@@ -226,6 +227,24 @@ The generated app exposes an MCP endpoint at `/api/mcp` with three tools:
226
227
 
227
228
  This lets AI agents (Claude, ChatGPT, etc.) query your docs programmatically. Requires the AI chat setup above for embeddings.
228
229
 
230
+ ## Password Protection
231
+
232
+ Gate your entire documentation site behind a shared password by setting `SITE_PASSWORD` in the generated app's environment - add it to `.env` for local development, or your host's environment variables in production:
233
+
234
+ ```env
235
+ SITE_PASSWORD=choose-a-strong-shared-password
236
+ ```
237
+
238
+ When set, every visitor sees a login screen until they enter the password. Protection is enforced across three layers:
239
+
240
+ - **Pages** are gated behind the login screen.
241
+ - **Content APIs** (`/api/rag` chat and `/api/search`) return `401` without a valid session, so the docs can't be scraped around the login.
242
+ - **Search engines and crawlers** are blocked: `robots.txt` disallows everything, pages carry a `noindex, nofollow` tag, and responses include an `X-Robots-Tag` header.
243
+
244
+ A successful login sets a signed, `httpOnly` cookie that lasts 30 days. The cookie stores an HMAC of the password, never the password itself. Leave `SITE_PASSWORD` unset (the default) to keep the site fully public. Documentation pages stay statically rendered either way - the gate is enforced in middleware.
245
+
246
+ > **Note:** The [MCP endpoint](#mcp-server) uses its own `DOCS_API_KEY` bearer token and is not affected by `SITE_PASSWORD`.
247
+
229
248
  ## License
230
249
 
231
250
  This project is licensed under a modified MIT license with a SaaS restriction. See [LICENSE](LICENSE) for details.
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
7
7
  import matter from "gray-matter";
8
8
  import chalk from "chalk";
9
9
  import { appStructure, startingDocsStructure } from "./lib/structures.js";
10
- import { layoutTemplate } from "./lib/layout.js";
10
+ import { rootLayoutTemplate, siteLayoutTemplate } from "./lib/layout.js";
11
11
  import { ConfigManager } from "./lib/config-manager.js";
12
12
  import { findAvailablePort, generateSlug, getFullSlug, escapeTemplateContent, } from "./lib/utils.js";
13
13
  import { generateMetadataBlock, generateRuntimeOnlyMetadataBlock, generateJsonLdScript, } from "./lib/metadata.js";
@@ -80,6 +80,13 @@ class MDXToNextJSGenerator {
80
80
  console.log(chalk.white(" npm install && npm run dev"));
81
81
  }
82
82
  async createNextJSStructure() {
83
+ // Clear the generated app/ directory first so a fresh run never inherits
84
+ // stale routes from a previous version (e.g. pages left at their old paths
85
+ // after a route-group move would collide with the newly generated ones).
86
+ // Everything under app/ is regenerated below and by processAllMDXFiles /
87
+ // generateSectionIndexPages, so nothing here is user-authored. Config JSONs
88
+ // and other generated dirs live outside app/ and are untouched.
89
+ await fs.remove(path.join(this.outputDir, "app"));
83
90
  const siteUrl = await this.loadSiteUrl();
84
91
  const structure = {
85
92
  ...appStructure,
@@ -94,6 +101,7 @@ class MDXToNextJSGenerator {
94
101
  "theme.json": `{}\n`,
95
102
  "app/robots.ts": robotsTemplate(siteUrl !== null),
96
103
  "app/layout.tsx": this.generateRootLayout(),
104
+ "app/(site)/layout.tsx": this.generateSiteLayout(),
97
105
  };
98
106
  for (const [filePath, content] of Object.entries(structure)) {
99
107
  const fullPath = path.join(this.outputDir, filePath);
@@ -710,7 +718,7 @@ class MDXToNextJSGenerator {
710
718
  // We don't have frontmatter for deleted files, so use directory-based matching
711
719
  const { sectionSlug, pageSlug } = this.determineSectionForFile(filePath, {});
712
720
  const fullSlug = getFullSlug(pageSlug, sectionSlug);
713
- const pagePath = path.join(this.outputDir, "app", fullSlug);
721
+ const pagePath = path.join(this.outputDir, "app", "(site)", fullSlug);
714
722
  await fs.remove(pagePath);
715
723
  }
716
724
  await this.updatePagesIndex();
@@ -749,10 +757,13 @@ class MDXToNextJSGenerator {
749
757
  return files;
750
758
  }
751
759
  async generateRootLayout() {
752
- const pages = await this.buildAllPagesMeta();
753
760
  const fontConfig = await this.loadFontConfig();
754
761
  const analyticsEnabled = this.analyticsConfig !== null;
755
- return layoutTemplate(pages, fontConfig, this.sectionsConfig, analyticsEnabled);
762
+ return rootLayoutTemplate(fontConfig, analyticsEnabled);
763
+ }
764
+ async generateSiteLayout() {
765
+ const pages = await this.buildAllPagesMeta();
766
+ return siteLayoutTemplate(pages, this.sectionsConfig);
756
767
  }
757
768
  async generateSectionIndexPages() {
758
769
  if (!this.sectionsConfig || this.sectionsConfig.length === 0)
@@ -782,7 +793,7 @@ export default function SectionIndex() {
782
793
  redirect("/${firstPage.slug}");
783
794
  }
784
795
  `;
785
- const pagePath = path.join(this.outputDir, "app", section.slug, "page.tsx");
796
+ const pagePath = path.join(this.outputDir, "app", "(site)", section.slug, "page.tsx");
786
797
  await fs.ensureDir(path.dirname(pagePath));
787
798
  await fs.writeFile(pagePath, redirectContent, "utf8");
788
799
  console.log(chalk.blue(`🔀 Generated section index redirect: /${section.slug} -> /${firstPage.slug}`));
@@ -838,7 +849,7 @@ export default function Page() {
838
849
  );
839
850
  }
840
851
  `;
841
- const pagePath = path.join(this.outputDir, "app", mdxFile.slug, "page.tsx");
852
+ const pagePath = path.join(this.outputDir, "app", "(site)", mdxFile.slug, "page.tsx");
842
853
  await fs.ensureDir(path.dirname(pagePath));
843
854
  await fs.writeFile(pagePath, pageContent, "utf8");
844
855
  }
@@ -908,7 +919,9 @@ export default function Home() {
908
919
  );
909
920
  }
910
921
  `;
911
- await fs.writeFile(path.join(this.outputDir, "app", "page.tsx"), indexContent, "utf8");
922
+ const homePath = path.join(this.outputDir, "app", "(site)", "page.tsx");
923
+ await fs.ensureDir(path.dirname(homePath));
924
+ await fs.writeFile(homePath, indexContent, "utf8");
912
925
  }
913
926
  async updateSectionIndex(sectionSlug, frontmatter, mdxContent) {
914
927
  const metadataBlock = generateMetadataBlock({
@@ -956,13 +969,15 @@ export default function Page() {
956
969
  );
957
970
  }
958
971
  `;
959
- const pagePath = path.join(this.outputDir, "app", sectionSlug, "page.tsx");
972
+ const pagePath = path.join(this.outputDir, "app", "(site)", sectionSlug, "page.tsx");
960
973
  await fs.ensureDir(path.dirname(pagePath));
961
974
  await fs.writeFile(pagePath, indexContent, "utf8");
962
975
  }
963
976
  async updateRootLayout() {
964
- const layoutContent = await this.generateRootLayout();
965
- await fs.writeFile(path.join(this.outputDir, "app", "layout.tsx"), layoutContent, "utf8");
977
+ await fs.writeFile(path.join(this.outputDir, "app", "layout.tsx"), await this.generateRootLayout(), "utf8");
978
+ const siteLayoutPath = path.join(this.outputDir, "app", "(site)", "layout.tsx");
979
+ await fs.ensureDir(path.dirname(siteLayoutPath));
980
+ await fs.writeFile(siteLayoutPath, await this.generateSiteLayout(), "utf8");
966
981
  }
967
982
  async loadSiteUrl() {
968
983
  const configPath = path.join(this.rootDir, "config.json");
@@ -26,5 +26,22 @@ interface FontConfig {
26
26
  src?: LocalFontSrc[];
27
27
  };
28
28
  }
29
- export declare const layoutTemplate: (pages: PageData[], fontConfig: FontConfig | null, sectionsConfig?: SectionConfig[] | null, analyticsEnabled?: boolean) => string;
29
+ /**
30
+ * Root layout ("app/layout.tsx"). Minimal shell: html/body, fonts, the theme
31
+ * provider stack, and (optionally) PostHog. It renders `children` directly so
32
+ * both the docs (via the "(site)" layout) and the password gate ("app/gate")
33
+ * share the same providers and theme without the docs chrome. Deliberately has
34
+ * NO request-time data (cookies/headers) so pages stay statically renderable —
35
+ * the SITE_PASSWORD gate is enforced in the middleware (proxy.ts), which
36
+ * rewrites locked page requests to "/gate".
37
+ */
38
+ export declare const rootLayoutTemplate: (fontConfig: FontConfig | null, analyticsEnabled?: boolean) => string;
39
+ /**
40
+ * Docs chrome layout ("app/(site)/layout.tsx"). Wraps every documentation page
41
+ * with the header, sidebar/section navigation, chat, and footer. Lives in the
42
+ * URL-transparent "(site)" route group so it wraps the docs but NOT the gate
43
+ * screen at "/gate", which renders under the root shell alone. Renders inside
44
+ * the root layout's providers, so it needs no html/body or theme provider.
45
+ */
46
+ export declare const siteLayoutTemplate: (pages: PageData[], sectionsConfig?: SectionConfig[] | null) => string;
30
47
  export {};
@@ -35,59 +35,54 @@ function getLocalFontSrc(fc) {
35
35
  return `"${fc.localFonts}"`;
36
36
  return JSON.stringify(fc.localFonts?.src, null, 2).replace(/"([^"]+)":/g, "$1:");
37
37
  }
38
- export const layoutTemplate = (pages, fontConfig, sectionsConfig = null, analyticsEnabled = false) => {
39
- const hasSections = sectionsConfig !== null && sectionsConfig.length > 0;
40
- // Extra indent when PostHogProvider wraps the inner content
41
- const a = analyticsEnabled ? " " : "";
42
- // ChtProvider line wraps at wider indent
43
- const chtOpen = analyticsEnabled
44
- ? `<ChtProvider
45
- ${a} isChatActive={process.env.LLM_PROVIDER ? true : false}
46
- ${a} >`
47
- : `<ChtProvider isChatActive={process.env.LLM_PROVIDER ? true : false}>`;
48
- return `import type { Metadata } from "next";
49
- ${isGoogleFont(fontConfig) ? `import { ${fontConfig.googleFont.fontName} } from "next/font/google";` : isLocalFont(fontConfig) ? 'import localFont from "next/font/local";' : 'import { Inter } from "next/font/google";'}
50
- import dynamic from "next/dynamic";
51
- import { StyledComponentsRegistry } from "cherry-styled-components";
52
- import { theme } from "@/app/theme";
53
- import { CherryThemeProvider } from "@/components/layout/CherryThemeProvider";
54
- import { ChtProvider } from "@/components/Chat";
55
- import { SearchProvider } from "@/components/SearchDocs";
56
- ${hasSections
57
- ? ""
58
- : `import { Footer } from "@/components/layout/Footer";
59
- `}import { Header } from "@/components/layout/Header";
60
- import { DocsWrapper } from "@/components/layout/DocsComponents";
61
- ${hasSections
62
- ? ""
63
- : `import { SectionBarProvider } from "@/components/layout/DocsComponents";
64
- import { SideBar } from "@/components/SideBar";
65
- import { DocsNavigation } from "@/components/layout/DocsNavigation";
66
- `}import { type PagesProps } from "@/utils/orderNavItems";
67
- ${hasSections
68
- ? ""
69
- : `import { transformPagesToGroupedStructure } from "@/utils/orderNavItems";
70
- `}
71
- ${hasSections
72
- ? ""
73
- : `import { StaticLinks } from "@/components/layout/StaticLinks";
74
- `}import { config } from "@/utils/config";
75
- import { verifyBrandingKey } from "@/utils/branding";
76
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
77
- import navigation from "@/navigation.json";
78
- ${hasSections
79
- ? `import { SectionBar } from "@/components/layout/SectionBar";
80
- import { SectionNavProvider } from "@/components/SectionNavProvider";
81
- `
82
- : ""}${analyticsEnabled ? `import { PostHogProvider } from "@/components/PostHogProvider";\n` : ""}const Chat = dynamic(() => import("@/components/Chat").then((mod) => mod.Chat));
83
-
84
- ${isGoogleFont(fontConfig)
85
- ? `const font = ${fontConfig.googleFont.fontName}({ ${[fontConfig.googleFont.subsets?.length ? `subsets: ${JSON.stringify(fontConfig.googleFont.subsets)}` : "", fontConfig.googleFont.weight?.length ? `weight: ${Array.isArray(fontConfig.googleFont.weight) ? JSON.stringify(fontConfig.googleFont.weight) : `"${fontConfig.googleFont.weight}"`}` : ""].filter(Boolean).join(", ")} });`
38
+ function fontImportLine(fontConfig) {
39
+ return isGoogleFont(fontConfig)
40
+ ? `import { ${fontConfig.googleFont.fontName} } from "next/font/google";`
41
+ : isLocalFont(fontConfig)
42
+ ? 'import localFont from "next/font/local";'
43
+ : 'import { Inter } from "next/font/google";';
44
+ }
45
+ function fontDeclLine(fontConfig) {
46
+ return isGoogleFont(fontConfig)
47
+ ? `const font = ${fontConfig.googleFont.fontName}({ ${[
48
+ fontConfig.googleFont.subsets?.length
49
+ ? `subsets: ${JSON.stringify(fontConfig.googleFont.subsets)}`
50
+ : "",
51
+ fontConfig.googleFont.weight?.length
52
+ ? `weight: ${Array.isArray(fontConfig.googleFont.weight)
53
+ ? JSON.stringify(fontConfig.googleFont.weight)
54
+ : `"${fontConfig.googleFont.weight}"`}`
55
+ : "",
56
+ ]
57
+ .filter(Boolean)
58
+ .join(", ")} });`
86
59
  : isLocalFont(fontConfig)
87
60
  ? `const font = localFont({
88
61
  src: ${getLocalFontSrc(fontConfig)},
89
62
  });`
90
- : 'const font = Inter({ subsets: ["latin"] });'}
63
+ : 'const font = Inter({ subsets: ["latin"] });';
64
+ }
65
+ // The inline blocking script that resolves dark mode before first paint by
66
+ // adding the "dark" class to <html>. Shared by the root layout's <head>.
67
+ const THEME_INIT_SCRIPT = `(function(){try{var c=document.cookie.split(";").map(function(s){return s.trim();}).find(function(s){return s.indexOf("theme=")===0;});var v=c?c.split("=")[1]:null;var d=v?v==="dark":(window.matchMedia&&window.matchMedia("(prefers-color-scheme:dark)").matches);if(!v){document.cookie="theme="+(d?"dark":"light")+";path=/;max-age=31536000;SameSite=Lax";}if(d){document.documentElement.classList.add("dark");document.documentElement.style.colorScheme="dark";}else{document.documentElement.style.colorScheme="light";}}catch(e){}})();`;
68
+ /**
69
+ * Root layout ("app/layout.tsx"). Minimal shell: html/body, fonts, the theme
70
+ * provider stack, and (optionally) PostHog. It renders `children` directly so
71
+ * both the docs (via the "(site)" layout) and the password gate ("app/gate")
72
+ * share the same providers and theme without the docs chrome. Deliberately has
73
+ * NO request-time data (cookies/headers) so pages stay statically renderable —
74
+ * the SITE_PASSWORD gate is enforced in the middleware (proxy.ts), which
75
+ * rewrites locked page requests to "/gate".
76
+ */
77
+ export const rootLayoutTemplate = (fontConfig, analyticsEnabled = false) => {
78
+ return `import type { Metadata } from "next";
79
+ ${fontImportLine(fontConfig)}
80
+ import { StyledComponentsRegistry } from "cherry-styled-components";
81
+ import { theme } from "@/app/theme";
82
+ import { CherryThemeProvider } from "@/components/layout/CherryThemeProvider";
83
+ import { config } from "@/utils/config";
84
+ ${analyticsEnabled ? `import { PostHogProvider } from "@/components/PostHogProvider";\n` : ""}
85
+ ${fontDeclLine(fontConfig)}
91
86
 
92
87
  function resolveSiteUrl(): URL | undefined {
93
88
  const raw = process.env.NEXT_PUBLIC_SITE_URL ?? config.url;
@@ -115,18 +110,11 @@ export const metadata: Metadata = {
115
110
  },
116
111
  };
117
112
 
118
- const doccupinePages = ${formatObjectArray(pages)};${hasSections ? `\nconst doccupineSections = ${formatObjectArray(sectionsConfig)};` : ""}
119
-
120
- export default async function RootLayout({
113
+ export default function RootLayout({
121
114
  children,
122
115
  }: Readonly<{
123
116
  children: React.ReactNode;
124
117
  }>) {
125
- const hideBranding = verifyBrandingKey();
126
- ${hasSections
127
- ? `
128
- const pages: PagesProps[] = doccupinePages;
129
-
130
118
  return (
131
119
  <html lang="en" suppressHydrationWarning>
132
120
  <head>
@@ -141,35 +129,94 @@ ${hasSections
141
129
  different between server (no class) and client (after script). */}
142
130
  <script
143
131
  dangerouslySetInnerHTML={{
144
- __html: \`(function(){try{var c=document.cookie.split(";").map(function(s){return s.trim();}).find(function(s){return s.indexOf("theme=")===0;});var v=c?c.split("=")[1]:null;var d=v?v==="dark":(window.matchMedia&&window.matchMedia("(prefers-color-scheme:dark)").matches);if(!v){document.cookie="theme="+(d?"dark":"light")+";path=/;max-age=31536000;SameSite=Lax";}if(d){document.documentElement.classList.add("dark");document.documentElement.style.colorScheme="dark";}else{document.documentElement.style.colorScheme="light";}}catch(e){}})();\`,
132
+ __html: \`${THEME_INIT_SCRIPT}\`,
145
133
  }}
146
134
  />
147
135
  </head>
148
136
  <body className={font.className}>
149
137
  <StyledComponentsRegistry>
150
- ${analyticsEnabled ? " <PostHogProvider>\n" : ""}${a} <CherryThemeProvider theme={theme}>
151
- ${a} ${chtOpen}
152
- ${a} <SearchProvider pages={pages} sections={doccupineSections}>
153
- ${a} <Header>
154
- ${a} <SectionBar sections={doccupineSections} />
155
- ${a} </Header>
156
- ${a} {process.env.LLM_PROVIDER && <Chat />}
157
- ${a} <DocsWrapper>
158
- ${a} <SectionNavProvider
159
- ${a} sections={doccupineSections}
160
- ${a} allPages={pages}
161
- ${a} hideBranding={hideBranding}
162
- ${a} >
163
- ${a} {children}
164
- ${a} </SectionNavProvider>
165
- ${a} </DocsWrapper>
166
- ${a} </SearchProvider>
167
- ${a} </ChtProvider>
168
- ${a} </CherryThemeProvider>
169
- ${analyticsEnabled ? " </PostHogProvider>\n" : ""} </StyledComponentsRegistry>
138
+ ${analyticsEnabled
139
+ ? ` <PostHogProvider>
140
+ <CherryThemeProvider theme={theme}>{children}</CherryThemeProvider>
141
+ </PostHogProvider>`
142
+ : ` <CherryThemeProvider theme={theme}>{children}</CherryThemeProvider>`}
143
+ </StyledComponentsRegistry>
170
144
  </body>
171
145
  </html>
172
146
  );
147
+ }
148
+ `;
149
+ };
150
+ /**
151
+ * Docs chrome layout ("app/(site)/layout.tsx"). Wraps every documentation page
152
+ * with the header, sidebar/section navigation, chat, and footer. Lives in the
153
+ * URL-transparent "(site)" route group so it wraps the docs but NOT the gate
154
+ * screen at "/gate", which renders under the root shell alone. Renders inside
155
+ * the root layout's providers, so it needs no html/body or theme provider.
156
+ */
157
+ export const siteLayoutTemplate = (pages, sectionsConfig = null) => {
158
+ const hasSections = sectionsConfig !== null && sectionsConfig.length > 0;
159
+ const chtOpen = `<ChtProvider isChatActive={process.env.LLM_PROVIDER ? true : false}>`;
160
+ return `import dynamic from "next/dynamic";
161
+ import { ChtProvider } from "@/components/Chat";
162
+ import { SearchProvider } from "@/components/SearchDocs";
163
+ ${hasSections
164
+ ? ""
165
+ : `import { Footer } from "@/components/layout/Footer";
166
+ `}import { Header } from "@/components/layout/Header";
167
+ import { DocsWrapper } from "@/components/layout/DocsComponents";
168
+ ${hasSections
169
+ ? ""
170
+ : `import { SectionBarProvider } from "@/components/layout/DocsComponents";
171
+ import { SideBar } from "@/components/SideBar";
172
+ import { DocsNavigation } from "@/components/layout/DocsNavigation";
173
+ `}import { type PagesProps } from "@/utils/orderNavItems";
174
+ ${hasSections
175
+ ? ""
176
+ : `import { transformPagesToGroupedStructure } from "@/utils/orderNavItems";
177
+ `}${hasSections
178
+ ? ""
179
+ : `import { StaticLinks } from "@/components/layout/StaticLinks";
180
+ `}import { verifyBrandingKey } from "@/utils/branding";
181
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
182
+ import navigation from "@/navigation.json";
183
+ ${hasSections
184
+ ? `import { SectionBar } from "@/components/layout/SectionBar";
185
+ import { SectionNavProvider } from "@/components/SectionNavProvider";
186
+ `
187
+ : ""}const Chat = dynamic(() => import("@/components/Chat").then((mod) => mod.Chat));
188
+
189
+ const doccupinePages = ${formatObjectArray(pages)};${hasSections ? `\nconst doccupineSections = ${formatObjectArray(sectionsConfig)};` : ""}
190
+
191
+ export default function SiteLayout({
192
+ children,
193
+ }: Readonly<{
194
+ children: React.ReactNode;
195
+ }>) {
196
+ const hideBranding = verifyBrandingKey();
197
+ ${hasSections
198
+ ? `
199
+ const pages: PagesProps[] = doccupinePages;
200
+
201
+ return (
202
+ ${chtOpen}
203
+ <SearchProvider pages={pages} sections={doccupineSections}>
204
+ <Header>
205
+ <SectionBar sections={doccupineSections} />
206
+ </Header>
207
+ {process.env.LLM_PROVIDER && <Chat />}
208
+ <DocsWrapper>
209
+ <SectionNavProvider
210
+ sections={doccupineSections}
211
+ allPages={pages}
212
+ hideBranding={hideBranding}
213
+ >
214
+ {children}
215
+ </SectionNavProvider>
216
+ </DocsWrapper>
217
+ </SearchProvider>
218
+ </ChtProvider>
219
+ );
173
220
  }`
174
221
  : `
175
222
  const defaultPages = [
@@ -193,47 +240,21 @@ ${analyticsEnabled ? " </PostHogProvider>\n" : ""} </StyledCompo
193
240
  const defaultResults = transformPagesToGroupedStructure(defaultPages);
194
241
 
195
242
  return (
196
- <html lang="en" suppressHydrationWarning>
197
- <head>
198
- {/* Resolves dark mode before first paint by adding the "dark" class
199
- to <html> when needed. CSS variables in GlobalStyles flip values
200
- on :root vs :root.dark, so the right palette renders without a
201
- React roundtrip. Inlined as a plain <script> (not next/script) so
202
- it ships in the SSR HTML and runs synchronously before paint —
203
- next/script with beforeInteractive is async in App Router and
204
- would still show a flash. suppressHydrationWarning on <html>
205
- tells React the class/colorScheme attributes are intentionally
206
- different between server (no class) and client (after script). */}
207
- <script
208
- dangerouslySetInnerHTML={{
209
- __html: \`(function(){try{var c=document.cookie.split(";").map(function(s){return s.trim();}).find(function(s){return s.indexOf("theme=")===0;});var v=c?c.split("=")[1]:null;var d=v?v==="dark":(window.matchMedia&&window.matchMedia("(prefers-color-scheme:dark)").matches);if(!v){document.cookie="theme="+(d?"dark":"light")+";path=/;max-age=31536000;SameSite=Lax";}if(d){document.documentElement.classList.add("dark");document.documentElement.style.colorScheme="dark";}else{document.documentElement.style.colorScheme="light";}}catch(e){}})();\`,
210
- }}
211
- />
212
- </head>
213
- <body className={font.className}>
214
- <StyledComponentsRegistry>
215
- ${analyticsEnabled ? " <PostHogProvider>\n" : ""}${a} <CherryThemeProvider theme={theme}>
216
- ${a} ${chtOpen}
217
- ${a} <SearchProvider pages={pages}>
218
- ${a} <Header />
219
- ${a} {process.env.LLM_PROVIDER && <Chat />}
220
- ${a} <SectionBarProvider hasSectionBar={false}>
221
- ${a} <DocsWrapper>
222
- ${a} <SideBar result={result.length ? result : defaultResults} />
223
- ${a} {children}
224
- ${a} <DocsNavigation
225
- ${a} result={result.length ? result : defaultResults}
226
- ${a} />
227
- ${a} <StaticLinks />
228
- ${a} <Footer hideBranding={hideBranding} />
229
- ${a} </DocsWrapper>
230
- ${a} </SectionBarProvider>
231
- ${a} </SearchProvider>
232
- ${a} </ChtProvider>
233
- ${a} </CherryThemeProvider>
234
- ${analyticsEnabled ? " </PostHogProvider>\n" : ""} </StyledComponentsRegistry>
235
- </body>
236
- </html>
243
+ ${chtOpen}
244
+ <SearchProvider pages={pages}>
245
+ <Header />
246
+ {process.env.LLM_PROVIDER && <Chat />}
247
+ <SectionBarProvider hasSectionBar={false}>
248
+ <DocsWrapper>
249
+ <SideBar result={result.length ? result : defaultResults} />
250
+ {children}
251
+ <DocsNavigation result={result.length ? result : defaultResults} />
252
+ <StaticLinks />
253
+ <Footer hideBranding={hideBranding} />
254
+ </DocsWrapper>
255
+ </SectionBarProvider>
256
+ </SearchProvider>
257
+ </ChtProvider>
237
258
  );
238
259
  }`}
239
260
  `;
@@ -5,6 +5,8 @@ import { packageJsonTemplate } from "../templates/package.js";
5
5
  import { prettierrcTemplate } from "../templates/prettierrc.js";
6
6
  import { prettierignoreTemplate } from "../templates/prettierignore.js";
7
7
  import { tsconfigTemplate } from "../templates/tsconfig.js";
8
+ import { gatePageTemplate } from "../templates/app/gate/page.js";
9
+ import { gateRoutesTemplate } from "../templates/app/api/gate/route.js";
8
10
  import { mcpRoutesTemplate } from "../templates/app/api/mcp/route.js";
9
11
  import { ragRoutesTemplate } from "../templates/app/api/rag/route.js";
10
12
  import { searchRoutesTemplate } from "../templates/app/api/search/route.js";
@@ -45,6 +47,7 @@ import { headerTemplate } from "../templates/components/layout/Header.js";
45
47
  import { iconTemplate } from "../templates/components/layout/Icon.js";
46
48
  import { pictogramsTemplate } from "../templates/components/layout/Pictograms.js";
47
49
  import { sharedStyledTemplate } from "../templates/components/layout/SharedStyles.js";
50
+ import { siteGateComponentTemplate } from "../templates/components/layout/SiteGate.js";
48
51
  import { staticLinksTemplate } from "../templates/components/layout/StaticLinks.js";
49
52
  import { stepsTemplate } from "../templates/components/layout/Steps.js";
50
53
  import { tabsTemplate } from "../templates/components/layout/Tabs.js";
@@ -61,6 +64,7 @@ import { llmFactoryTemplate } from "../templates/services/llm/factory.js";
61
64
  import { llmIndexTemplate } from "../templates/services/llm/index.js";
62
65
  import { llmTypesTemplate } from "../templates/services/llm/types.js";
63
66
  import { posthogServerTemplate } from "../templates/lib/posthog.js";
67
+ import { siteGateTemplate } from "../templates/lib/siteGate.js";
64
68
  import { styledDTemplate } from "../templates/types/styled.js";
65
69
  import { orderNavItemsTemplate } from "../templates/utils/orderNavItems.js";
66
70
  import { rateLimitTemplate } from "../templates/utils/rateLimit.js";
@@ -69,6 +73,7 @@ import { configTemplate } from "../templates/utils/config.js";
69
73
  import { accordionMdxTemplate } from "../templates/mdx/accordion.mdx.js";
70
74
  import { aiAssistantMdxTemplate } from "../templates/mdx/ai-assistant.mdx.js";
71
75
  import { analyticsMdxTemplate } from "../templates/mdx/analytics.mdx.js";
76
+ import { authenticationMdxTemplate } from "../templates/mdx/authentication.mdx.js";
72
77
  import { buttonsMdxTemplate } from "../templates/mdx/buttons.mdx.js";
73
78
  import { calloutsMdxTemplate } from "../templates/mdx/callouts.mdx.js";
74
79
  import { cardsMdxTemplate } from "../templates/mdx/cards.mdx.js";
@@ -119,8 +124,10 @@ export const appStructure = {
119
124
  "eslint.config.mjs": eslintConfigTemplate,
120
125
  "package.json": packageJsonTemplate,
121
126
  "tsconfig.json": tsconfigTemplate,
122
- "app/not-found.tsx": notFoundTemplate,
127
+ "app/(site)/not-found.tsx": notFoundTemplate,
128
+ "app/gate/page.tsx": gatePageTemplate,
123
129
  "app/theme.ts": themeTemplate,
130
+ "app/api/gate/route.ts": gateRoutesTemplate,
124
131
  "app/api/mcp/route.ts": mcpRoutesTemplate,
125
132
  "app/api/rag/route.ts": ragRoutesTemplate,
126
133
  "app/api/search/route.ts": searchRoutesTemplate,
@@ -136,6 +143,7 @@ export const appStructure = {
136
143
  "services/llm/types.ts": llmTypesTemplate,
137
144
  "types/styled.d.ts": styledDTemplate,
138
145
  "lib/posthog.ts": posthogServerTemplate,
146
+ "lib/siteGate.ts": siteGateTemplate,
139
147
  "utils/branding.ts": brandingTemplate,
140
148
  "utils/orderNavItems.ts": orderNavItemsTemplate,
141
149
  "utils/rateLimit.ts": rateLimitTemplate,
@@ -174,6 +182,7 @@ export const appStructure = {
174
182
  "components/layout/Icon.tsx": iconTemplate,
175
183
  "components/layout/Pictograms.tsx": pictogramsTemplate,
176
184
  "components/layout/SharedStyled.ts": sharedStyledTemplate,
185
+ "components/layout/SiteGate.tsx": siteGateComponentTemplate,
177
186
  "components/layout/StaticLinks.tsx": staticLinksTemplate,
178
187
  "components/layout/Steps.tsx": stepsTemplate,
179
188
  "components/layout/Tabs.tsx": tabsTemplate,
@@ -185,6 +194,7 @@ export const startingDocsStructure = {
185
194
  "accordion.mdx": accordionMdxTemplate,
186
195
  "ai-assistant.mdx": aiAssistantMdxTemplate,
187
196
  "analytics.mdx": analyticsMdxTemplate,
197
+ "authentication.mdx": authenticationMdxTemplate,
188
198
  "buttons.mdx": buttonsMdxTemplate,
189
199
  "callouts.mdx": calloutsMdxTemplate,
190
200
  "cards.mdx": cardsMdxTemplate,
@@ -0,0 +1 @@
1
+ export declare const gateRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport { cookies } from \"next/headers\";\nimport { GATE_COOKIE_NAME, gateToken, timingSafeEqual } from \"@/lib/siteGate\";\nimport { rateLimit } from \"@/utils/rateLimit\";\n\nexport async function POST(req: Request) {\n const password = process.env.SITE_PASSWORD;\n if (!password) {\n return NextResponse.json({ ok: true });\n }\n\n // Rate limit password attempts by IP to slow down brute-force guessing.\n const ip =\n req.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim() ?? \"unknown\";\n const { allowed, retryAfter } = rateLimit(ip);\n if (!allowed) {\n return NextResponse.json(\n { ok: false, error: \"Too many attempts\" },\n { status: 429, headers: { \"Retry-After\": String(retryAfter) } },\n );\n }\n\n let submitted: unknown;\n try {\n submitted = (await req.json())?.password;\n } catch {\n return NextResponse.json(\n { ok: false, error: \"Bad Request\" },\n { status: 400 },\n );\n }\n\n const expected = await gateToken(password);\n const candidate = await gateToken(\n typeof submitted === \"string\" ? submitted : \"\",\n );\n\n if (!timingSafeEqual(candidate, expected)) {\n return NextResponse.json(\n { ok: false, error: \"Incorrect password\" },\n { status: 401 },\n );\n }\n\n const cookieStore = await cookies();\n cookieStore.set(GATE_COOKIE_NAME, expected, {\n httpOnly: true,\n secure: process.env.NODE_ENV === \"production\",\n sameSite: \"lax\",\n path: \"/\",\n maxAge: 60 * 60 * 24 * 30, // 30 days\n });\n\n return NextResponse.json({ ok: true });\n}\n";
@@ -0,0 +1,56 @@
1
+ export const gateRoutesTemplate = `import { NextResponse } from "next/server";
2
+ import { cookies } from "next/headers";
3
+ import { GATE_COOKIE_NAME, gateToken, timingSafeEqual } from "@/lib/siteGate";
4
+ import { rateLimit } from "@/utils/rateLimit";
5
+
6
+ export async function POST(req: Request) {
7
+ const password = process.env.SITE_PASSWORD;
8
+ if (!password) {
9
+ return NextResponse.json({ ok: true });
10
+ }
11
+
12
+ // Rate limit password attempts by IP to slow down brute-force guessing.
13
+ const ip =
14
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
15
+ const { allowed, retryAfter } = rateLimit(ip);
16
+ if (!allowed) {
17
+ return NextResponse.json(
18
+ { ok: false, error: "Too many attempts" },
19
+ { status: 429, headers: { "Retry-After": String(retryAfter) } },
20
+ );
21
+ }
22
+
23
+ let submitted: unknown;
24
+ try {
25
+ submitted = (await req.json())?.password;
26
+ } catch {
27
+ return NextResponse.json(
28
+ { ok: false, error: "Bad Request" },
29
+ { status: 400 },
30
+ );
31
+ }
32
+
33
+ const expected = await gateToken(password);
34
+ const candidate = await gateToken(
35
+ typeof submitted === "string" ? submitted : "",
36
+ );
37
+
38
+ if (!timingSafeEqual(candidate, expected)) {
39
+ return NextResponse.json(
40
+ { ok: false, error: "Incorrect password" },
41
+ { status: 401 },
42
+ );
43
+ }
44
+
45
+ const cookieStore = await cookies();
46
+ cookieStore.set(GATE_COOKIE_NAME, expected, {
47
+ httpOnly: true,
48
+ secure: process.env.NODE_ENV === "production",
49
+ sameSite: "lax",
50
+ path: "/",
51
+ maxAge: 60 * 60 * 24 * 30, // 30 days
52
+ });
53
+
54
+ return NextResponse.json({ ok: true });
55
+ }
56
+ `;
@@ -0,0 +1 @@
1
+ export declare const gatePageTemplate = "import type { Metadata } from \"next\";\nimport { SiteGate } from \"@/components/layout/SiteGate\";\nimport { verifyBrandingKey } from \"@/utils/branding\";\n\n// The gate screen is never indexed. The middleware also sets X-Robots-Tag and\n// robots.txt disallows everything while SITE_PASSWORD is active.\nexport const metadata: Metadata = {\n robots: { index: false, follow: false },\n};\n\n// The login form is identical for every visitor, so it can render statically.\n// The middleware (proxy.ts) decides who is shown this screen by rewriting\n// locked requests here; unlocked visitors are redirected back to the site.\nexport const dynamic = \"force-static\";\nexport const revalidate = false;\n\nexport default function GatePage() {\n const hideBranding = verifyBrandingKey();\n return <SiteGate hideBranding={hideBranding} />;\n}\n";
@@ -0,0 +1,21 @@
1
+ export const gatePageTemplate = `import type { Metadata } from "next";
2
+ import { SiteGate } from "@/components/layout/SiteGate";
3
+ import { verifyBrandingKey } from "@/utils/branding";
4
+
5
+ // The gate screen is never indexed. The middleware also sets X-Robots-Tag and
6
+ // robots.txt disallows everything while SITE_PASSWORD is active.
7
+ export const metadata: Metadata = {
8
+ robots: { index: false, follow: false },
9
+ };
10
+
11
+ // The login form is identical for every visitor, so it can render statically.
12
+ // The middleware (proxy.ts) decides who is shown this screen by rewriting
13
+ // locked requests here; unlocked visitors are redirected back to the site.
14
+ export const dynamic = "force-static";
15
+ export const revalidate = false;
16
+
17
+ export default function GatePage() {
18
+ const hideBranding = verifyBrandingKey();
19
+ return <SiteGate hideBranding={hideBranding} />;
20
+ }
21
+ `;