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.
@@ -3,6 +3,16 @@ export const robotsTemplate = (hasSiteUrl) => {
3
3
  return `import type { MetadataRoute } from "next";
4
4
 
5
5
  export default function robots(): MetadataRoute.Robots {
6
+ // When the site is password protected, keep every crawler out entirely.
7
+ if (process.env.SITE_PASSWORD) {
8
+ return {
9
+ rules: {
10
+ userAgent: "*",
11
+ disallow: "/",
12
+ },
13
+ };
14
+ }
15
+
6
16
  return {
7
17
  rules: {
8
18
  userAgent: "*",
@@ -22,6 +32,17 @@ function resolveBaseUrl(): string | null {
22
32
  }
23
33
 
24
34
  export default function robots(): MetadataRoute.Robots {
35
+ // When the site is password protected, keep every crawler out entirely and
36
+ // don't advertise the sitemap.
37
+ if (process.env.SITE_PASSWORD) {
38
+ return {
39
+ rules: {
40
+ userAgent: "*",
41
+ disallow: "/",
42
+ },
43
+ };
44
+ }
45
+
25
46
  const base = resolveBaseUrl();
26
47
  const rules: MetadataRoute.Robots = {
27
48
  rules: {
@@ -18,13 +18,7 @@ export const sitemapTemplate = (entries) => {
18
18
  import { config } from "@/utils/config";
19
19
 
20
20
  type ChangeFrequency =
21
- | "always"
22
- | "hourly"
23
- | "daily"
24
- | "weekly"
25
- | "monthly"
26
- | "yearly"
27
- | "never";
21
+ "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
28
22
 
29
23
  interface Entry {
30
24
  slug: string;
@@ -1 +1 @@
1
- export declare const buttonTemplate = "\"use client\";\nimport Link from \"next/link\";\nimport styled from \"styled-components\";\nimport {\n theme as localTheme,\n ButtonProps,\n buttonStyles,\n} from \"cherry-styled-components\";\nimport { Icon } from \"@/components/layout/Icon\";\n\ninterface LinkButtonProps extends ButtonProps {\n href?: string;\n target?: \"_blank\" | \"_self\" | \"_parent\" | \"_top\";\n rel?: string;\n variant?: \"primary\" | \"secondary\" | \"tertiary\";\n size?: \"default\" | \"big\";\n outline?: boolean;\n fullWidth?: boolean;\n icon?: string;\n iconPosition?: \"left\" | \"right\";\n theme?: typeof localTheme;\n}\n\n// Cherry's buttonStyles picks filled-button text via `isDark ? colors.dark : colors.light`.\n// Our theme's isDark is a stub (false) because mode switching lives in CSS vars, so\n// the fallback resolves to --color-light (black in dark mode). Re-pin to `surface`,\n// which resolves to white in both modes, for the filled, non-disabled case.\nconst StyledLinkButton = styled(Link)<LinkButtonProps>`\n ${({ theme, $variant, $size, $outline, $fullWidth, disabled }) =>\n buttonStyles(theme, $variant, $size, $outline, $fullWidth, disabled)}\n\n ${({ theme, $outline, disabled }) =>\n !disabled && !$outline && `color: ${theme.colors.surface};`}\n\n & p {\n color: inherit !important;\n }\n\n & svg.lucide {\n margin: auto 0;\n min-width: min-content;\n color: inherit;\n }\n`;\n\nconst ButtonBase = styled.button<ButtonProps>`\n ${({ theme, $variant, $size, $outline, $fullWidth, disabled }) =>\n buttonStyles(theme, $variant, $size, $outline, $fullWidth, disabled)}\n\n ${({ theme, $outline, disabled }) =>\n !disabled && !$outline && `color: ${theme.colors.surface};`}\n\n & p {\n color: inherit !important;\n }\n\n & svg.lucide {\n margin: auto 0;\n min-width: min-content;\n color: inherit;\n }\n`;\n\nfunction Button({\n variant = \"primary\",\n size,\n outline,\n fullWidth,\n icon,\n iconPosition = \"left\",\n theme: _theme = localTheme,\n href,\n target,\n rel,\n ...props\n}: LinkButtonProps) {\n const isExternal = typeof href === \"string\" && /^(https?:)?\\/\\//i.test(href);\n const resolvedTarget = target ?? (isExternal ? \"_blank\" : undefined);\n const resolvedRel =\n rel ?? (resolvedTarget === \"_blank\" ? \"noopener noreferrer\" : undefined);\n return href ? (\n <div>\n <StyledLinkButton\n {...props}\n href={href}\n target={resolvedTarget}\n rel={resolvedRel}\n $variant={variant}\n $size={size}\n $outline={outline}\n $fullWidth={fullWidth}\n >\n {iconPosition === \"left\" && icon && <Icon name={icon} size={16} />}\n {props.children}\n {iconPosition === \"right\" && icon && <Icon name={icon} size={16} />}\n </StyledLinkButton>\n </div>\n ) : (\n <div>\n <ButtonBase\n {...props}\n $variant={variant}\n $size={size}\n $outline={outline}\n $fullWidth={fullWidth}\n >\n {iconPosition === \"left\" && icon && <Icon name={icon} size={16} />}\n {props.children}\n {iconPosition === \"right\" && icon && <Icon name={icon} size={16} />}\n </ButtonBase>\n </div>\n );\n}\n\nexport { Button };\n";
1
+ export declare const buttonTemplate = "\"use client\";\nimport Link from \"next/link\";\nimport styled from \"styled-components\";\nimport {\n theme as localTheme,\n ButtonProps,\n buttonStyles,\n} from \"cherry-styled-components\";\nimport { Icon } from \"@/components/layout/Icon\";\n\ninterface LinkButtonProps extends ButtonProps {\n href?: string;\n target?: \"_blank\" | \"_self\" | \"_parent\" | \"_top\";\n rel?: string;\n variant?: \"primary\" | \"secondary\" | \"tertiary\";\n size?: \"default\" | \"big\";\n outline?: boolean;\n fullWidth?: boolean;\n icon?: string;\n iconPosition?: \"left\" | \"right\";\n theme?: typeof localTheme;\n}\n\n// Cherry's buttonStyles picks filled-button text via `isDark ? colors.dark : colors.light`.\n// Our theme's isDark is a stub (false) because mode switching lives in CSS vars, so\n// the fallback resolves to --color-light (black in dark mode). Re-pin to `surface`,\n// which resolves to white in both modes, for the filled, non-disabled case.\nconst StyledLinkButton = styled(Link)<LinkButtonProps>`\n ${({ theme, $variant, $size, $outline, $fullWidth, $error, disabled }) =>\n buttonStyles(\n theme,\n $variant,\n $size,\n $outline,\n $fullWidth,\n $error,\n disabled,\n )}\n\n ${({ theme, $outline, disabled }) =>\n !disabled && !$outline && `color: ${theme.colors.surface};`}\n\n & p {\n color: inherit !important;\n }\n\n & svg.lucide {\n margin: auto 0;\n min-width: min-content;\n color: inherit;\n }\n`;\n\nconst ButtonBase = styled.button<ButtonProps>`\n ${({ theme, $variant, $size, $outline, $fullWidth, $error, disabled }) =>\n buttonStyles(\n theme,\n $variant,\n $size,\n $outline,\n $fullWidth,\n $error,\n disabled,\n )}\n\n ${({ theme, $outline, disabled }) =>\n !disabled && !$outline && `color: ${theme.colors.surface};`}\n\n & p {\n color: inherit !important;\n }\n\n & svg.lucide {\n margin: auto 0;\n min-width: min-content;\n color: inherit;\n }\n`;\n\nfunction Button({\n variant = \"primary\",\n size,\n outline,\n fullWidth,\n icon,\n iconPosition = \"left\",\n theme: _theme = localTheme,\n href,\n target,\n rel,\n ...props\n}: LinkButtonProps) {\n const isExternal = typeof href === \"string\" && /^(https?:)?\\/\\//i.test(href);\n const resolvedTarget = target ?? (isExternal ? \"_blank\" : undefined);\n const resolvedRel =\n rel ?? (resolvedTarget === \"_blank\" ? \"noopener noreferrer\" : undefined);\n return href ? (\n <div>\n <StyledLinkButton\n {...props}\n href={href}\n target={resolvedTarget}\n rel={resolvedRel}\n $variant={variant}\n $size={size}\n $outline={outline}\n $fullWidth={fullWidth}\n >\n {iconPosition === \"left\" && icon && <Icon name={icon} size={16} />}\n {props.children}\n {iconPosition === \"right\" && icon && <Icon name={icon} size={16} />}\n </StyledLinkButton>\n </div>\n ) : (\n <div>\n <ButtonBase\n {...props}\n $variant={variant}\n $size={size}\n $outline={outline}\n $fullWidth={fullWidth}\n >\n {iconPosition === \"left\" && icon && <Icon name={icon} size={16} />}\n {props.children}\n {iconPosition === \"right\" && icon && <Icon name={icon} size={16} />}\n </ButtonBase>\n </div>\n );\n}\n\nexport { Button };\n";
@@ -26,8 +26,16 @@ interface LinkButtonProps extends ButtonProps {
26
26
  // the fallback resolves to --color-light (black in dark mode). Re-pin to \`surface\`,
27
27
  // which resolves to white in both modes, for the filled, non-disabled case.
28
28
  const StyledLinkButton = styled(Link)<LinkButtonProps>\`
29
- \${({ theme, $variant, $size, $outline, $fullWidth, disabled }) =>
30
- buttonStyles(theme, $variant, $size, $outline, $fullWidth, disabled)}
29
+ \${({ theme, $variant, $size, $outline, $fullWidth, $error, disabled }) =>
30
+ buttonStyles(
31
+ theme,
32
+ $variant,
33
+ $size,
34
+ $outline,
35
+ $fullWidth,
36
+ $error,
37
+ disabled,
38
+ )}
31
39
 
32
40
  \${({ theme, $outline, disabled }) =>
33
41
  !disabled && !$outline && \`color: \${theme.colors.surface};\`}
@@ -44,8 +52,16 @@ const StyledLinkButton = styled(Link)<LinkButtonProps>\`
44
52
  \`;
45
53
 
46
54
  const ButtonBase = styled.button<ButtonProps>\`
47
- \${({ theme, $variant, $size, $outline, $fullWidth, disabled }) =>
48
- buttonStyles(theme, $variant, $size, $outline, $fullWidth, disabled)}
55
+ \${({ theme, $variant, $size, $outline, $fullWidth, $error, disabled }) =>
56
+ buttonStyles(
57
+ theme,
58
+ $variant,
59
+ $size,
60
+ $outline,
61
+ $fullWidth,
62
+ $error,
63
+ disabled,
64
+ )}
49
65
 
50
66
  \${({ theme, $outline, disabled }) =>
51
67
  !disabled && !$outline && \`color: \${theme.colors.surface};\`}
@@ -0,0 +1 @@
1
+ export declare const siteGateComponentTemplate = "\"use client\";\nimport { Suspense, useState, type FormEvent } from \"react\";\nimport styled from \"styled-components\";\nimport { Input } from \"cherry-styled-components\";\nimport { Lock } from \"lucide-react\";\nimport { Theme } from \"@/app/theme\";\nimport { Button } from \"@/components/layout/Button\";\nimport { Callout } from \"@/components/layout/Callout\";\nimport {\n ToggleTheme,\n ToggleThemeLoading,\n} from \"@/components/layout/ThemeToggle\";\nimport { config } from \"@/utils/config\";\n\nconst StyledWrapper = styled.div<{ theme: Theme }>`\n min-height: 100dvh;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 20px;\n background: ${({ theme }) => theme.colors.light};\n`;\n\nconst StyledInner = styled.div`\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 20px;\n width: 100%;\n max-width: 380px;\n`;\n\nconst StyledFooter = styled.div`\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 12px;\n`;\n\nconst StyledBranding = styled.p<{ theme: Theme }>`\n margin: 0;\n font-size: 14px;\n color: ${({ theme }) => theme.colors.grayDark};\n\n & a {\n color: ${({ theme }) => theme.colors.accent};\n font-weight: 700;\n text-decoration: none;\n transition: all 0.3s ease;\n }\n\n & a:hover {\n color: ${({ theme }) => theme.colors.primary};\n }\n`;\n\nconst StyledCard = styled.div<{ theme: Theme }>`\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 20px;\n width: 100%;\n padding: 40px 32px;\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n box-shadow: ${({ theme }) => theme.shadows.sm};\n text-align: center;\n`;\n\nconst StyledIcon = styled.div<{ theme: Theme }>`\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 48px;\n height: 48px;\n border-radius: 50%;\n color: ${({ theme }) => theme.colors.accent};\n background: ${({ theme }) => theme.colors.grayLight};\n\n & svg {\n width: 22px;\n height: 22px;\n }\n`;\n\nconst StyledTitle = styled.h1<{ theme: Theme }>`\n margin: 0;\n font-size: 22px;\n color: ${({ theme }) => theme.colors.accent};\n`;\n\nconst StyledLede = styled.p<{ theme: Theme }>`\n margin: 0;\n font-size: 15px;\n color: ${({ theme }) => theme.colors.grayDark};\n`;\n\nconst StyledForm = styled.form`\n display: flex;\n flex-direction: column;\n gap: 20px;\n width: 100%;\n`;\n\nconst StyledAlert = styled.div`\n width: 100%;\n text-align: left;\n`;\n\nexport function SiteGate({ hideBranding }: { hideBranding?: boolean }) {\n const [password, setPassword] = useState(\"\");\n const [status, setStatus] = useState<\"idle\" | \"loading\" | \"error\">(\"idle\");\n\n async function handleSubmit(event: FormEvent<HTMLFormElement>) {\n event.preventDefault();\n if (!password || status === \"loading\") return;\n\n setStatus(\"loading\");\n try {\n const res = await fetch(\"/api/gate\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ password }),\n });\n if (res.ok) {\n window.location.reload();\n return;\n }\n setStatus(\"error\");\n } catch {\n setStatus(\"error\");\n }\n }\n\n return (\n <StyledWrapper>\n <StyledInner>\n <StyledCard>\n <StyledIcon>\n <Lock />\n </StyledIcon>\n <StyledTitle>{config.name || \"Documentation\"}</StyledTitle>\n <StyledLede>This site is password protected.</StyledLede>\n <StyledForm onSubmit={handleSubmit} noValidate>\n <Input\n type=\"password\"\n name=\"password\"\n autoComplete=\"current-password\"\n placeholder=\"Enter password\"\n aria-label=\"Password\"\n autoFocus\n value={password}\n $error={status === \"error\"}\n $fullWidth\n onChange={(e) => {\n setPassword(e.target.value);\n if (status === \"error\") setStatus(\"idle\");\n }}\n />\n <Button\n type=\"submit\"\n fullWidth\n disabled={status === \"loading\" || !password}\n >\n {status === \"loading\" ? \"Unlocking...\" : \"Enter\"}\n </Button>\n </StyledForm>\n {status === \"error\" && (\n <StyledAlert>\n <Callout type=\"danger\">\n <p>Incorrect password. Try again.</p>\n </Callout>\n </StyledAlert>\n )}\n </StyledCard>\n <StyledFooter>\n <Suspense fallback={<ToggleThemeLoading />}>\n <ToggleTheme />\n </Suspense>\n {!hideBranding && (\n <StyledBranding>\n Powered by <a href=\"https://doccupine.com\">Doccupine</a>\n </StyledBranding>\n )}\n </StyledFooter>\n </StyledInner>\n </StyledWrapper>\n );\n}\n";
@@ -0,0 +1,191 @@
1
+ export const siteGateComponentTemplate = `"use client";
2
+ import { Suspense, useState, type FormEvent } from "react";
3
+ import styled from "styled-components";
4
+ import { Input } from "cherry-styled-components";
5
+ import { Lock } from "lucide-react";
6
+ import { Theme } from "@/app/theme";
7
+ import { Button } from "@/components/layout/Button";
8
+ import { Callout } from "@/components/layout/Callout";
9
+ import {
10
+ ToggleTheme,
11
+ ToggleThemeLoading,
12
+ } from "@/components/layout/ThemeToggle";
13
+ import { config } from "@/utils/config";
14
+
15
+ const StyledWrapper = styled.div<{ theme: Theme }>\`
16
+ min-height: 100dvh;
17
+ display: flex;
18
+ align-items: center;
19
+ justify-content: center;
20
+ padding: 20px;
21
+ background: \${({ theme }) => theme.colors.light};
22
+ \`;
23
+
24
+ const StyledInner = styled.div\`
25
+ display: flex;
26
+ flex-direction: column;
27
+ align-items: center;
28
+ gap: 20px;
29
+ width: 100%;
30
+ max-width: 380px;
31
+ \`;
32
+
33
+ const StyledFooter = styled.div\`
34
+ display: flex;
35
+ flex-direction: column;
36
+ align-items: center;
37
+ gap: 12px;
38
+ \`;
39
+
40
+ const StyledBranding = styled.p<{ theme: Theme }>\`
41
+ margin: 0;
42
+ font-size: 14px;
43
+ color: \${({ theme }) => theme.colors.grayDark};
44
+
45
+ & a {
46
+ color: \${({ theme }) => theme.colors.accent};
47
+ font-weight: 700;
48
+ text-decoration: none;
49
+ transition: all 0.3s ease;
50
+ }
51
+
52
+ & a:hover {
53
+ color: \${({ theme }) => theme.colors.primary};
54
+ }
55
+ \`;
56
+
57
+ const StyledCard = styled.div<{ theme: Theme }>\`
58
+ display: flex;
59
+ flex-direction: column;
60
+ align-items: center;
61
+ gap: 20px;
62
+ width: 100%;
63
+ padding: 40px 32px;
64
+ background: \${({ theme }) => theme.colors.light};
65
+ border: solid 1px \${({ theme }) => theme.colors.grayLight};
66
+ border-radius: \${({ theme }) => theme.spacing.radius.lg};
67
+ box-shadow: \${({ theme }) => theme.shadows.sm};
68
+ text-align: center;
69
+ \`;
70
+
71
+ const StyledIcon = styled.div<{ theme: Theme }>\`
72
+ display: inline-flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ width: 48px;
76
+ height: 48px;
77
+ border-radius: 50%;
78
+ color: \${({ theme }) => theme.colors.accent};
79
+ background: \${({ theme }) => theme.colors.grayLight};
80
+
81
+ & svg {
82
+ width: 22px;
83
+ height: 22px;
84
+ }
85
+ \`;
86
+
87
+ const StyledTitle = styled.h1<{ theme: Theme }>\`
88
+ margin: 0;
89
+ font-size: 22px;
90
+ color: \${({ theme }) => theme.colors.accent};
91
+ \`;
92
+
93
+ const StyledLede = styled.p<{ theme: Theme }>\`
94
+ margin: 0;
95
+ font-size: 15px;
96
+ color: \${({ theme }) => theme.colors.grayDark};
97
+ \`;
98
+
99
+ const StyledForm = styled.form\`
100
+ display: flex;
101
+ flex-direction: column;
102
+ gap: 20px;
103
+ width: 100%;
104
+ \`;
105
+
106
+ const StyledAlert = styled.div\`
107
+ width: 100%;
108
+ text-align: left;
109
+ \`;
110
+
111
+ export function SiteGate({ hideBranding }: { hideBranding?: boolean }) {
112
+ const [password, setPassword] = useState("");
113
+ const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
114
+
115
+ async function handleSubmit(event: FormEvent<HTMLFormElement>) {
116
+ event.preventDefault();
117
+ if (!password || status === "loading") return;
118
+
119
+ setStatus("loading");
120
+ try {
121
+ const res = await fetch("/api/gate", {
122
+ method: "POST",
123
+ headers: { "Content-Type": "application/json" },
124
+ body: JSON.stringify({ password }),
125
+ });
126
+ if (res.ok) {
127
+ window.location.reload();
128
+ return;
129
+ }
130
+ setStatus("error");
131
+ } catch {
132
+ setStatus("error");
133
+ }
134
+ }
135
+
136
+ return (
137
+ <StyledWrapper>
138
+ <StyledInner>
139
+ <StyledCard>
140
+ <StyledIcon>
141
+ <Lock />
142
+ </StyledIcon>
143
+ <StyledTitle>{config.name || "Documentation"}</StyledTitle>
144
+ <StyledLede>This site is password protected.</StyledLede>
145
+ <StyledForm onSubmit={handleSubmit} noValidate>
146
+ <Input
147
+ type="password"
148
+ name="password"
149
+ autoComplete="current-password"
150
+ placeholder="Enter password"
151
+ aria-label="Password"
152
+ autoFocus
153
+ value={password}
154
+ $error={status === "error"}
155
+ $fullWidth
156
+ onChange={(e) => {
157
+ setPassword(e.target.value);
158
+ if (status === "error") setStatus("idle");
159
+ }}
160
+ />
161
+ <Button
162
+ type="submit"
163
+ fullWidth
164
+ disabled={status === "loading" || !password}
165
+ >
166
+ {status === "loading" ? "Unlocking..." : "Enter"}
167
+ </Button>
168
+ </StyledForm>
169
+ {status === "error" && (
170
+ <StyledAlert>
171
+ <Callout type="danger">
172
+ <p>Incorrect password. Try again.</p>
173
+ </Callout>
174
+ </StyledAlert>
175
+ )}
176
+ </StyledCard>
177
+ <StyledFooter>
178
+ <Suspense fallback={<ToggleThemeLoading />}>
179
+ <ToggleTheme />
180
+ </Suspense>
181
+ {!hideBranding && (
182
+ <StyledBranding>
183
+ Powered by <a href="https://doccupine.com">Doccupine</a>
184
+ </StyledBranding>
185
+ )}
186
+ </StyledFooter>
187
+ </StyledInner>
188
+ </StyledWrapper>
189
+ );
190
+ }
191
+ `;
@@ -1 +1 @@
1
- export declare const envExampleTemplate = "# Public Site URL\n# Used by sitemap.xml and robots.txt. Overrides `url` in config.json when set.\n# NEXT_PUBLIC_SITE_URL=https://docs.example.com\n\n# LLM Provider Configuration\n# Choose your preferred LLM provider: openai, anthropic, or google\nLLM_PROVIDER=openai\n\n# API Keys (set the one matching your provider)\nOPENAI_API_KEY=your_openai_api_key_here\nANTHROPIC_API_KEY=your_anthropic_api_key_here\nGOOGLE_API_KEY=your_google_api_key_here\n\n# Optional: Override default chat model\n# See available models at your provider's docs:\n# OpenAI: https://platform.openai.com/docs/models\n# Anthropic: https://docs.anthropic.com/claude/docs/models-overview\n# Google: https://ai.google.dev/models/gemini\n# LLM_CHAT_MODEL=\n\n# Optional: Override default embedding model\n# See available embedding models at your provider's docs:\n# OpenAI: https://platform.openai.com/docs/guides/embeddings\n# Google: https://ai.google.dev/gemini-api/docs/embeddings\n# Note: Anthropic doesn't provide embeddings, will fallback to OpenAI\n# LLM_EMBEDDING_MODEL=\n\n# Optional: Set temperature (0-1, default: 0)\n# LLM_TEMPERATURE=0\n";
1
+ export declare const envExampleTemplate = "# Public Site URL\n# Used by sitemap.xml and robots.txt. Overrides `url` in config.json when set.\n# NEXT_PUBLIC_SITE_URL=https://docs.example.com\n\n# Password Protection (optional)\n# Set a shared password to gate the whole site behind a login screen. When set,\n# pages require the password, the content APIs (chat + search) return 401\n# without it, and the site is hidden from search engines and crawlers. Leave\n# unset (or remove) to keep the site public.\n# SITE_PASSWORD=choose-a-strong-shared-password\n\n# LLM Provider Configuration\n# Choose your preferred LLM provider: openai, anthropic, or google\nLLM_PROVIDER=openai\n\n# API Keys (set the one matching your provider)\nOPENAI_API_KEY=your_openai_api_key_here\nANTHROPIC_API_KEY=your_anthropic_api_key_here\nGOOGLE_API_KEY=your_google_api_key_here\n\n# Optional: Override default chat model\n# See available models at your provider's docs:\n# OpenAI: https://platform.openai.com/docs/models\n# Anthropic: https://docs.anthropic.com/claude/docs/models-overview\n# Google: https://ai.google.dev/models/gemini\n# LLM_CHAT_MODEL=\n\n# Optional: Override default embedding model\n# See available embedding models at your provider's docs:\n# OpenAI: https://platform.openai.com/docs/guides/embeddings\n# Google: https://ai.google.dev/gemini-api/docs/embeddings\n# Note: Anthropic doesn't provide embeddings, will fallback to OpenAI\n# LLM_EMBEDDING_MODEL=\n\n# Optional: Set temperature (0-1, default: 0)\n# LLM_TEMPERATURE=0\n";
@@ -2,6 +2,13 @@ export const envExampleTemplate = `# Public Site URL
2
2
  # Used by sitemap.xml and robots.txt. Overrides \`url\` in config.json when set.
3
3
  # NEXT_PUBLIC_SITE_URL=https://docs.example.com
4
4
 
5
+ # Password Protection (optional)
6
+ # Set a shared password to gate the whole site behind a login screen. When set,
7
+ # pages require the password, the content APIs (chat + search) return 401
8
+ # without it, and the site is hidden from search engines and crawlers. Leave
9
+ # unset (or remove) to keep the site public.
10
+ # SITE_PASSWORD=choose-a-strong-shared-password
11
+
5
12
  # LLM Provider Configuration
6
13
  # Choose your preferred LLM provider: openai, anthropic, or google
7
14
  LLM_PROVIDER=openai
@@ -0,0 +1 @@
1
+ export declare const siteGateTemplate = "/**\n * Shared site-gate crypto. The gate cookie never stores the password itself -\n * it stores HMAC-SHA256(key=SITE_PASSWORD, data=GATE_MESSAGE) as hex. Uses Web\n * Crypto (crypto.subtle) so the same helpers run in both the Edge middleware\n * (proxy.ts) and the Node route handler (app/api/gate/route.ts).\n */\nexport const GATE_COOKIE_NAME = \"doccupine_gate\";\n\n// Bump the suffix to invalidate every previously issued cookie at once.\nconst GATE_MESSAGE = \"doccupine-gate-v1\";\n\nasync function hmacHex(key: string, data: string): Promise<string> {\n const encoder = new TextEncoder();\n const cryptoKey = await crypto.subtle.importKey(\n \"raw\",\n encoder.encode(key),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const signature = await crypto.subtle.sign(\n \"HMAC\",\n cryptoKey,\n encoder.encode(data),\n );\n return Array.from(new Uint8Array(signature))\n .map((byte) => byte.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n\n/** Derive the gate cookie token for a given password. */\nexport function gateToken(password: string): Promise<string> {\n return hmacHex(password, GATE_MESSAGE);\n}\n\n/** Constant-time comparison so a mismatch never leaks token bytes via timing. */\nexport function timingSafeEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n let mismatch = 0;\n for (let i = 0; i < a.length; i++) {\n mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);\n }\n return mismatch === 0;\n}\n\n/**\n * Whether the request carrying the given cookie value is allowed past the gate.\n * Returns true (open) when no password is configured.\n */\nexport async function isGateUnlocked(\n cookieValue: string | undefined,\n password: string | undefined = process.env.SITE_PASSWORD,\n): Promise<boolean> {\n if (!password) return true;\n if (!cookieValue) return false;\n const expected = await gateToken(password);\n return timingSafeEqual(cookieValue, expected);\n}\n";
@@ -0,0 +1,59 @@
1
+ export const siteGateTemplate = `/**
2
+ * Shared site-gate crypto. The gate cookie never stores the password itself -
3
+ * it stores HMAC-SHA256(key=SITE_PASSWORD, data=GATE_MESSAGE) as hex. Uses Web
4
+ * Crypto (crypto.subtle) so the same helpers run in both the Edge middleware
5
+ * (proxy.ts) and the Node route handler (app/api/gate/route.ts).
6
+ */
7
+ export const GATE_COOKIE_NAME = "doccupine_gate";
8
+
9
+ // Bump the suffix to invalidate every previously issued cookie at once.
10
+ const GATE_MESSAGE = "doccupine-gate-v1";
11
+
12
+ async function hmacHex(key: string, data: string): Promise<string> {
13
+ const encoder = new TextEncoder();
14
+ const cryptoKey = await crypto.subtle.importKey(
15
+ "raw",
16
+ encoder.encode(key),
17
+ { name: "HMAC", hash: "SHA-256" },
18
+ false,
19
+ ["sign"],
20
+ );
21
+ const signature = await crypto.subtle.sign(
22
+ "HMAC",
23
+ cryptoKey,
24
+ encoder.encode(data),
25
+ );
26
+ return Array.from(new Uint8Array(signature))
27
+ .map((byte) => byte.toString(16).padStart(2, "0"))
28
+ .join("");
29
+ }
30
+
31
+ /** Derive the gate cookie token for a given password. */
32
+ export function gateToken(password: string): Promise<string> {
33
+ return hmacHex(password, GATE_MESSAGE);
34
+ }
35
+
36
+ /** Constant-time comparison so a mismatch never leaks token bytes via timing. */
37
+ export function timingSafeEqual(a: string, b: string): boolean {
38
+ if (a.length !== b.length) return false;
39
+ let mismatch = 0;
40
+ for (let i = 0; i < a.length; i++) {
41
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
42
+ }
43
+ return mismatch === 0;
44
+ }
45
+
46
+ /**
47
+ * Whether the request carrying the given cookie value is allowed past the gate.
48
+ * Returns true (open) when no password is configured.
49
+ */
50
+ export async function isGateUnlocked(
51
+ cookieValue: string | undefined,
52
+ password: string | undefined = process.env.SITE_PASSWORD,
53
+ ): Promise<boolean> {
54
+ if (!password) return true;
55
+ if (!cookieValue) return false;
56
+ const expected = await gateToken(password);
57
+ return timingSafeEqual(cookieValue, expected);
58
+ }
59
+ `;
@@ -0,0 +1 @@
1
+ export declare const authenticationMdxTemplate = "---\ntitle: \"Authentication\"\ndescription: \"Password protect your documentation site with a single environment variable that gates pages, content APIs, and search crawlers behind a shared password.\"\ndate: \"2026-07-01\"\ncategory: \"Configuration\"\ncategoryOrder: 3\norder: 12\n---\n\n# Authentication\n\nKeep your documentation private by putting the whole site behind a shared password. Set one environment variable and Doccupine gates every page behind a login screen, blocks the content APIs, and hides the site from search engines - no accounts, no database, no extra services.\n\n## Enabling password protection\n\nSet `SITE_PASSWORD` in your environment (for local development, add it to `.env`):\n\n```bash\nSITE_PASSWORD=choose-a-strong-shared-password\n```\n\nThat is the only setting. Everyone who visits the site shares this single password.\n\n<Callout type=\"note\">\n When `SITE_PASSWORD` is unset or empty, the site is fully public and behaves exactly as before. Removing the variable turns protection off again.\n</Callout>\n\n## What gets protected\n\nWhile a password is set, protection is enforced across three layers:\n\n- **Pages**: Every page renders a login screen instead of your documentation until the visitor enters the correct password.\n- **Content APIs**: The AI chat (`/api/rag`) and search (`/api/search`) endpoints return `401 Unauthorized` without a valid session, so the docs cannot be scraped around the login screen.\n- **Search engines and crawlers**: `robots.txt` disallows all crawlers, every page ships a `noindex, nofollow` meta tag, and responses carry an `X-Robots-Tag: noindex, nofollow` header.\n\n<Callout type=\"note\">\n The [MCP server](/model-context-protocol) keeps its own authentication through the `DOCS_API_KEY` bearer token and is not affected by `SITE_PASSWORD`. Set both when you want the docs and the MCP endpoint locked down.\n</Callout>\n\n## How it works\n\nWhen a visitor submits the correct password, Doccupine sets a signed, `httpOnly` cookie that unlocks the site for 30 days. The cookie never stores the password itself - it holds an HMAC derived from it - and it is checked in constant time to avoid leaking information through timing.\n\nBecause the check reads `SITE_PASSWORD` at request time, you can turn protection on or off by changing the environment variable and redeploying. No rebuild of your content is required.\n\n## Security notes\n\n- **Use a strong password.** It is shared by everyone with access, so treat it like any other shared secret and rotate it when needed.\n- **Serve over HTTPS in production.** The session cookie is marked `secure` in production, so it is only sent over encrypted connections.\n- **Brute-force protection.** Password attempts are rate limited by IP address to slow down guessing.\n- **Rotating the password.** Changing `SITE_PASSWORD` invalidates every existing session immediately - visitors will need to enter the new password.\n\n<Callout type=\"warning\">\n A shared password is meant for lightweight gating - private previews, internal docs, or work-in-progress sites. For per-user access control or audit trails, put the site behind your own identity provider or a hosting platform's access controls.\n</Callout>\n\n## Behavior\n\n- **Runtime toggle**: Protection is controlled entirely by the `SITE_PASSWORD` environment variable, evaluated on each request.\n- **Graceful default**: With no password set, nothing changes - the site stays public and fully indexable.\n- **Session length**: A successful login lasts 30 days before the visitor is asked to sign in again.\n\n## Example\n\nProtect a staging deployment while it is being reviewed:\n\n```bash\nSITE_PASSWORD=preview-2026\nNEXT_PUBLIC_SITE_URL=https://staging.example.com\n```\n\nWhen you are ready to launch publicly, remove `SITE_PASSWORD` and redeploy. The login screen disappears, crawlers are welcomed back, and your [sitemap](/deployment-and-hosting) is advertised again.";
@@ -0,0 +1,72 @@
1
+ export const authenticationMdxTemplate = `---
2
+ title: "Authentication"
3
+ description: "Password protect your documentation site with a single environment variable that gates pages, content APIs, and search crawlers behind a shared password."
4
+ date: "2026-07-01"
5
+ category: "Configuration"
6
+ categoryOrder: 3
7
+ order: 12
8
+ ---
9
+
10
+ # Authentication
11
+
12
+ Keep your documentation private by putting the whole site behind a shared password. Set one environment variable and Doccupine gates every page behind a login screen, blocks the content APIs, and hides the site from search engines - no accounts, no database, no extra services.
13
+
14
+ ## Enabling password protection
15
+
16
+ Set \`SITE_PASSWORD\` in your environment (for local development, add it to \`.env\`):
17
+
18
+ \`\`\`bash
19
+ SITE_PASSWORD=choose-a-strong-shared-password
20
+ \`\`\`
21
+
22
+ That is the only setting. Everyone who visits the site shares this single password.
23
+
24
+ <Callout type="note">
25
+ When \`SITE_PASSWORD\` is unset or empty, the site is fully public and behaves exactly as before. Removing the variable turns protection off again.
26
+ </Callout>
27
+
28
+ ## What gets protected
29
+
30
+ While a password is set, protection is enforced across three layers:
31
+
32
+ - **Pages**: Every page renders a login screen instead of your documentation until the visitor enters the correct password.
33
+ - **Content APIs**: The AI chat (\`/api/rag\`) and search (\`/api/search\`) endpoints return \`401 Unauthorized\` without a valid session, so the docs cannot be scraped around the login screen.
34
+ - **Search engines and crawlers**: \`robots.txt\` disallows all crawlers, every page ships a \`noindex, nofollow\` meta tag, and responses carry an \`X-Robots-Tag: noindex, nofollow\` header.
35
+
36
+ <Callout type="note">
37
+ The [MCP server](/model-context-protocol) keeps its own authentication through the \`DOCS_API_KEY\` bearer token and is not affected by \`SITE_PASSWORD\`. Set both when you want the docs and the MCP endpoint locked down.
38
+ </Callout>
39
+
40
+ ## How it works
41
+
42
+ When a visitor submits the correct password, Doccupine sets a signed, \`httpOnly\` cookie that unlocks the site for 30 days. The cookie never stores the password itself - it holds an HMAC derived from it - and it is checked in constant time to avoid leaking information through timing.
43
+
44
+ Because the check reads \`SITE_PASSWORD\` at request time, you can turn protection on or off by changing the environment variable and redeploying. No rebuild of your content is required.
45
+
46
+ ## Security notes
47
+
48
+ - **Use a strong password.** It is shared by everyone with access, so treat it like any other shared secret and rotate it when needed.
49
+ - **Serve over HTTPS in production.** The session cookie is marked \`secure\` in production, so it is only sent over encrypted connections.
50
+ - **Brute-force protection.** Password attempts are rate limited by IP address to slow down guessing.
51
+ - **Rotating the password.** Changing \`SITE_PASSWORD\` invalidates every existing session immediately - visitors will need to enter the new password.
52
+
53
+ <Callout type="warning">
54
+ A shared password is meant for lightweight gating - private previews, internal docs, or work-in-progress sites. For per-user access control or audit trails, put the site behind your own identity provider or a hosting platform's access controls.
55
+ </Callout>
56
+
57
+ ## Behavior
58
+
59
+ - **Runtime toggle**: Protection is controlled entirely by the \`SITE_PASSWORD\` environment variable, evaluated on each request.
60
+ - **Graceful default**: With no password set, nothing changes - the site stays public and fully indexable.
61
+ - **Session length**: A successful login lasts 30 days before the visitor is asked to sign in again.
62
+
63
+ ## Example
64
+
65
+ Protect a staging deployment while it is being reviewed:
66
+
67
+ \`\`\`bash
68
+ SITE_PASSWORD=preview-2026
69
+ NEXT_PUBLIC_SITE_URL=https://staging.example.com
70
+ \`\`\`
71
+
72
+ When you are ready to launch publicly, remove \`SITE_PASSWORD\` and redeploy. The login screen disappears, crawlers are welcomed back, and your [sitemap](/deployment-and-hosting) is advertised again.`;
@@ -10,39 +10,39 @@ export const packageJsonTemplate = JSON.stringify({
10
10
  format: "prettier --write .",
11
11
  },
12
12
  dependencies: {
13
- "@langchain/anthropic": "^1.4.0",
14
- "@langchain/core": "^1.1.48",
15
- "@langchain/google-genai": "^2.1.31",
16
- "@langchain/openai": "^1.4.7",
13
+ "@langchain/anthropic": "^1.5.1",
14
+ "@langchain/core": "^1.2.1",
15
+ "@langchain/google-genai": "^2.2.0",
16
+ "@langchain/openai": "^1.5.3",
17
17
  "@mdx-js/react": "^3.1.1",
18
18
  "@modelcontextprotocol/sdk": "^1.29.0",
19
- "@posthog/react": "^1.10.1",
20
- "cherry-styled-components": "^0.1.17",
21
- langchain: "^1.4.4",
22
- "lucide-react": "^1.17.0",
19
+ "@posthog/react": "^1.10.3",
20
+ "cherry-styled-components": "^0.1.18",
21
+ langchain: "^1.5.2",
22
+ "lucide-react": "^1.23.0",
23
23
  minisearch: "^7.2.0",
24
24
  next: "16.2.9",
25
25
  "next-mdx-remote": "^6.0.0",
26
- "posthog-js": "^1.384.1",
27
- "posthog-node": "^5.36.10",
26
+ "posthog-js": "^1.396.4",
27
+ "posthog-node": "^5.39.2",
28
28
  react: "19.2.7",
29
29
  "react-dom": "19.2.7",
30
30
  "rehype-highlight": "^7.0.2",
31
31
  "rehype-parse": "^9.0.1",
32
32
  "rehype-stringify": "^10.0.1",
33
33
  "remark-gfm": "^4.0.1",
34
- "styled-components": "^6.4.2",
34
+ "styled-components": "^6.4.3",
35
35
  unified: "^11.0.5",
36
36
  zod: "^4.4.3",
37
37
  },
38
38
  devDependencies: {
39
- "@types/node": "^25",
39
+ "@types/node": "^26",
40
40
  "@types/react": "^19",
41
41
  "@types/react-dom": "^19",
42
- "baseline-browser-mapping": "^2.10.35",
42
+ "baseline-browser-mapping": "^2.10.40",
43
43
  eslint: "^9",
44
44
  "eslint-config-next": "16.2.9",
45
- prettier: "^3.8.4",
45
+ prettier: "^3.9.4",
46
46
  typescript: "^6",
47
47
  },
48
48
  }, null, 2) + "\n";
