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.
- package/README.md +27 -5
- package/package.json +2 -3
- package/src/cli/commands/build.ts +2 -1
- package/src/cli/commands/deploy.ts +208 -22
- package/src/cli/commands/dev.ts +2 -1
- package/src/cli/commands/init.ts +56 -4
- package/src/cli/commands/preview.ts +36 -3
- package/src/cli/main.ts +271 -19
- package/src/cli/prompt.ts +105 -0
- package/src/cli/provider-files.ts +225 -0
- package/src/cli/provider.ts +36 -0
- package/src/render/layout.tsx +28 -31
- package/src/render/right-rail.tsx +5 -3
- package/src/search/index.ts +19 -24
- package/src/search/page.tsx +13 -11
- package/src/server/headers.ts +23 -4
- package/src/server/user-routes.ts +1 -1
- package/src/server.ts +22 -2
- package/src/site/cache.ts +108 -0
- package/src/site/config.ts +46 -4
- package/templates/default/.github/workflows/ci.yml +0 -3
- package/templates/default/README.md +6 -5
- package/templates/default/package.json +0 -1
- package/templates/default/scripts/check.ts +0 -1
- package/templates/default/site.jsonc +10 -0
- package/templates/default/src/server.ts +1 -1
- package/templates/default/src/ui/layout.tsx +27 -26
- package/templates/default/src/ui/right-rail.tsx +6 -3
- package/templates/default/src/ui/search-page.tsx +13 -10
- package/templates/default/tsconfig.json +1 -1
- package/templates/default/scripts/smoke.ts +0 -223
- package/templates/default/vercel.json +0 -7
|
@@ -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";
|
package/src/render/layout.tsx
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
/* eslint-disable react/
|
|
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
|
|
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
|
-
|
|
116
|
+
novalidate
|
|
125
117
|
>
|
|
126
|
-
|
|
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
|
-
|
|
126
|
+
autocomplete="off"
|
|
134
127
|
spellcheck={false}
|
|
135
128
|
placeholder="Search..."
|
|
136
|
-
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
286
|
-
|
|
282
|
+
Built with idcmd SSR + Tailwind | Zero JavaScript on content
|
|
283
|
+
pages
|
|
287
284
|
</footer>
|
|
288
285
|
</div>
|
|
289
286
|
{scriptPaths.map((scriptPath) => (
|
|
290
|
-
<script
|
|
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>${
|
|
344
|
+
`<!DOCTYPE html>${<Layout {...props} />}`;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
))}
|
package/src/search/index.ts
CHANGED
|
@@ -11,19 +11,16 @@ import {
|
|
|
11
11
|
slugFromContentFile,
|
|
12
12
|
} from "../content/paths";
|
|
13
13
|
|
|
14
|
-
export
|
|
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
|
|
24
|
-
version: typeof SEARCH_INDEX_VERSION;
|
|
21
|
+
export interface SearchIndex {
|
|
25
22
|
generatedAt: string;
|
|
26
|
-
documents:
|
|
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:
|
|
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<
|
|
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<
|
|
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:
|
|
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
|
|
144
|
+
const isSearchIndexDocument = (
|
|
149
145
|
value: unknown
|
|
150
|
-
): value is
|
|
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
|
|
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) =>
|
|
169
|
+
record.documents.every((doc) => isSearchIndexDocument(doc))
|
|
175
170
|
);
|
|
176
171
|
};
|
|
177
172
|
|
|
178
|
-
let indexCache:
|
|
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<
|
|
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
|
|
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<
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
244
|
+
index: SearchIndex,
|
|
250
245
|
query: string,
|
|
251
246
|
scope: SearchScope
|
|
252
247
|
): SearchResult[] => {
|
package/src/search/page.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
|
|
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">
|
|
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
|
|
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
|
|
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
|
-
|
|
100
|
+
`${<SearchPage {...props} />}`;
|
package/src/server/headers.ts
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
1
|
-
|
|
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
|
-
:
|
|
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 = (
|
|
9
|
-
|
|
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}).
|
|
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
|
|
35
|
-
const
|
|
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
|
+
};
|