idcmd 0.0.10 → 0.0.11
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 +15 -1
- package/package.json +1 -1
- package/src/cli/commands/deploy.ts +208 -22
- package/src/cli/commands/init.ts +44 -1
- package/src/cli/main.ts +27 -5
- package/src/cli/prompt.ts +105 -0
- package/src/cli/provider-files.ts +225 -0
- package/src/cli/provider.ts +36 -0
- package/src/server/headers.ts +23 -4
- package/src/server.ts +22 -2
- package/src/site/cache.ts +108 -0
- package/src/site/config.ts +46 -4
- package/templates/default/README.md +5 -3
- package/templates/default/scripts/check.ts +0 -1
- package/templates/default/site.jsonc +10 -0
- package/templates/default/vercel.json +0 -7
package/README.md
CHANGED
|
@@ -18,10 +18,24 @@ idcmd init [dir] # scaffold a new site
|
|
|
18
18
|
idcmd dev # tailwind watch + SSR dev server
|
|
19
19
|
idcmd build # static public/
|
|
20
20
|
idcmd preview # serve public/ locally
|
|
21
|
-
idcmd deploy # build +
|
|
21
|
+
idcmd deploy # build + generate deploy files (Vercel/Fly/Railway)
|
|
22
22
|
idcmd client ... # add/update local src implementations
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
### Deploy targets
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
idcmd init my-docs --fly
|
|
29
|
+
idcmd init my-docs --railway
|
|
30
|
+
idcmd init my-docs --vercel
|
|
31
|
+
|
|
32
|
+
idcmd deploy --fly
|
|
33
|
+
idcmd deploy --railway
|
|
34
|
+
idcmd deploy --vercel
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`idcmd init --yes` is provider-neutral by default (no provider files generated).
|
|
38
|
+
|
|
25
39
|
## Layout (V1)
|
|
26
40
|
|
|
27
41
|
- `content/<slug>.md` -> `/<slug>/` (`index.md` -> `/`)
|
package/package.json
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
|
+
import { resolveCachePolicy } from "../../site/cache";
|
|
2
|
+
import { loadSiteConfig } from "../../site/config";
|
|
3
|
+
import { basename } from "../path";
|
|
4
|
+
import { isDeployProvider, resolveProviderFromFlags } from "../provider";
|
|
5
|
+
import { generateProviderFiles, providerConfigPaths } from "../provider-files";
|
|
1
6
|
import { buildCommand } from "./build";
|
|
2
7
|
|
|
8
|
+
export interface DeployFlags {
|
|
9
|
+
fly?: boolean;
|
|
10
|
+
railway?: boolean;
|
|
11
|
+
vercel?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
3
14
|
const readJsonFile = async (path: string): Promise<unknown> => {
|
|
4
15
|
const file = Bun.file(path);
|
|
5
16
|
if (!(await file.exists())) {
|
|
@@ -9,22 +20,81 @@ const readJsonFile = async (path: string): Promise<unknown> => {
|
|
|
9
20
|
return JSON.parse(text) as unknown;
|
|
10
21
|
};
|
|
11
22
|
|
|
23
|
+
const readPackageName = async (): Promise<string> => {
|
|
24
|
+
const raw = await readJsonFile("package.json");
|
|
25
|
+
if (raw && typeof raw === "object") {
|
|
26
|
+
const pkg = raw as { name?: unknown };
|
|
27
|
+
if (typeof pkg.name === "string" && pkg.name.length > 0) {
|
|
28
|
+
return pkg.name;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return basename(process.cwd());
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const warn = (message: string): void => {
|
|
35
|
+
// eslint-disable-next-line no-console
|
|
36
|
+
console.warn(`Warning: ${message}`);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const warnIfFileMissing = async (
|
|
40
|
+
path: string,
|
|
41
|
+
context: string
|
|
42
|
+
): Promise<void> => {
|
|
43
|
+
if (await Bun.file(path).exists()) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
warn(`${path} not found (${context}).`);
|
|
47
|
+
};
|
|
48
|
+
|
|
12
49
|
const warnIfVercelMisconfigured = async (): Promise<void> => {
|
|
13
|
-
const raw = await readJsonFile(
|
|
50
|
+
const raw = await readJsonFile(providerConfigPaths.vercelJson);
|
|
14
51
|
if (!raw) {
|
|
15
|
-
|
|
16
|
-
console.warn(
|
|
17
|
-
"Warning: vercel.json not found. Vercel static deploy expects public/ output."
|
|
18
|
-
);
|
|
52
|
+
warn("vercel.json not found. Run `idcmd deploy --vercel` to generate it.");
|
|
19
53
|
return;
|
|
20
54
|
}
|
|
21
55
|
|
|
22
56
|
const record = raw as Record<string, unknown>;
|
|
23
57
|
const out = record.outputDirectory;
|
|
24
58
|
if (out !== "public") {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
59
|
+
warn(
|
|
60
|
+
`vercel.json outputDirectory is not "public" (got ${JSON.stringify(out)}).`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const warnIfFlyMisconfigured = async (): Promise<void> => {
|
|
66
|
+
await warnIfFileMissing(
|
|
67
|
+
providerConfigPaths.flyToml,
|
|
68
|
+
"required for Fly.io deploy"
|
|
69
|
+
);
|
|
70
|
+
await warnIfFileMissing(
|
|
71
|
+
providerConfigPaths.dockerfile,
|
|
72
|
+
"required for Fly.io deploy"
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const warnIfRailwayMisconfigured = async (): Promise<void> => {
|
|
77
|
+
await warnIfFileMissing(
|
|
78
|
+
providerConfigPaths.railwayJson,
|
|
79
|
+
"required for Railway deploy"
|
|
80
|
+
);
|
|
81
|
+
await warnIfFileMissing(
|
|
82
|
+
providerConfigPaths.dockerfile,
|
|
83
|
+
"required for Railway deploy"
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const raw = await readJsonFile(providerConfigPaths.railwayJson);
|
|
87
|
+
if (!raw || typeof raw !== "object") {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const config = raw as {
|
|
92
|
+
build?: { builder?: unknown };
|
|
93
|
+
};
|
|
94
|
+
const builder = config.build?.builder;
|
|
95
|
+
if (builder !== undefined && builder !== "DOCKERFILE") {
|
|
96
|
+
warn(
|
|
97
|
+
`railway.json build.builder is ${JSON.stringify(builder)}; expected "DOCKERFILE".`
|
|
28
98
|
);
|
|
29
99
|
}
|
|
30
100
|
};
|
|
@@ -40,33 +110,78 @@ const warnIfBaseUrlMissing = async (): Promise<void> => {
|
|
|
40
110
|
baseUrl?: unknown;
|
|
41
111
|
};
|
|
42
112
|
if (!cfg.baseUrl) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"Warning: site.jsonc missing baseUrl; sitemap.xml and robots.txt will be skipped."
|
|
113
|
+
warn(
|
|
114
|
+
"site.jsonc missing baseUrl; sitemap.xml and robots.txt will be skipped."
|
|
46
115
|
);
|
|
47
116
|
}
|
|
48
117
|
} catch (error) {
|
|
49
118
|
const message = error instanceof Error ? error.message : String(error);
|
|
50
|
-
|
|
51
|
-
console.warn(`Warning: Failed to parse site.jsonc: ${message}`);
|
|
119
|
+
warn(`Failed to parse site.jsonc: ${message}`);
|
|
52
120
|
}
|
|
53
121
|
};
|
|
54
122
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
123
|
+
const warnIfProviderMisconfigured = async (
|
|
124
|
+
provider: "vercel" | "fly" | "railway"
|
|
125
|
+
): Promise<void> => {
|
|
126
|
+
if (provider === "vercel") {
|
|
127
|
+
await warnIfVercelMisconfigured();
|
|
128
|
+
return;
|
|
59
129
|
}
|
|
130
|
+
if (provider === "fly") {
|
|
131
|
+
await warnIfFlyMisconfigured();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
await warnIfRailwayMisconfigured();
|
|
135
|
+
};
|
|
60
136
|
|
|
61
|
-
|
|
62
|
-
|
|
137
|
+
const printGeneratedFiles = (files: readonly string[]): void => {
|
|
138
|
+
if (files.length === 0) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// eslint-disable-next-line no-console
|
|
142
|
+
console.log("");
|
|
143
|
+
// eslint-disable-next-line no-console
|
|
144
|
+
console.log("Generated files:");
|
|
145
|
+
for (const path of files) {
|
|
146
|
+
// eslint-disable-next-line no-console
|
|
147
|
+
console.log(` - ${path}`);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
63
150
|
|
|
64
|
-
|
|
151
|
+
const generateProviderFilesForProject = async (
|
|
152
|
+
provider: "vercel" | "fly" | "railway"
|
|
153
|
+
): Promise<void> => {
|
|
154
|
+
const siteConfig = await loadSiteConfig();
|
|
155
|
+
const packageName = await readPackageName();
|
|
156
|
+
const files = await generateProviderFiles({
|
|
157
|
+
cachePolicy: resolveCachePolicy(siteConfig.cache),
|
|
158
|
+
packageName,
|
|
159
|
+
provider,
|
|
160
|
+
targetDir: process.cwd(),
|
|
161
|
+
});
|
|
162
|
+
printGeneratedFiles(files);
|
|
163
|
+
};
|
|
65
164
|
|
|
66
|
-
|
|
165
|
+
const printNeutralDeployInstructions = (): void => {
|
|
166
|
+
// eslint-disable-next-line no-console
|
|
167
|
+
console.log("");
|
|
168
|
+
// eslint-disable-next-line no-console
|
|
169
|
+
console.log("Deploy:");
|
|
170
|
+
// eslint-disable-next-line no-console
|
|
171
|
+
console.log(" 1. Choose a provider");
|
|
172
|
+
// eslint-disable-next-line no-console
|
|
173
|
+
console.log(" 2. Generate provider files:");
|
|
174
|
+
// eslint-disable-next-line no-console
|
|
175
|
+
console.log(" idcmd deploy --vercel");
|
|
176
|
+
// eslint-disable-next-line no-console
|
|
177
|
+
console.log(" idcmd deploy --fly");
|
|
178
|
+
// eslint-disable-next-line no-console
|
|
179
|
+
console.log(" idcmd deploy --railway");
|
|
180
|
+
// eslint-disable-next-line no-console
|
|
181
|
+
console.log("");
|
|
67
182
|
};
|
|
68
183
|
|
|
69
|
-
const
|
|
184
|
+
const printVercelInstructions = (): void => {
|
|
70
185
|
// eslint-disable-next-line no-console
|
|
71
186
|
console.log("");
|
|
72
187
|
// eslint-disable-next-line no-console
|
|
@@ -80,3 +195,74 @@ const printDeployInstructions = (): void => {
|
|
|
80
195
|
// eslint-disable-next-line no-console
|
|
81
196
|
console.log("");
|
|
82
197
|
};
|
|
198
|
+
|
|
199
|
+
const printFlyInstructions = (): void => {
|
|
200
|
+
// eslint-disable-next-line no-console
|
|
201
|
+
console.log("");
|
|
202
|
+
// eslint-disable-next-line no-console
|
|
203
|
+
console.log("Deploy (Fly.io):");
|
|
204
|
+
// eslint-disable-next-line no-console
|
|
205
|
+
console.log(" 1. Install flyctl and run `fly auth login`");
|
|
206
|
+
// eslint-disable-next-line no-console
|
|
207
|
+
console.log(" 2. Set `app` in fly.toml to your Fly app name");
|
|
208
|
+
// eslint-disable-next-line no-console
|
|
209
|
+
console.log(" 3. Run `fly deploy`");
|
|
210
|
+
// eslint-disable-next-line no-console
|
|
211
|
+
console.log("");
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const printRailwayInstructions = (): void => {
|
|
215
|
+
// eslint-disable-next-line no-console
|
|
216
|
+
console.log("");
|
|
217
|
+
// eslint-disable-next-line no-console
|
|
218
|
+
console.log("Deploy (Railway):");
|
|
219
|
+
// eslint-disable-next-line no-console
|
|
220
|
+
console.log(" 1. Connect this repo in Railway (or use `railway up`)");
|
|
221
|
+
// eslint-disable-next-line no-console
|
|
222
|
+
console.log(" 2. Railway will build from Dockerfile");
|
|
223
|
+
// eslint-disable-next-line no-console
|
|
224
|
+
console.log(
|
|
225
|
+
" 3. Ensure required env vars are set (for example SITE_BASE_URL)"
|
|
226
|
+
);
|
|
227
|
+
// eslint-disable-next-line no-console
|
|
228
|
+
console.log("");
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const printDeployInstructions = (
|
|
232
|
+
provider: "none" | "vercel" | "fly" | "railway"
|
|
233
|
+
): void => {
|
|
234
|
+
if (provider === "none") {
|
|
235
|
+
printNeutralDeployInstructions();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (provider === "vercel") {
|
|
239
|
+
printVercelInstructions();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (provider === "fly") {
|
|
243
|
+
printFlyInstructions();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
printRailwayInstructions();
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export const deployCommand = async (
|
|
250
|
+
flags: DeployFlags = {}
|
|
251
|
+
): Promise<number> => {
|
|
252
|
+
const provider = resolveProviderFromFlags(flags);
|
|
253
|
+
const code = await buildCommand();
|
|
254
|
+
if (code !== 0) {
|
|
255
|
+
return code;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
await warnIfBaseUrlMissing();
|
|
259
|
+
|
|
260
|
+
if (isDeployProvider(provider)) {
|
|
261
|
+
await generateProviderFilesForProject(provider);
|
|
262
|
+
await warnIfProviderMisconfigured(provider);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
printDeployInstructions(provider);
|
|
266
|
+
|
|
267
|
+
return 0;
|
|
268
|
+
};
|
package/src/cli/commands/init.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveCachePolicy } from "../../site/cache";
|
|
1
2
|
import { copyDir, ensureDir, isDirEmpty, replaceInFile } from "../fs";
|
|
2
3
|
import {
|
|
3
4
|
normalizeOptionalString,
|
|
@@ -5,7 +6,9 @@ import {
|
|
|
5
6
|
toPackageName,
|
|
6
7
|
} from "../normalize";
|
|
7
8
|
import { basename, dirname, joinPath } from "../path";
|
|
8
|
-
import { promptOptionalText, promptText } from "../prompt";
|
|
9
|
+
import { promptOptionalText, promptSelect, promptText } from "../prompt";
|
|
10
|
+
import { isDeployProvider, resolveProviderFromFlags } from "../provider";
|
|
11
|
+
import { generateProviderFiles } from "../provider-files";
|
|
9
12
|
import { run } from "../run";
|
|
10
13
|
import { readPackageVersion } from "../version";
|
|
11
14
|
|
|
@@ -14,9 +17,12 @@ const DEFAULT_PORT = 4000;
|
|
|
14
17
|
export interface InitFlags {
|
|
15
18
|
"base-url"?: string;
|
|
16
19
|
description?: string;
|
|
20
|
+
fly?: boolean;
|
|
17
21
|
git?: boolean;
|
|
18
22
|
name?: string;
|
|
19
23
|
port?: string;
|
|
24
|
+
railway?: boolean;
|
|
25
|
+
vercel?: boolean;
|
|
20
26
|
yes?: boolean;
|
|
21
27
|
}
|
|
22
28
|
|
|
@@ -125,14 +131,41 @@ interface InitInputs {
|
|
|
125
131
|
baseUrl: string | null;
|
|
126
132
|
description: string;
|
|
127
133
|
port: number;
|
|
134
|
+
provider: "none" | "vercel" | "fly" | "railway";
|
|
128
135
|
siteName: string;
|
|
129
136
|
}
|
|
130
137
|
|
|
138
|
+
const promptProviderChoice = (): Promise<
|
|
139
|
+
"none" | "vercel" | "fly" | "railway"
|
|
140
|
+
> =>
|
|
141
|
+
promptSelect(
|
|
142
|
+
"Deploy provider (optional)",
|
|
143
|
+
[
|
|
144
|
+
{ label: "None (self-host / decide later)", value: "none" },
|
|
145
|
+
{ label: "Vercel", value: "vercel" },
|
|
146
|
+
{ label: "Fly.io", value: "fly" },
|
|
147
|
+
{ label: "Railway", value: "railway" },
|
|
148
|
+
],
|
|
149
|
+
"none"
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const resolveInitProvider = (
|
|
153
|
+
flags: InitFlags,
|
|
154
|
+
yes: boolean
|
|
155
|
+
): Promise<"none" | "vercel" | "fly" | "railway"> => {
|
|
156
|
+
const provider = resolveProviderFromFlags(flags);
|
|
157
|
+
if (provider !== "none" || yes) {
|
|
158
|
+
return Promise.resolve(provider);
|
|
159
|
+
}
|
|
160
|
+
return promptProviderChoice();
|
|
161
|
+
};
|
|
162
|
+
|
|
131
163
|
const readInitInputs = async (
|
|
132
164
|
flags: InitFlags,
|
|
133
165
|
defaults: InitDefaults
|
|
134
166
|
): Promise<InitInputs> => {
|
|
135
167
|
const yes = flags.yes === true;
|
|
168
|
+
const provider = await resolveInitProvider(flags, yes);
|
|
136
169
|
|
|
137
170
|
const siteName = yes
|
|
138
171
|
? (flags.name ?? defaults.defaultSiteName)
|
|
@@ -157,6 +190,7 @@ const readInitInputs = async (
|
|
|
157
190
|
baseUrl: normalizeOptionalString(baseUrlRaw),
|
|
158
191
|
description,
|
|
159
192
|
port,
|
|
193
|
+
provider,
|
|
160
194
|
siteName,
|
|
161
195
|
};
|
|
162
196
|
};
|
|
@@ -280,6 +314,15 @@ const scaffoldAndConfigure = async (args: {
|
|
|
280
314
|
siteName: args.inputs.siteName,
|
|
281
315
|
targetDir: args.targetDir,
|
|
282
316
|
});
|
|
317
|
+
|
|
318
|
+
if (isDeployProvider(args.inputs.provider)) {
|
|
319
|
+
await generateProviderFiles({
|
|
320
|
+
cachePolicy: resolveCachePolicy(),
|
|
321
|
+
packageName: args.defaults.packageName,
|
|
322
|
+
provider: args.inputs.provider,
|
|
323
|
+
targetDir: args.targetDir,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
283
326
|
};
|
|
284
327
|
|
|
285
328
|
export const initCommand = async (
|
package/src/cli/main.ts
CHANGED
|
@@ -19,11 +19,11 @@ const usage = (): string =>
|
|
|
19
19
|
"idcmd",
|
|
20
20
|
"",
|
|
21
21
|
"Usage:",
|
|
22
|
-
" idcmd init [dir] [--yes] [--name <name>] [--description <text>] [--base-url <url>] [--port <port>] [--no-git]",
|
|
22
|
+
" idcmd init [dir] [--yes] [--name <name>] [--description <text>] [--base-url <url>] [--port <port>] [--no-git] [--vercel|--fly|--railway]",
|
|
23
23
|
" idcmd dev [--port <port>]",
|
|
24
24
|
" idcmd build",
|
|
25
25
|
" idcmd preview [--port <port>]",
|
|
26
|
-
" idcmd deploy",
|
|
26
|
+
" idcmd deploy [--vercel|--fly|--railway]",
|
|
27
27
|
" idcmd client <add|update> <layout|right-rail|search-page|runtime|all> [--dry-run] [--yes]",
|
|
28
28
|
"",
|
|
29
29
|
].join("\n");
|
|
@@ -34,9 +34,26 @@ const asStringFlag = (value: unknown): string | undefined =>
|
|
|
34
34
|
const asBooleanFlag = (value: unknown): boolean =>
|
|
35
35
|
value === true || value === "true";
|
|
36
36
|
|
|
37
|
+
const asOptionalBooleanFlag = (value: unknown): boolean | undefined => {
|
|
38
|
+
if (value === false || value === "false") {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return asBooleanFlag(value) ? true : undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
37
44
|
const handleInit = (parsed: ParsedArgs): Promise<number> => {
|
|
38
45
|
const [dir] = parsed.positionals;
|
|
39
|
-
return initCommand(dir,
|
|
46
|
+
return initCommand(dir, {
|
|
47
|
+
"base-url": asStringFlag(parsed.flags["base-url"]),
|
|
48
|
+
description: asStringFlag(parsed.flags.description),
|
|
49
|
+
fly: asBooleanFlag(parsed.flags.fly),
|
|
50
|
+
git: asOptionalBooleanFlag(parsed.flags.git),
|
|
51
|
+
name: asStringFlag(parsed.flags.name),
|
|
52
|
+
port: asStringFlag(parsed.flags.port),
|
|
53
|
+
railway: asBooleanFlag(parsed.flags.railway),
|
|
54
|
+
vercel: asBooleanFlag(parsed.flags.vercel),
|
|
55
|
+
yes: asBooleanFlag(parsed.flags.yes),
|
|
56
|
+
});
|
|
40
57
|
};
|
|
41
58
|
|
|
42
59
|
const handleDev = (parsed: ParsedArgs): Promise<number> =>
|
|
@@ -49,7 +66,12 @@ const handlePreview = (parsed: ParsedArgs): Promise<number> => {
|
|
|
49
66
|
return previewCommand(port);
|
|
50
67
|
};
|
|
51
68
|
|
|
52
|
-
const handleDeploy = (): Promise<number> =>
|
|
69
|
+
const handleDeploy = (parsed: ParsedArgs): Promise<number> =>
|
|
70
|
+
deployCommand({
|
|
71
|
+
fly: asBooleanFlag(parsed.flags.fly),
|
|
72
|
+
railway: asBooleanFlag(parsed.flags.railway),
|
|
73
|
+
vercel: asBooleanFlag(parsed.flags.vercel),
|
|
74
|
+
});
|
|
53
75
|
|
|
54
76
|
const handleClient = (parsed: ParsedArgs): Promise<number> =>
|
|
55
77
|
clientCommand(parsed.positionals, {
|
|
@@ -60,7 +82,7 @@ const handleClient = (parsed: ParsedArgs): Promise<number> =>
|
|
|
60
82
|
const handlers: Record<string, (parsed: ParsedArgs) => Promise<number>> = {
|
|
61
83
|
build: () => handleBuild(),
|
|
62
84
|
client: (parsed) => handleClient(parsed),
|
|
63
|
-
deploy: () => handleDeploy(),
|
|
85
|
+
deploy: (parsed) => handleDeploy(parsed),
|
|
64
86
|
dev: (parsed) => handleDev(parsed),
|
|
65
87
|
init: (parsed) => handleInit(parsed),
|
|
66
88
|
preview: (parsed) => handlePreview(parsed),
|
package/src/cli/prompt.ts
CHANGED
|
@@ -72,3 +72,108 @@ export const promptOptionalText = async (
|
|
|
72
72
|
const trimmed = raw.trim();
|
|
73
73
|
return trimmed.length > 0 ? trimmed : defaultValue;
|
|
74
74
|
};
|
|
75
|
+
|
|
76
|
+
export interface PromptSelectOption<T extends string> {
|
|
77
|
+
label: string;
|
|
78
|
+
value: T;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const parseNumericChoice = (
|
|
82
|
+
raw: string,
|
|
83
|
+
optionsLength: number
|
|
84
|
+
): number | null => {
|
|
85
|
+
const parsed = Number(raw);
|
|
86
|
+
if (!Number.isInteger(parsed)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const index = parsed - 1;
|
|
90
|
+
if (index < 0 || index >= optionsLength) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return index;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const findOptionByValue = <T extends string>(
|
|
97
|
+
options: readonly PromptSelectOption<T>[],
|
|
98
|
+
raw: string
|
|
99
|
+
): PromptSelectOption<T> | null => {
|
|
100
|
+
const direct = options.find((option) => option.value === raw);
|
|
101
|
+
return direct ?? null;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const getDefaultOptionIndex = <T extends string>(
|
|
105
|
+
options: readonly PromptSelectOption<T>[],
|
|
106
|
+
defaultValue: T
|
|
107
|
+
): number => {
|
|
108
|
+
if (options.length === 0) {
|
|
109
|
+
throw new Error("promptSelect requires at least one option.");
|
|
110
|
+
}
|
|
111
|
+
const defaultIndex = options.findIndex(
|
|
112
|
+
(option) => option.value === defaultValue
|
|
113
|
+
);
|
|
114
|
+
if (defaultIndex === -1) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`promptSelect default value "${defaultValue}" is not in options.`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return defaultIndex;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const printSelectPrompt = <T extends string>(args: {
|
|
123
|
+
defaultIndex: number;
|
|
124
|
+
options: readonly PromptSelectOption<T>[];
|
|
125
|
+
question: string;
|
|
126
|
+
}): void => {
|
|
127
|
+
process.stdout.write(`${args.question}\n`);
|
|
128
|
+
for (const [index, option] of args.options.entries()) {
|
|
129
|
+
const suffix = index === args.defaultIndex ? " (default)" : "";
|
|
130
|
+
process.stdout.write(` ${String(index + 1)}. ${option.label}${suffix}\n`);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const resolveSelectedValue = <T extends string>(args: {
|
|
135
|
+
defaultValue: T;
|
|
136
|
+
input: string;
|
|
137
|
+
options: readonly PromptSelectOption<T>[];
|
|
138
|
+
}): T | null => {
|
|
139
|
+
if (args.input.length === 0) {
|
|
140
|
+
return args.defaultValue;
|
|
141
|
+
}
|
|
142
|
+
const numericChoice = parseNumericChoice(args.input, args.options.length);
|
|
143
|
+
if (numericChoice !== null) {
|
|
144
|
+
return args.options[numericChoice]?.value ?? args.defaultValue;
|
|
145
|
+
}
|
|
146
|
+
const valueChoice = findOptionByValue(args.options, args.input);
|
|
147
|
+
return valueChoice?.value ?? null;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const readSelection = async <T extends string>(args: {
|
|
151
|
+
defaultIndex: number;
|
|
152
|
+
defaultValue: T;
|
|
153
|
+
options: readonly PromptSelectOption<T>[];
|
|
154
|
+
}): Promise<T> => {
|
|
155
|
+
while (true) {
|
|
156
|
+
const input = await readLine(
|
|
157
|
+
`Select [1-${String(args.options.length)}] (${String(args.defaultIndex + 1)}): `
|
|
158
|
+
);
|
|
159
|
+
const value = resolveSelectedValue({
|
|
160
|
+
defaultValue: args.defaultValue,
|
|
161
|
+
input: input.trim(),
|
|
162
|
+
options: args.options,
|
|
163
|
+
});
|
|
164
|
+
if (value !== null) {
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
process.stdout.write("Invalid selection. Enter a number from the list.\n");
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const promptSelect = <T extends string>(
|
|
172
|
+
question: string,
|
|
173
|
+
options: readonly PromptSelectOption<T>[],
|
|
174
|
+
defaultValue: T
|
|
175
|
+
): Promise<T> => {
|
|
176
|
+
const defaultIndex = getDefaultOptionIndex(options, defaultValue);
|
|
177
|
+
printSelectPrompt({ defaultIndex, options, question });
|
|
178
|
+
return readSelection({ defaultIndex, defaultValue, options });
|
|
179
|
+
};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { ResolvedCachePolicy } from "../site/cache";
|
|
2
|
+
import type { DeployProvider } from "./provider";
|
|
3
|
+
|
|
4
|
+
import { ensureDir } from "./fs";
|
|
5
|
+
import { dirname, joinPath } from "./path";
|
|
6
|
+
|
|
7
|
+
interface ProviderFileOptions {
|
|
8
|
+
cachePolicy: ResolvedCachePolicy;
|
|
9
|
+
packageName: string;
|
|
10
|
+
targetDir: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DOCKERFILE_PATH = "Dockerfile";
|
|
14
|
+
const DOCKERIGNORE_PATH = ".dockerignore";
|
|
15
|
+
const FLY_CONFIG_PATH = "fly.toml";
|
|
16
|
+
const RAILWAY_CONFIG_PATH = "railway.json";
|
|
17
|
+
const VERCEL_CONFIG_PATH = "vercel.json";
|
|
18
|
+
|
|
19
|
+
const DOCKERFILE = `FROM oven/bun:1
|
|
20
|
+
|
|
21
|
+
WORKDIR /app
|
|
22
|
+
|
|
23
|
+
COPY package.json bun.lock* ./
|
|
24
|
+
RUN bun install --frozen-lockfile
|
|
25
|
+
|
|
26
|
+
COPY . .
|
|
27
|
+
|
|
28
|
+
RUN bun run build
|
|
29
|
+
|
|
30
|
+
ENV NODE_ENV=production
|
|
31
|
+
ENV PORT=8080
|
|
32
|
+
|
|
33
|
+
EXPOSE 8080
|
|
34
|
+
|
|
35
|
+
CMD ["bun", "src/server.ts"]
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const DOCKERIGNORE = `.git
|
|
39
|
+
.github
|
|
40
|
+
.gitignore
|
|
41
|
+
node_modules
|
|
42
|
+
public
|
|
43
|
+
.env
|
|
44
|
+
.env.*
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
const sanitizeFlyAppName = (value: string): string => {
|
|
48
|
+
const normalized = value
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.replaceAll(/[^a-z0-9-]/g, "-")
|
|
51
|
+
.replaceAll(/-+/g, "-")
|
|
52
|
+
.replaceAll(/^-+|-+$/g, "");
|
|
53
|
+
if (normalized.length === 0) {
|
|
54
|
+
return "idcmd-app";
|
|
55
|
+
}
|
|
56
|
+
return normalized.slice(0, 63);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const buildCacheControl = (args: {
|
|
60
|
+
browserCacheControl: string;
|
|
61
|
+
edgeCacheControl: string | null;
|
|
62
|
+
}): string =>
|
|
63
|
+
args.edgeCacheControl
|
|
64
|
+
? `${args.browserCacheControl}, ${args.edgeCacheControl}`
|
|
65
|
+
: args.browserCacheControl;
|
|
66
|
+
|
|
67
|
+
const createVercelHeaders = (
|
|
68
|
+
cachePolicy: ResolvedCachePolicy
|
|
69
|
+
): {
|
|
70
|
+
headers: { key: string; value: string }[];
|
|
71
|
+
source: string;
|
|
72
|
+
}[] => {
|
|
73
|
+
const htmlHeaders = [
|
|
74
|
+
{
|
|
75
|
+
key: "Cache-Control",
|
|
76
|
+
value: buildCacheControl({
|
|
77
|
+
browserCacheControl: cachePolicy.html.browserCacheControl,
|
|
78
|
+
edgeCacheControl: null,
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
if (cachePolicy.html.edgeCacheControl) {
|
|
83
|
+
htmlHeaders.push({
|
|
84
|
+
key: "Vercel-CDN-Cache-Control",
|
|
85
|
+
value: cachePolicy.html.edgeCacheControl,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return [
|
|
90
|
+
{ headers: htmlHeaders, source: "/(.*)" },
|
|
91
|
+
{
|
|
92
|
+
headers: [
|
|
93
|
+
{ key: "Cache-Control", value: cachePolicy.static.cacheControl },
|
|
94
|
+
],
|
|
95
|
+
source: "/(.*\\..*)",
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const createVercelConfig = (cachePolicy: ResolvedCachePolicy): string =>
|
|
101
|
+
`${JSON.stringify(
|
|
102
|
+
{
|
|
103
|
+
$schema: "https://openapi.vercel.sh/vercel.json",
|
|
104
|
+
buildCommand: "bun run build",
|
|
105
|
+
bunVersion: "1.x",
|
|
106
|
+
headers: createVercelHeaders(cachePolicy),
|
|
107
|
+
installCommand: "bun install",
|
|
108
|
+
outputDirectory: "public",
|
|
109
|
+
},
|
|
110
|
+
null,
|
|
111
|
+
2
|
|
112
|
+
)}\n`;
|
|
113
|
+
|
|
114
|
+
const createFlyToml = (packageName: string): string =>
|
|
115
|
+
`app = "${sanitizeFlyAppName(packageName)}"
|
|
116
|
+
primary_region = "iad"
|
|
117
|
+
|
|
118
|
+
[env]
|
|
119
|
+
NODE_ENV = "production"
|
|
120
|
+
PORT = "8080"
|
|
121
|
+
|
|
122
|
+
[http_service]
|
|
123
|
+
internal_port = 8080
|
|
124
|
+
force_https = true
|
|
125
|
+
auto_stop_machines = "stop"
|
|
126
|
+
auto_start_machines = true
|
|
127
|
+
min_machines_running = 0
|
|
128
|
+
processes = ["app"]
|
|
129
|
+
|
|
130
|
+
[[http_service.checks]]
|
|
131
|
+
grace_period = "10s"
|
|
132
|
+
interval = "30s"
|
|
133
|
+
method = "GET"
|
|
134
|
+
timeout = "5s"
|
|
135
|
+
path = "/health"
|
|
136
|
+
`;
|
|
137
|
+
|
|
138
|
+
const createRailwayConfig = (): string =>
|
|
139
|
+
`${JSON.stringify(
|
|
140
|
+
{
|
|
141
|
+
$schema: "https://railway.com/railway.schema.json",
|
|
142
|
+
build: {
|
|
143
|
+
builder: "DOCKERFILE",
|
|
144
|
+
},
|
|
145
|
+
deploy: {
|
|
146
|
+
healthcheckPath: "/health",
|
|
147
|
+
healthcheckTimeout: 120,
|
|
148
|
+
restartPolicyMaxRetries: 10,
|
|
149
|
+
restartPolicyType: "ON_FAILURE",
|
|
150
|
+
startCommand: "bun src/server.ts",
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
null,
|
|
154
|
+
2
|
|
155
|
+
)}\n`;
|
|
156
|
+
|
|
157
|
+
const writeFile = async (
|
|
158
|
+
targetDir: string,
|
|
159
|
+
relativePath: string,
|
|
160
|
+
text: string
|
|
161
|
+
): Promise<void> => {
|
|
162
|
+
const path = joinPath(targetDir, relativePath);
|
|
163
|
+
await ensureDir(dirname(path));
|
|
164
|
+
await Bun.write(path, text);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const writeVercelFiles = async (
|
|
168
|
+
args: ProviderFileOptions
|
|
169
|
+
): Promise<string[]> => {
|
|
170
|
+
await writeFile(
|
|
171
|
+
args.targetDir,
|
|
172
|
+
VERCEL_CONFIG_PATH,
|
|
173
|
+
createVercelConfig(args.cachePolicy)
|
|
174
|
+
);
|
|
175
|
+
return [VERCEL_CONFIG_PATH];
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const writeFlyFiles = async (args: ProviderFileOptions): Promise<string[]> => {
|
|
179
|
+
await writeFile(args.targetDir, DOCKERFILE_PATH, DOCKERFILE);
|
|
180
|
+
await writeFile(args.targetDir, DOCKERIGNORE_PATH, DOCKERIGNORE);
|
|
181
|
+
await writeFile(
|
|
182
|
+
args.targetDir,
|
|
183
|
+
FLY_CONFIG_PATH,
|
|
184
|
+
createFlyToml(args.packageName)
|
|
185
|
+
);
|
|
186
|
+
return [DOCKERFILE_PATH, DOCKERIGNORE_PATH, FLY_CONFIG_PATH];
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const writeRailwayFiles = async (
|
|
190
|
+
args: ProviderFileOptions
|
|
191
|
+
): Promise<string[]> => {
|
|
192
|
+
await writeFile(args.targetDir, DOCKERFILE_PATH, DOCKERFILE);
|
|
193
|
+
await writeFile(args.targetDir, DOCKERIGNORE_PATH, DOCKERIGNORE);
|
|
194
|
+
await writeFile(args.targetDir, RAILWAY_CONFIG_PATH, createRailwayConfig());
|
|
195
|
+
return [DOCKERFILE_PATH, DOCKERIGNORE_PATH, RAILWAY_CONFIG_PATH];
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export const generateProviderFiles = (args: {
|
|
199
|
+
cachePolicy: ResolvedCachePolicy;
|
|
200
|
+
packageName: string;
|
|
201
|
+
provider: DeployProvider;
|
|
202
|
+
targetDir: string;
|
|
203
|
+
}): Promise<string[]> => {
|
|
204
|
+
const options = {
|
|
205
|
+
cachePolicy: args.cachePolicy,
|
|
206
|
+
packageName: args.packageName,
|
|
207
|
+
targetDir: args.targetDir,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
if (args.provider === "vercel") {
|
|
211
|
+
return writeVercelFiles(options);
|
|
212
|
+
}
|
|
213
|
+
if (args.provider === "fly") {
|
|
214
|
+
return writeFlyFiles(options);
|
|
215
|
+
}
|
|
216
|
+
return writeRailwayFiles(options);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export const providerConfigPaths = {
|
|
220
|
+
dockerfile: DOCKERFILE_PATH,
|
|
221
|
+
dockerignore: DOCKERIGNORE_PATH,
|
|
222
|
+
flyToml: FLY_CONFIG_PATH,
|
|
223
|
+
railwayJson: RAILWAY_CONFIG_PATH,
|
|
224
|
+
vercelJson: VERCEL_CONFIG_PATH,
|
|
225
|
+
} as const;
|
|
@@ -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/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
|
});
|
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
|
+
};
|
package/src/site/config.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { ZodError, z } from "zod";
|
|
2
2
|
|
|
3
|
+
import type { CacheConfig } from "./cache";
|
|
4
|
+
|
|
5
|
+
import { CacheConfigSchema } from "./cache";
|
|
6
|
+
|
|
3
7
|
export const SearchScopeSchema = z.enum([
|
|
4
8
|
"full",
|
|
5
9
|
"title",
|
|
@@ -64,6 +68,7 @@ export type GroupConfig = z.infer<typeof GroupConfigSchema>;
|
|
|
64
68
|
export const SiteConfigSchema = z
|
|
65
69
|
.object({
|
|
66
70
|
baseUrl: z.string().url().optional(),
|
|
71
|
+
cache: CacheConfigSchema.optional(),
|
|
67
72
|
description: z.string(),
|
|
68
73
|
groups: z.array(GroupConfigSchema).optional(),
|
|
69
74
|
name: z.string().min(1),
|
|
@@ -146,12 +151,31 @@ const normalizeBaseUrl = (baseUrl: string): string | undefined => {
|
|
|
146
151
|
}
|
|
147
152
|
};
|
|
148
153
|
|
|
149
|
-
const
|
|
154
|
+
const resolveExplicitBaseUrlFromEnv = (): string | undefined => {
|
|
150
155
|
const explicit = process.env.SITE_BASE_URL;
|
|
151
156
|
if (explicit) {
|
|
152
157
|
return normalizeBaseUrl(explicit);
|
|
153
158
|
}
|
|
159
|
+
return undefined;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const resolveRailwayBaseUrlFromEnv = (): string | undefined => {
|
|
163
|
+
const railwayDomain = process.env.RAILWAY_PUBLIC_DOMAIN;
|
|
164
|
+
if (railwayDomain) {
|
|
165
|
+
return normalizeBaseUrl(`https://${railwayDomain}`);
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const resolveFlyBaseUrlFromEnv = (): string | undefined => {
|
|
171
|
+
const flyApp = process.env.FLY_APP_NAME;
|
|
172
|
+
if (flyApp) {
|
|
173
|
+
return normalizeBaseUrl(`https://${flyApp}.fly.dev`);
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
};
|
|
154
177
|
|
|
178
|
+
const resolveVercelBaseUrlFromEnv = (): string | undefined => {
|
|
155
179
|
// Vercel provides hostnames without protocol.
|
|
156
180
|
const vercelUrl =
|
|
157
181
|
process.env.VERCEL_URL ??
|
|
@@ -161,10 +185,15 @@ const resolveBaseUrlFromEnv = (): string | undefined => {
|
|
|
161
185
|
if (vercelUrl) {
|
|
162
186
|
return normalizeBaseUrl(`https://${vercelUrl}`);
|
|
163
187
|
}
|
|
164
|
-
|
|
165
188
|
return undefined;
|
|
166
189
|
};
|
|
167
190
|
|
|
191
|
+
const resolveBaseUrlFromEnv = (): string | undefined =>
|
|
192
|
+
resolveExplicitBaseUrlFromEnv() ??
|
|
193
|
+
resolveRailwayBaseUrlFromEnv() ??
|
|
194
|
+
resolveFlyBaseUrlFromEnv() ??
|
|
195
|
+
resolveVercelBaseUrlFromEnv();
|
|
196
|
+
|
|
168
197
|
const resolveBaseUrl = (baseUrl: string | undefined): string | undefined => {
|
|
169
198
|
const normalizedConfigUrl = baseUrl ? normalizeBaseUrl(baseUrl) : undefined;
|
|
170
199
|
const envUrl = resolveBaseUrlFromEnv();
|
|
@@ -190,7 +219,11 @@ const formatSiteConfigZodError = (
|
|
|
190
219
|
return `${configPath} validation failed:\n${lines.join("\n")}`;
|
|
191
220
|
};
|
|
192
221
|
|
|
193
|
-
const DEFAULT_SITE_CONFIG: SiteConfig = {
|
|
222
|
+
const DEFAULT_SITE_CONFIG: SiteConfig = {
|
|
223
|
+
cache: { preset: "static" },
|
|
224
|
+
description: "",
|
|
225
|
+
name: "idcmd",
|
|
226
|
+
};
|
|
194
227
|
|
|
195
228
|
const parseSiteConfigJsonc = (configPath: string, text: string): unknown => {
|
|
196
229
|
try {
|
|
@@ -229,8 +262,17 @@ export const loadSiteConfig = async (): Promise<SiteConfig> => {
|
|
|
229
262
|
const text = await file.text();
|
|
230
263
|
const raw = parseSiteConfigJsonc(configPath, text);
|
|
231
264
|
const parsed = parseSiteConfigUnknown(configPath, raw);
|
|
232
|
-
return {
|
|
265
|
+
return {
|
|
266
|
+
...parsed,
|
|
267
|
+
baseUrl: resolveBaseUrl(parsed.baseUrl),
|
|
268
|
+
cache: resolveCacheConfig(parsed.cache),
|
|
269
|
+
};
|
|
233
270
|
};
|
|
234
271
|
|
|
235
272
|
export const getSearchScope = (siteConfig: SiteConfig): SearchScope =>
|
|
236
273
|
siteConfig.search?.scope ?? "full";
|
|
274
|
+
|
|
275
|
+
const resolveCacheConfig = (cache: CacheConfig | undefined): CacheConfig => ({
|
|
276
|
+
html: cache?.html,
|
|
277
|
+
preset: cache?.preset ?? "static",
|
|
278
|
+
});
|
|
@@ -43,10 +43,12 @@ idcmd client update runtime --yes
|
|
|
43
43
|
These commands copy the latest baseline implementations from `idcmd` into `src/ui/` and `src/runtime/`.
|
|
44
44
|
Runtime files in `src/runtime/` are compiled automatically by `idcmd dev` and `idcmd build`.
|
|
45
45
|
|
|
46
|
-
## Deploy
|
|
46
|
+
## Deploy
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
|
-
|
|
49
|
+
idcmd deploy --vercel
|
|
50
|
+
idcmd deploy --fly
|
|
51
|
+
idcmd deploy --railway
|
|
50
52
|
```
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
Use one provider flag at a time to generate deployment files.
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "__IDCMD_SITE_NAME__",
|
|
3
3
|
"description": "__IDCMD_SITE_DESCRIPTION__",
|
|
4
|
+
"cache": {
|
|
5
|
+
// "fresh" | "balanced" | "static"
|
|
6
|
+
"preset": "static",
|
|
7
|
+
|
|
8
|
+
// Optional HTML edge cache overrides
|
|
9
|
+
// "html": {
|
|
10
|
+
// "sMaxAgeSeconds": 60,
|
|
11
|
+
// "staleWhileRevalidateSeconds": 3600,
|
|
12
|
+
// },
|
|
13
|
+
},
|
|
4
14
|
"baseUrl": "__IDCMD_SITE_BASE_URL__",
|
|
5
15
|
"groups": [{ "id": "main", "label": "Navigation", "order": 1 }],
|
|
6
16
|
"search": {
|