@@ -1 +1 @@
1
- export declare const pnpmWorkspaceTemplate = "allowBuilds:\n core-js: false\n protobufjs: false\n sharp: false\n unrs-resolver: false\nminimumReleaseAgeExclude:\n - '@next/env@16.2.9'\n - '@next/eslint-plugin-next@16.2.9'\n - '@next/swc-darwin-arm64@16.2.9'\n - '@next/swc-darwin-x64@16.2.9'\n - '@next/swc-linux-arm64-gnu@16.2.9'\n - '@next/swc-linux-arm64-musl@16.2.9'\n - '@next/swc-linux-x64-gnu@16.2.9'\n - '@next/swc-linux-x64-musl@16.2.9'\n - '@next/swc-win32-arm64-msvc@16.2.9'\n - '@next/swc-win32-x64-msvc@16.2.9'\n - '@posthog/core@1.31.1'\n - '@posthog/types@1.384.1'\n - baseline-browser-mapping@2.10.35\n - eslint-config-next@16.2.9\n - next@16.2.9\n - posthog-js@1.384.1\n - posthog-node@5.36.10\n - prettier@3.8.4\n";
1
+ export declare const pnpmWorkspaceTemplate = "allowBuilds:\n core-js: false\n protobufjs: false\n sharp: false\n unrs-resolver: false\n# Disable pnpm's minimum-release-age supply-chain gate for the generated app.\n# The gate blocks freshly published versions, which trips installs whenever the\n# pinned dependencies here are newer than the cutoff. Set to 0 to install any\n# resolved version without waiting out the release-age window.\nminimumReleaseAge: 0\n";
@@ -3,23 +3,9 @@ export const pnpmWorkspaceTemplate = `allowBuilds:
3
3
  protobufjs: false
4
4
  sharp: false
5
5
  unrs-resolver: false
6
- minimumReleaseAgeExclude:
7
- - '@next/env@16.2.9'
8
- - '@next/eslint-plugin-next@16.2.9'
9
- - '@next/swc-darwin-arm64@16.2.9'
10
- - '@next/swc-darwin-x64@16.2.9'
11
- - '@next/swc-linux-arm64-gnu@16.2.9'
12
- - '@next/swc-linux-arm64-musl@16.2.9'
13
- - '@next/swc-linux-x64-gnu@16.2.9'
14
- - '@next/swc-linux-x64-musl@16.2.9'
15
- - '@next/swc-win32-arm64-msvc@16.2.9'
16
- - '@next/swc-win32-x64-msvc@16.2.9'
17
- - '@posthog/core@1.31.1'
18
- - '@posthog/types@1.384.1'
19
- - baseline-browser-mapping@2.10.35
20
- - eslint-config-next@16.2.9
21
- - next@16.2.9
22
- - posthog-js@1.384.1
23
- - posthog-node@5.36.10
24
- - prettier@3.8.4
6
+ # Disable pnpm's minimum-release-age supply-chain gate for the generated app.
7
+ # The gate blocks freshly published versions, which trips installs whenever the
8
+ # pinned dependencies here are newer than the cutoff. Set to 0 to install any
9
+ # resolved version without waiting out the release-age window.
10
+ minimumReleaseAge: 0
25
11
  `;