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 +19 -0
- package/dist/index.js +25 -10
- package/dist/lib/layout.d.ts +18 -1
- package/dist/lib/layout.js +140 -119
- package/dist/lib/structures.js +11 -1
- package/dist/templates/app/api/gate/route.d.ts +1 -0
- package/dist/templates/app/api/gate/route.js +56 -0
- package/dist/templates/app/gate/page.d.ts +1 -0
- package/dist/templates/app/gate/page.js +21 -0
- package/dist/templates/app/robots.js +21 -0
- package/dist/templates/app/sitemap.js +1 -7
- package/dist/templates/components/layout/Button.d.ts +1 -1
- package/dist/templates/components/layout/Button.js +20 -4
- package/dist/templates/components/layout/SiteGate.d.ts +1 -0
- package/dist/templates/components/layout/SiteGate.js +191 -0
- package/dist/templates/env.example.d.ts +1 -1
- package/dist/templates/env.example.js +7 -0
- package/dist/templates/lib/siteGate.d.ts +1 -0
- package/dist/templates/lib/siteGate.js +59 -0
- package/dist/templates/mdx/authentication.mdx.d.ts +1 -0
- package/dist/templates/mdx/authentication.mdx.js +72 -0
- package/dist/templates/package.js +14 -14
- package/dist/templates/pnpmWorkspace.d.ts +1 -1
- package/dist/templates/pnpmWorkspace.js +5 -19
- package/dist/templates/proxy.js +64 -16
- package/package.json +4 -4
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
965
|
-
|
|
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");
|
package/dist/lib/layout.d.ts
CHANGED
|
@@ -26,5 +26,22 @@ interface FontConfig {
|
|
|
26
26
|
src?: LocalFontSrc[];
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
-
|
|
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 {};
|
package/dist/lib/layout.js
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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:
|
|
132
|
+
__html: \`${THEME_INIT_SCRIPT}\`,
|
|
145
133
|
}}
|
|
146
134
|
/>
|
|
147
135
|
</head>
|
|
148
136
|
<body className={font.className}>
|
|
149
137
|
<StyledComponentsRegistry>
|
|
150
|
-
${analyticsEnabled
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
197
|
-
<
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
`;
|
package/dist/lib/structures.js
CHANGED
|
@@ -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
|
+
`;
|