idcmd 0.0.10 → 0.0.12

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.
@@ -0,0 +1,36 @@
1
+ export type Provider = "none" | "vercel" | "fly" | "railway";
2
+ export type DeployProvider = Exclude<Provider, "none">;
3
+
4
+ export interface ProviderFlags {
5
+ fly?: boolean;
6
+ railway?: boolean;
7
+ vercel?: boolean;
8
+ }
9
+
10
+ const DEPLOY_PROVIDERS: readonly DeployProvider[] = [
11
+ "vercel",
12
+ "fly",
13
+ "railway",
14
+ ];
15
+
16
+ const isFlagEnabled = (value: boolean | undefined): boolean => value === true;
17
+
18
+ const selectedProviders = (flags: ProviderFlags): DeployProvider[] =>
19
+ DEPLOY_PROVIDERS.filter((provider) => isFlagEnabled(flags[provider]));
20
+
21
+ const formatProviderFlags = (providers: readonly DeployProvider[]): string =>
22
+ providers.map((provider) => `--${provider}`).join(" ");
23
+
24
+ export const resolveProviderFromFlags = (flags: ProviderFlags): Provider => {
25
+ const selected = selectedProviders(flags);
26
+ if (selected.length > 1) {
27
+ throw new Error(
28
+ `Choose exactly one provider. Received ${formatProviderFlags(selected)}.`
29
+ );
30
+ }
31
+ return selected[0] ?? "none";
32
+ };
33
+
34
+ export const isDeployProvider = (
35
+ provider: Provider
36
+ ): provider is DeployProvider => provider !== "none";
@@ -1,7 +1,4 @@
1
- /* eslint-disable react/no-danger */
2
- import type { JSX } from "preact";
3
-
4
- import { render } from "preact-render-to-string";
1
+ /* eslint-disable react/jsx-key */
5
2
 
6
3
  import type { NavGroup, NavItem } from "../content/navigation";
7
4
  import type { ResolvedRightRailConfig } from "../site/config";
@@ -10,6 +7,8 @@ import type { TocItem } from "./toc";
10
7
 
11
8
  import { RightRail } from "./right-rail";
12
9
 
10
+ const escapeText = (value: string): string => Bun.escapeHTML(value);
11
+
13
12
  export interface LayoutProps {
14
13
  title: string;
15
14
  siteName: string;
@@ -31,10 +30,7 @@ export interface LayoutProps {
31
30
  export type RenderLayout = (props: LayoutProps) => string;
32
31
 
33
32
  const Icon = ({ svg }: { svg: string }): JSX.Element => (
34
- <span
35
- class="inline-flex w-[18px] h-[18px]"
36
- dangerouslySetInnerHTML={{ __html: svg }}
37
- />
33
+ <span class="inline-flex w-[18px] h-[18px]">{svg}</span>
38
34
  );
39
35
 
40
36
  const isActiveLink = (item: NavItem, currentPath: string): boolean =>
@@ -59,7 +55,7 @@ const NavLink = ({
59
55
  class={`flex items-center gap-3 px-3 py-1.5 text-sm hover:text-sidebar-foreground transition-colors ${activeClass}`}
60
56
  >
61
57
  <Icon svg={item.iconSvg} />
62
- <span>{item.title}</span>
58
+ <span>{escapeText(item.title)}</span>
63
59
  </a>
64
60
  );
65
61
  };
@@ -73,11 +69,11 @@ const NavGroupComponent = ({
73
69
  }): JSX.Element => (
74
70
  <div class="py-2">
75
71
  <div class="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
76
- {group.label}
72
+ {escapeText(group.label)}
77
73
  </div>
78
74
  <nav class="space-y-1">
79
75
  {group.items.map((item) => (
80
- <NavLink key={item.href} item={item} currentPath={currentPath} />
76
+ <NavLink item={item} currentPath={currentPath} />
81
77
  ))}
82
78
  </nav>
83
79
  </div>
@@ -100,16 +96,12 @@ const Sidebar = ({
100
96
  data-prefetch="hover"
101
97
  >
102
98
  <span class="text-muted-foreground">~/</span>
103
- {siteName}
99
+ {escapeText(siteName)}
104
100
  </a>
105
101
  </div>
106
102
  <div class="sidebar-content">
107
103
  {navigation.map((group) => (
108
- <NavGroupComponent
109
- key={group.id}
110
- group={group}
111
- currentPath={currentPath}
112
- />
104
+ <NavGroupComponent group={group} currentPath={currentPath} />
113
105
  ))}
114
106
  </div>
115
107
  </aside>
@@ -121,19 +113,20 @@ const SearchForm = ({ query }: { query?: string }): JSX.Element => (
121
113
  action="/search/"
122
114
  class="flex w-full items-center"
123
115
  role="search"
124
- noValidate
116
+ novalidate
125
117
  >
126
- <label htmlFor="site-search" class="sr-only">
118
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
119
+ <label for="site-search" class="sr-only">
127
120
  Search pages
128
121
  </label>
129
122
  <input
130
123
  id="site-search"
131
124
  name="q"
132
125
  type="search"
133
- autoComplete="off"
126
+ autocomplete="off"
134
127
  spellcheck={false}
135
128
  placeholder="Search..."
136
- defaultValue={query ?? ""}
129
+ value={escapeText(query ?? "")}
137
130
  class="w-full border-b border-input bg-transparent px-1 py-1.5 text-sm placeholder:text-muted-foreground focus:border-foreground focus:outline-none transition-colors"
138
131
  />
139
132
  </form>
@@ -155,7 +148,7 @@ const TopNavbar = ({
155
148
  data-prefetch="hover"
156
149
  >
157
150
  <span class="text-muted-foreground">~/</span>
158
- {siteName}
151
+ {escapeText(siteName)}
159
152
  </a>
160
153
  <div class="not-prose w-full max-w-xs ml-auto">
161
154
  <SearchForm query={query} />
@@ -183,14 +176,16 @@ const DocumentHead = ({
183
176
  <head>
184
177
  <meta charset="utf-8" />
185
178
  <meta name="viewport" content="width=device-width, initial-scale=1" />
186
- <title>{title}</title>
187
- {description ? <meta name="description" content={description} /> : null}
179
+ <title>{escapeText(title)}</title>
180
+ {description ? (
181
+ <meta name="description" content={escapeText(description)} />
182
+ ) : null}
188
183
  {canonicalUrl ? <link rel="canonical" href={canonicalUrl} /> : null}
189
184
  <link rel="preconnect" href="https://fonts.googleapis.com" />
190
185
  <link
191
186
  rel="preconnect"
192
187
  href="https://fonts.gstatic.com"
193
- crossOrigin="anonymous"
188
+ crossorigin="anonymous"
194
189
  />
195
190
  <link
196
191
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap"
@@ -269,8 +264,10 @@ const DocumentBody = ({
269
264
  <div class="mx-auto flex w-full max-w-6xl items-start gap-10">
270
265
  <article
271
266
  class={`prose min-w-0 flex-1${currentPath === "/" ? " prose-home" : ""}`}
272
- dangerouslySetInnerHTML={{ __html: content }}
273
- />
267
+ >
268
+ {/* content is pre-rendered markdown HTML */}
269
+ {content}
270
+ </article>
274
271
  {shouldShowRightRail ? (
275
272
  <RightRailComponent
276
273
  canonicalUrl={canonicalUrl}
@@ -282,12 +279,12 @@ const DocumentBody = ({
282
279
  </div>
283
280
  </main>
284
281
  <footer class="site-footer">
285
- Built with Preact SSR + Tailwind &nbsp;|&nbsp; Zero JavaScript on
286
- content pages
282
+ Built with idcmd SSR + Tailwind &nbsp;|&nbsp; Zero JavaScript on content
283
+ pages
287
284
  </footer>
288
285
  </div>
289
286
  {scriptPaths.map((scriptPath) => (
290
- <script key={scriptPath} defer src={scriptPath} />
287
+ <script defer src={scriptPath} />
291
288
  ))}
292
289
  </body>
293
290
  );
@@ -344,4 +341,4 @@ const Layout = ({
344
341
  };
345
342
 
346
343
  export const renderLayout: RenderLayout = (props) =>
347
- `<!DOCTYPE html>${render(<Layout {...props} />)}`;
344
+ `<!DOCTYPE html>${<Layout {...props} />}`;
@@ -1,8 +1,10 @@
1
- import type { JSX } from "preact";
1
+ /* eslint-disable react/jsx-key */
2
2
 
3
3
  import type { ResolvedRightRailConfig } from "../site/config";
4
4
  import type { TocItem } from "./toc";
5
5
 
6
+ const escapeText = (value: string): string => Bun.escapeHTML(value);
7
+
6
8
  const CaretDownIcon = (): JSX.Element => (
7
9
  <svg
8
10
  width="16"
@@ -171,13 +173,13 @@ const OnThisPage = ({ items }: { items: TocItem[] }): JSX.Element => (
171
173
  <div class="toc-scroll min-h-0 flex-1" data-toc-scroll-container="1">
172
174
  <ul class="space-y-2 text-sm text-muted-foreground">
173
175
  {items.map((item) => (
174
- <li key={item.id} class={item.level >= 3 ? "pl-3" : ""}>
176
+ <li class={item.level >= 3 ? "pl-3" : ""}>
175
177
  <a
176
178
  href={`#${encodeURIComponent(item.id)}`}
177
179
  class="hover:text-foreground"
178
180
  data-toc-link="1"
179
181
  >
180
- {item.text}
182
+ {escapeText(item.text)}
181
183
  </a>
182
184
  </li>
183
185
  ))}
@@ -11,19 +11,16 @@ import {
11
11
  slugFromContentFile,
12
12
  } from "../content/paths";
13
13
 
14
- export const SEARCH_INDEX_VERSION = 1 as const;
15
-
16
- export interface SearchIndexDocumentV1 {
14
+ export interface SearchIndexDocument {
17
15
  url: string;
18
16
  title: string;
19
17
  description: string;
20
18
  body: string;
21
19
  }
22
20
 
23
- export interface SearchIndexV1 {
24
- version: typeof SEARCH_INDEX_VERSION;
21
+ export interface SearchIndex {
25
22
  generatedAt: string;
26
- documents: SearchIndexDocumentV1[];
23
+ documents: SearchIndexDocument[];
27
24
  }
28
25
 
29
26
  const SEARCH_INDEX_PATH = "public/search-index.json";
@@ -67,7 +64,7 @@ const isEligibleDocument = (
67
64
  hidden: boolean | undefined
68
65
  ): boolean => !hidden && slug !== "404";
69
66
 
70
- const sortDocuments = (documents: SearchIndexDocumentV1[]): void => {
67
+ const sortDocuments = (documents: SearchIndexDocument[]): void => {
71
68
  documents.sort((a, b) => {
72
69
  if (a.url === "/") {
73
70
  return -1;
@@ -84,7 +81,7 @@ const buildDocumentFromFile = async (
84
81
  bodyMaxChars: number,
85
82
  contentDir: string,
86
83
  siteConfig: SiteConfig
87
- ): Promise<SearchIndexDocumentV1 | null> => {
84
+ ): Promise<SearchIndexDocument | null> => {
88
85
  const slug = slugFromContentFile(file);
89
86
  const markdown = await Bun.file(`${contentDir}/${file}`).text();
90
87
  const parsed = parseFrontmatter(markdown);
@@ -117,11 +114,11 @@ export interface GenerateSearchIndexOptions {
117
114
 
118
115
  export const generateSearchIndexFromContent = async (
119
116
  options: GenerateSearchIndexOptions
120
- ): Promise<SearchIndexV1> => {
117
+ ): Promise<SearchIndex> => {
121
118
  const { bodyMaxChars = DEFAULT_BODY_MAX_CHARS, siteConfig } = options;
122
119
  const generatedAt = options.generatedAt ?? new Date().toISOString();
123
120
 
124
- const documents: SearchIndexDocumentV1[] = [];
121
+ const documents: SearchIndexDocument[] = [];
125
122
  const contentDir = await getContentDir();
126
123
 
127
124
  for await (const file of scanContentFiles()) {
@@ -141,13 +138,12 @@ export const generateSearchIndexFromContent = async (
141
138
  return {
142
139
  documents,
143
140
  generatedAt,
144
- version: SEARCH_INDEX_VERSION,
145
141
  };
146
142
  };
147
143
 
148
- const isSearchIndexDocumentV1 = (
144
+ const isSearchIndexDocument = (
149
145
  value: unknown
150
- ): value is SearchIndexDocumentV1 => {
146
+ ): value is SearchIndexDocument => {
151
147
  if (!value || typeof value !== "object") {
152
148
  return false;
153
149
  }
@@ -161,28 +157,27 @@ const isSearchIndexDocumentV1 = (
161
157
  );
162
158
  };
163
159
 
164
- const isSearchIndexV1 = (value: unknown): value is SearchIndexV1 => {
160
+ const isSearchIndex = (value: unknown): value is SearchIndex => {
165
161
  if (!value || typeof value !== "object") {
166
162
  return false;
167
163
  }
168
164
 
169
165
  const record = value as Record<string, unknown>;
170
166
  return (
171
- record.version === SEARCH_INDEX_VERSION &&
172
167
  isNonEmptyString(record.generatedAt) &&
173
168
  Array.isArray(record.documents) &&
174
- record.documents.every((doc) => isSearchIndexDocumentV1(doc))
169
+ record.documents.every((doc) => isSearchIndexDocument(doc))
175
170
  );
176
171
  };
177
172
 
178
- let indexCache: SearchIndexV1 | null = null;
173
+ let indexCache: SearchIndex | null = null;
179
174
 
180
175
  export interface LoadSearchIndexOptions {
181
176
  forceRefresh?: boolean;
182
177
  siteConfig: SiteConfig;
183
178
  }
184
179
 
185
- const tryLoadSearchIndexFromDisk = async (): Promise<SearchIndexV1 | null> => {
180
+ const tryLoadSearchIndexFromDisk = async (): Promise<SearchIndex | null> => {
186
181
  const file = Bun.file(SEARCH_INDEX_PATH);
187
182
  if (!(await file.exists())) {
188
183
  return null;
@@ -190,7 +185,7 @@ const tryLoadSearchIndexFromDisk = async (): Promise<SearchIndexV1 | null> => {
190
185
 
191
186
  try {
192
187
  const parsed = (await file.json()) as unknown;
193
- return isSearchIndexV1(parsed) ? parsed : null;
188
+ return isSearchIndex(parsed) ? parsed : null;
194
189
  } catch {
195
190
  return null;
196
191
  }
@@ -198,7 +193,7 @@ const tryLoadSearchIndexFromDisk = async (): Promise<SearchIndexV1 | null> => {
198
193
 
199
194
  export const loadSearchIndex = async (
200
195
  options: LoadSearchIndexOptions
201
- ): Promise<SearchIndexV1> => {
196
+ ): Promise<SearchIndex> => {
202
197
  const { forceRefresh = false, siteConfig } = options;
203
198
  if (indexCache && !forceRefresh) {
204
199
  return indexCache;
@@ -216,7 +211,7 @@ export const loadSearchIndex = async (
216
211
  };
217
212
 
218
213
  const getScopeHaystack = (
219
- document: SearchIndexDocumentV1,
214
+ document: SearchIndexDocument,
220
215
  scope: SearchScope
221
216
  ): string => {
222
217
  if (scope === "title") {
@@ -231,7 +226,7 @@ const getScopeHaystack = (
231
226
  };
232
227
 
233
228
  const matchesAllTokens = (
234
- document: SearchIndexDocumentV1,
229
+ document: SearchIndexDocument,
235
230
  tokens: readonly string[],
236
231
  scope: SearchScope
237
232
  ): boolean => {
@@ -239,14 +234,14 @@ const matchesAllTokens = (
239
234
  return tokens.every((token) => haystack.includes(token));
240
235
  };
241
236
 
242
- const toSearchResult = (document: SearchIndexDocumentV1): SearchResult => ({
237
+ const toSearchResult = (document: SearchIndexDocument): SearchResult => ({
243
238
  description: document.description,
244
239
  slug: document.url,
245
240
  title: document.title,
246
241
  });
247
242
 
248
243
  export const search = (
249
- index: SearchIndexV1,
244
+ index: SearchIndex,
250
245
  query: string,
251
246
  scope: SearchScope
252
247
  ): SearchResult[] => {
@@ -1,9 +1,9 @@
1
- import type { JSX } from "preact";
2
-
3
- import { render as renderToString } from "preact-render-to-string";
1
+ /* eslint-disable react/jsx-key */
4
2
 
5
3
  import type { SearchResult } from "./contract";
6
4
 
5
+ const escapeText = (value: string): string => Bun.escapeHTML(value);
6
+
7
7
  export interface TopPageLink {
8
8
  href: string;
9
9
  title: string;
@@ -24,9 +24,11 @@ const ResultItem = ({ result }: { result: SearchResult }): JSX.Element => (
24
24
  href={result.slug}
25
25
  class="font-medium underline decoration-border underline-offset-4"
26
26
  >
27
- {result.title}
27
+ {escapeText(result.title)}
28
28
  </a>
29
- <p class="mt-1 text-sm text-muted-foreground">{result.description}</p>
29
+ <p class="mt-1 text-sm text-muted-foreground">
30
+ {escapeText(result.description)}
31
+ </p>
30
32
  </li>
31
33
  );
32
34
 
@@ -44,12 +46,12 @@ const EmptyState = ({
44
46
  <p class="mt-4 font-medium text-foreground">Popular pages</p>
45
47
  <ul class="mt-2 space-y-1">
46
48
  {topPages.map((page) => (
47
- <li key={page.href}>
49
+ <li>
48
50
  <a
49
51
  href={page.href}
50
52
  class="underline decoration-border underline-offset-4"
51
53
  >
52
- {page.title}
54
+ {escapeText(page.title)}
53
55
  </a>
54
56
  </li>
55
57
  ))}
@@ -74,8 +76,8 @@ const SearchPage = ({
74
76
  {showResults ? (
75
77
  <p class="mt-2 text-sm text-muted-foreground">
76
78
  {results.length === 0
77
- ? `No matches for "${trimmed}".`
78
- : `Found ${results.length} result(s) for "${trimmed}".`}
79
+ ? `No matches for "${escapeText(trimmed)}".`
80
+ : `Found ${results.length} result(s) for "${escapeText(trimmed)}".`}
79
81
  </p>
80
82
  ) : (
81
83
  <div class="mt-2">
@@ -86,7 +88,7 @@ const SearchPage = ({
86
88
  {showResults ? (
87
89
  <ul class="mt-4 space-y-2">
88
90
  {results.map((result) => (
89
- <ResultItem key={result.slug} result={result} />
91
+ <ResultItem result={result} />
90
92
  ))}
91
93
  </ul>
92
94
  ) : null}
@@ -95,4 +97,4 @@ const SearchPage = ({
95
97
  };
96
98
 
97
99
  export const renderSearchPageContent: RenderSearchPageContent = (props) =>
98
- renderToString(<SearchPage {...props} />);
100
+ `${<SearchPage {...props} />}`;
@@ -1,10 +1,29 @@
1
- export const createHtmlCacheHeaders = (isDev: boolean): HeadersInit => ({
1
+ import type { ResolvedCachePolicy } from "../site/cache";
2
+
3
+ const combineCacheControl = (args: {
4
+ browserCacheControl: string;
5
+ edgeCacheControl: string | null;
6
+ }): string =>
7
+ args.edgeCacheControl
8
+ ? `${args.browserCacheControl}, ${args.edgeCacheControl}`
9
+ : args.browserCacheControl;
10
+
11
+ export const createHtmlCacheHeaders = (
12
+ isDev: boolean,
13
+ cachePolicy: ResolvedCachePolicy
14
+ ): HeadersInit => ({
2
15
  "Cache-Control": isDev
3
16
  ? "no-cache"
4
- : "s-maxage=60, stale-while-revalidate=3600",
17
+ : combineCacheControl({
18
+ browserCacheControl: cachePolicy.html.browserCacheControl,
19
+ edgeCacheControl: cachePolicy.html.edgeCacheControl,
20
+ }),
5
21
  "Content-Type": "text/html; charset=utf-8",
6
22
  });
7
23
 
8
- export const createStaticCacheHeaders = (isDev: boolean): HeadersInit => ({
9
- "Cache-Control": isDev ? "no-cache" : "public, max-age=31536000, immutable",
24
+ export const createStaticCacheHeaders = (
25
+ isDev: boolean,
26
+ cachePolicy: ResolvedCachePolicy
27
+ ): HeadersInit => ({
28
+ "Cache-Control": isDev ? "no-cache" : cachePolicy.static.cacheControl,
10
29
  });
@@ -141,7 +141,7 @@ const loadOneRoute = async (
141
141
  const pathname = pathnameFromRouteRelativePath(relativeFile);
142
142
  if (hasUnsupportedDynamicSegment(pathname)) {
143
143
  throw new Error(
144
- `Unsupported dynamic route segment in ${routesDir}/${relativeFile} (computed pathname: ${pathname}). V1 does not support [param] or :param routes.`
144
+ `Unsupported dynamic route segment in ${routesDir}/${relativeFile} (computed pathname: ${pathname}). Dynamic [param] and :param routes are not supported.`
145
145
  );
146
146
  }
147
147
 
package/src/server.ts CHANGED
@@ -15,6 +15,8 @@ import {
15
15
  import { createLiveReload } from "./server/live-reload";
16
16
  import { serveStaticFile } from "./server/static";
17
17
  import { handleUserRouteRequest } from "./server/user-routes";
18
+ import { resolveCachePolicy } from "./site/cache";
19
+ import { loadSiteConfig } from "./site/config";
18
20
  import {
19
21
  getRedirectForCanonicalHtmlPath,
20
22
  isFileLikePathname,
@@ -30,9 +32,12 @@ const isDev = process.env.NODE_ENV !== "production";
30
32
  const LIVE_RELOAD_POLL_MS = 250;
31
33
  const MIN_SEARCH_QUERY_LENGTH = 2;
32
34
  const MAX_SEARCH_RESULTS = 50;
35
+ const HEALTHCHECK_PATH = "/health";
33
36
 
34
- const cacheHeaders = createHtmlCacheHeaders(isDev);
35
- const staticCacheHeaders = createStaticCacheHeaders(isDev);
37
+ const siteConfig = await loadSiteConfig();
38
+ const cachePolicy = resolveCachePolicy(siteConfig.cache);
39
+ const cacheHeaders = createHtmlCacheHeaders(isDev, cachePolicy);
40
+ const staticCacheHeaders = createStaticCacheHeaders(isDev, cachePolicy);
36
41
 
37
42
  const withQueryString = (pathname: string, search: string): string =>
38
43
  search ? `${pathname}${search}` : pathname;
@@ -74,6 +79,20 @@ const handleLlmsTxt = async (path: string): Promise<Response | undefined> => {
74
79
  });
75
80
  };
76
81
 
82
+ const handleHealthRequest = (path: string): Response | undefined => {
83
+ if (path !== HEALTHCHECK_PATH) {
84
+ return undefined;
85
+ }
86
+
87
+ return new Response("ok", {
88
+ headers: {
89
+ "Cache-Control": "no-cache",
90
+ "Content-Type": "text/plain; charset=utf-8",
91
+ },
92
+ status: 200,
93
+ });
94
+ };
95
+
77
96
  const handleMarkdownRequest = async (
78
97
  path: string
79
98
  ): Promise<Response | undefined> => {
@@ -202,6 +221,7 @@ const handleRequest = async (
202
221
 
203
222
  return (
204
223
  liveReloadUpgrade ??
224
+ handleHealthRequest(path) ??
205
225
  (await handleLlmsTxt(path)) ??
206
226
  (await handleRobotsTxt(url, seoEnv)) ??
207
227
  (await handleSitemapXml(url, seoEnv)) ??
@@ -0,0 +1,108 @@
1
+ import { z } from "zod";
2
+
3
+ const MAX_EDGE_CACHE_SECONDS = 7 * 24 * 60 * 60;
4
+ const MAX_STALE_SECONDS = 30 * 24 * 60 * 60;
5
+
6
+ const HTML_REVALIDATE_CACHE_CONTROL = "public, max-age=0, must-revalidate";
7
+ const STATIC_REVALIDATE_CACHE_CONTROL = "public, max-age=0, must-revalidate";
8
+ const STATIC_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
9
+ const NO_STORE_CACHE_CONTROL = "no-store";
10
+
11
+ export const CachePresetSchema = z.enum(["fresh", "balanced", "static"]);
12
+ export type CachePreset = z.infer<typeof CachePresetSchema>;
13
+
14
+ export const CacheHtmlConfigSchema = z
15
+ .object({
16
+ sMaxAgeSeconds: z
17
+ .number()
18
+ .int()
19
+ .min(0)
20
+ .max(MAX_EDGE_CACHE_SECONDS)
21
+ .optional(),
22
+ staleWhileRevalidateSeconds: z
23
+ .number()
24
+ .int()
25
+ .min(0)
26
+ .max(MAX_STALE_SECONDS)
27
+ .optional(),
28
+ })
29
+ .strict();
30
+ export type CacheHtmlConfig = z.infer<typeof CacheHtmlConfigSchema>;
31
+
32
+ export const CacheConfigSchema = z
33
+ .object({
34
+ html: CacheHtmlConfigSchema.optional(),
35
+ preset: CachePresetSchema.optional(),
36
+ })
37
+ .strict();
38
+ export type CacheConfig = z.infer<typeof CacheConfigSchema>;
39
+
40
+ export interface ResolvedCachePolicy {
41
+ html: {
42
+ browserCacheControl: string;
43
+ edgeCacheControl: string | null;
44
+ };
45
+ preset: CachePreset;
46
+ static: {
47
+ cacheControl: string;
48
+ };
49
+ }
50
+
51
+ interface HtmlEdgePolicy {
52
+ sMaxAgeSeconds: number;
53
+ staleWhileRevalidateSeconds: number;
54
+ }
55
+
56
+ const DEFAULT_HTML_EDGE_POLICY: HtmlEdgePolicy = {
57
+ sMaxAgeSeconds: 60,
58
+ staleWhileRevalidateSeconds: 3600,
59
+ };
60
+ const DEFAULT_PRESET: CachePreset = "static";
61
+
62
+ const formatEdgeCacheControl = (policy: HtmlEdgePolicy): string =>
63
+ `s-maxage=${String(policy.sMaxAgeSeconds)}, stale-while-revalidate=${String(policy.staleWhileRevalidateSeconds)}`;
64
+
65
+ const resolveHtmlEdgePolicy = (
66
+ config: CacheConfig | undefined
67
+ ): HtmlEdgePolicy => ({
68
+ sMaxAgeSeconds:
69
+ config?.html?.sMaxAgeSeconds ?? DEFAULT_HTML_EDGE_POLICY.sMaxAgeSeconds,
70
+ staleWhileRevalidateSeconds:
71
+ config?.html?.staleWhileRevalidateSeconds ??
72
+ DEFAULT_HTML_EDGE_POLICY.staleWhileRevalidateSeconds,
73
+ });
74
+
75
+ const resolvePreset = (config: CacheConfig | undefined): CachePreset =>
76
+ config?.preset ?? DEFAULT_PRESET;
77
+
78
+ export const resolveCachePolicy = (
79
+ config?: CacheConfig
80
+ ): ResolvedCachePolicy => {
81
+ const preset = resolvePreset(config);
82
+
83
+ if (preset === "fresh") {
84
+ return {
85
+ html: {
86
+ browserCacheControl: NO_STORE_CACHE_CONTROL,
87
+ edgeCacheControl: null,
88
+ },
89
+ preset,
90
+ static: { cacheControl: NO_STORE_CACHE_CONTROL },
91
+ };
92
+ }
93
+
94
+ const edgePolicy = resolveHtmlEdgePolicy(config);
95
+ return {
96
+ html: {
97
+ browserCacheControl: HTML_REVALIDATE_CACHE_CONTROL,
98
+ edgeCacheControl: formatEdgeCacheControl(edgePolicy),
99
+ },
100
+ preset,
101
+ static: {
102
+ cacheControl:
103
+ preset === "static"
104
+ ? STATIC_IMMUTABLE_CACHE_CONTROL
105
+ : STATIC_REVALIDATE_CACHE_CONTROL,
106
+ },
107
+ };
108
+ };