@webstir-io/webstir 0.1.0
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 +69 -0
- package/assets/features/client_nav/client_nav.ts +469 -0
- package/assets/features/content_nav/content_nav.css +170 -0
- package/assets/features/content_nav/content_nav.ts +358 -0
- package/assets/features/router/router-types.ts +6 -0
- package/assets/features/router/router.ts +118 -0
- package/assets/features/search/search.css +204 -0
- package/assets/features/search/search.ts +627 -0
- package/assets/templates/api/src/backend/index.ts +13 -0
- package/assets/templates/api/src/backend/tsconfig.json +15 -0
- package/assets/templates/api/src/shared/router-types.ts +23 -0
- package/assets/templates/api/src/shared/tsconfig.json +10 -0
- package/assets/templates/api/src/shared/types/index.ts +4 -0
- package/assets/templates/full/src/backend/index.ts +13 -0
- package/assets/templates/full/src/backend/tsconfig.json +15 -0
- package/assets/templates/full/src/frontend/app/app.css +65 -0
- package/assets/templates/full/src/frontend/app/app.html +13 -0
- package/assets/templates/full/src/frontend/app/app.ts +188 -0
- package/assets/templates/full/src/frontend/app/error.ts +127 -0
- package/assets/templates/full/src/frontend/app/hmr.js +355 -0
- package/assets/templates/full/src/frontend/app/navigation.ts +8 -0
- package/assets/templates/full/src/frontend/app/refresh.js +114 -0
- package/assets/templates/full/src/frontend/app/router.ts +126 -0
- package/assets/templates/full/src/frontend/app/styles/base.css +2 -0
- package/assets/templates/full/src/frontend/app/styles/reset.css +48 -0
- package/assets/templates/full/src/frontend/pages/home/index.css +21 -0
- package/assets/templates/full/src/frontend/pages/home/index.html +10 -0
- package/assets/templates/full/src/frontend/pages/home/index.ts +18 -0
- package/assets/templates/full/src/frontend/pages/home/tests/home.test.ts +21 -0
- package/assets/templates/full/src/frontend/tsconfig.json +20 -0
- package/assets/templates/full/src/shared/router-types.ts +23 -0
- package/assets/templates/full/src/shared/tsconfig.json +10 -0
- package/assets/templates/full/src/shared/types/index.ts +4 -0
- package/assets/templates/shared/Errors.404.html +23 -0
- package/assets/templates/shared/Errors.500.html +23 -0
- package/assets/templates/shared/Errors.default.html +23 -0
- package/assets/templates/shared/types/global.d.ts +32 -0
- package/assets/templates/shared/types.global.d.ts +32 -0
- package/assets/templates/spa/src/frontend/app/app.css +65 -0
- package/assets/templates/spa/src/frontend/app/app.html +13 -0
- package/assets/templates/spa/src/frontend/app/app.ts +188 -0
- package/assets/templates/spa/src/frontend/app/error.ts +127 -0
- package/assets/templates/spa/src/frontend/app/hmr.js +355 -0
- package/assets/templates/spa/src/frontend/app/navigation.ts +8 -0
- package/assets/templates/spa/src/frontend/app/refresh.js +114 -0
- package/assets/templates/spa/src/frontend/app/router.ts +126 -0
- package/assets/templates/spa/src/frontend/app/styles/base.css +2 -0
- package/assets/templates/spa/src/frontend/app/styles/reset.css +48 -0
- package/assets/templates/spa/src/frontend/pages/home/index.css +21 -0
- package/assets/templates/spa/src/frontend/pages/home/index.html +10 -0
- package/assets/templates/spa/src/frontend/pages/home/index.ts +18 -0
- package/assets/templates/spa/src/frontend/pages/home/tests/home.test.ts +21 -0
- package/assets/templates/spa/src/frontend/tsconfig.json +20 -0
- package/assets/templates/spa/src/shared/router-types.ts +23 -0
- package/assets/templates/spa/src/shared/tsconfig.json +10 -0
- package/assets/templates/spa/src/shared/types/index.ts +4 -0
- package/assets/templates/ssg/src/frontend/app/app.css +12 -0
- package/assets/templates/ssg/src/frontend/app/app.html +43 -0
- package/assets/templates/ssg/src/frontend/app/app.ts +190 -0
- package/assets/templates/ssg/src/frontend/app/error.ts +127 -0
- package/assets/templates/ssg/src/frontend/app/hmr.js +370 -0
- package/assets/templates/ssg/src/frontend/app/refresh.js +163 -0
- package/assets/templates/ssg/src/frontend/app/scripts/components/drawer.ts +183 -0
- package/assets/templates/ssg/src/frontend/app/scripts/components/menu.ts +75 -0
- package/assets/templates/ssg/src/frontend/app/styles/base.css +77 -0
- package/assets/templates/ssg/src/frontend/app/styles/components/buttons.css +108 -0
- package/assets/templates/ssg/src/frontend/app/styles/components/drawer.css +12 -0
- package/assets/templates/ssg/src/frontend/app/styles/components/header.css +164 -0
- package/assets/templates/ssg/src/frontend/app/styles/components/markdown.css +25 -0
- package/assets/templates/ssg/src/frontend/app/styles/layout.css +41 -0
- package/assets/templates/ssg/src/frontend/app/styles/reset.css +56 -0
- package/assets/templates/ssg/src/frontend/app/styles/tokens.css +72 -0
- package/assets/templates/ssg/src/frontend/app/styles/utilities.css +14 -0
- package/assets/templates/ssg/src/frontend/content/_sidebar.json +14 -0
- package/assets/templates/ssg/src/frontend/content/content-pipeline.md +82 -0
- package/assets/templates/ssg/src/frontend/content/css-playbook.md +68 -0
- package/assets/templates/ssg/src/frontend/content/hosting.md +48 -0
- package/assets/templates/ssg/src/frontend/pages/about/index.css +33 -0
- package/assets/templates/ssg/src/frontend/pages/about/index.html +60 -0
- package/assets/templates/ssg/src/frontend/pages/docs/index.css +505 -0
- package/assets/templates/ssg/src/frontend/pages/docs/index.html +52 -0
- package/assets/templates/ssg/src/frontend/pages/docs/index.ts +495 -0
- package/assets/templates/ssg/src/frontend/pages/home/index.css +91 -0
- package/assets/templates/ssg/src/frontend/pages/home/index.html +38 -0
- package/assets/templates/ssg/src/frontend/pages/home/tests/home.test.ts +24 -0
- package/assets/templates/ssg/src/frontend/tsconfig.json +13 -0
- package/package.json +41 -0
- package/scripts/pack-standalone.mjs +127 -0
- package/scripts/sync-assets.mjs +87 -0
- package/src/add-backend.ts +164 -0
- package/src/add.ts +112 -0
- package/src/api-watch.ts +84 -0
- package/src/backend-inspect.ts +45 -0
- package/src/backend-runtime.ts +286 -0
- package/src/build-plan.ts +12 -0
- package/src/build.ts +10 -0
- package/src/cli.ts +569 -0
- package/src/compile-tests.ts +61 -0
- package/src/dev-server.ts +393 -0
- package/src/enable-assets.ts +196 -0
- package/src/enable.ts +477 -0
- package/src/execute.ts +85 -0
- package/src/format.ts +254 -0
- package/src/frontend-watch.ts +145 -0
- package/src/full-watch.ts +80 -0
- package/src/index.ts +20 -0
- package/src/init-assets.ts +96 -0
- package/src/init.ts +339 -0
- package/src/paths.ts +26 -0
- package/src/providers.ts +88 -0
- package/src/publish.ts +8 -0
- package/src/refresh.ts +56 -0
- package/src/repair.ts +414 -0
- package/src/runtime.ts +48 -0
- package/src/smoke.ts +161 -0
- package/src/stop-signal.ts +26 -0
- package/src/test.ts +215 -0
- package/src/types.ts +29 -0
- package/src/watch-daemon-client.ts +171 -0
- package/src/watch-events.ts +195 -0
- package/src/watch.ts +66 -0
- package/src/workspace-watcher.ts +251 -0
- package/src/workspace.ts +55 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
4
|
+
import { access } from 'node:fs/promises';
|
|
5
|
+
|
|
6
|
+
import type { HotUpdatePayload, WatchStatus } from './watch-events.ts';
|
|
7
|
+
|
|
8
|
+
export interface DevServerOptions {
|
|
9
|
+
readonly buildRoot: string;
|
|
10
|
+
readonly host?: string;
|
|
11
|
+
readonly port?: number;
|
|
12
|
+
readonly apiProxyOrigin?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DevServerAddress {
|
|
16
|
+
readonly host: string;
|
|
17
|
+
readonly port: number;
|
|
18
|
+
readonly origin: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MIME_TYPES: Record<string, string> = {
|
|
22
|
+
'.html': 'text/html; charset=utf-8',
|
|
23
|
+
'.css': 'text/css; charset=utf-8',
|
|
24
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
25
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
26
|
+
'.json': 'application/json; charset=utf-8',
|
|
27
|
+
'.svg': 'image/svg+xml',
|
|
28
|
+
'.png': 'image/png',
|
|
29
|
+
'.jpg': 'image/jpeg',
|
|
30
|
+
'.jpeg': 'image/jpeg',
|
|
31
|
+
'.webp': 'image/webp',
|
|
32
|
+
'.gif': 'image/gif',
|
|
33
|
+
'.ico': 'image/x-icon',
|
|
34
|
+
'.woff': 'font/woff',
|
|
35
|
+
'.woff2': 'font/woff2',
|
|
36
|
+
'.ttf': 'font/ttf',
|
|
37
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
38
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
39
|
+
'.map': 'application/json; charset=utf-8',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const RESERVED_PREFIXES = ['__webstir', 'api', 'fonts', 'images', 'media', 'pages', 'sse'];
|
|
43
|
+
const STATIC_EXTENSIONS = new Set([
|
|
44
|
+
'.css', '.js', '.mjs', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico',
|
|
45
|
+
'.woff', '.woff2', '.ttf', '.otf', '.eot', '.mp3', '.m4a', '.wav', '.ogg', '.mp4',
|
|
46
|
+
'.webm', '.mov', '.json', '.txt', '.xml', '.map',
|
|
47
|
+
]);
|
|
48
|
+
const CONTENT_HASH_PATTERN = /\.[a-f0-9]{8,64}\.(css|js|png|jpg|jpeg|gif|svg|webp|ico|woff2?|ttf|otf|eot|mp3|m4a|wav|ogg|mp4|webm|mov)$/i;
|
|
49
|
+
|
|
50
|
+
interface SseClient {
|
|
51
|
+
readonly response: ServerResponse<IncomingMessage>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class DevServer {
|
|
55
|
+
private readonly buildRoot: string;
|
|
56
|
+
private readonly host: string;
|
|
57
|
+
private readonly port: number;
|
|
58
|
+
private readonly apiProxyOrigin?: string;
|
|
59
|
+
private readonly clients = new Set<SseClient>();
|
|
60
|
+
private server?: http.Server;
|
|
61
|
+
|
|
62
|
+
public constructor(options: DevServerOptions) {
|
|
63
|
+
this.buildRoot = path.resolve(options.buildRoot);
|
|
64
|
+
this.host = options.host ?? '127.0.0.1';
|
|
65
|
+
this.port = options.port ?? 8088;
|
|
66
|
+
this.apiProxyOrigin = options.apiProxyOrigin;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public async start(): Promise<DevServerAddress> {
|
|
70
|
+
if (this.server) {
|
|
71
|
+
return this.getAddress();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.server = http.createServer((request, response) => {
|
|
75
|
+
void this.handleRequest(request, response);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await new Promise<void>((resolve, reject) => {
|
|
79
|
+
this.server!.once('error', reject);
|
|
80
|
+
this.server!.listen(this.port, this.host, () => {
|
|
81
|
+
this.server!.off('error', reject);
|
|
82
|
+
resolve();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return this.getAddress();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public async stop(): Promise<void> {
|
|
90
|
+
await this.broadcastRaw('data: shutdown\n\n');
|
|
91
|
+
|
|
92
|
+
for (const client of this.clients) {
|
|
93
|
+
client.response.end();
|
|
94
|
+
}
|
|
95
|
+
this.clients.clear();
|
|
96
|
+
|
|
97
|
+
if (!this.server) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const server = this.server;
|
|
102
|
+
this.server = undefined;
|
|
103
|
+
await new Promise<void>((resolve, reject) => {
|
|
104
|
+
server.close((error) => {
|
|
105
|
+
if (error) {
|
|
106
|
+
reject(error);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
resolve();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
public async publishStatus(status: WatchStatus): Promise<void> {
|
|
116
|
+
await this.broadcastEvent('status', status);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public async publishReload(): Promise<void> {
|
|
120
|
+
await this.broadcastRaw('data: reload\n\n');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
public async publishHotUpdate(payload: HotUpdatePayload): Promise<void> {
|
|
124
|
+
await this.broadcastEvent('hmr', JSON.stringify(payload));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private getAddress(): DevServerAddress {
|
|
128
|
+
if (!this.server) {
|
|
129
|
+
throw new Error('Dev server has not started.');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const address = this.server.address();
|
|
133
|
+
if (!address || typeof address === 'string') {
|
|
134
|
+
throw new Error('Dev server did not expose a TCP address.');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const originHost = this.host === '0.0.0.0' ? '127.0.0.1' : this.host;
|
|
138
|
+
return {
|
|
139
|
+
host: originHost,
|
|
140
|
+
port: address.port,
|
|
141
|
+
origin: `http://${originHost}:${address.port}`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async handleRequest(
|
|
146
|
+
request: IncomingMessage,
|
|
147
|
+
response: ServerResponse<IncomingMessage>
|
|
148
|
+
): Promise<void> {
|
|
149
|
+
if (!request.url) {
|
|
150
|
+
this.writeText(response, 400, 'Bad request.');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const method = request.method ?? 'GET';
|
|
155
|
+
|
|
156
|
+
const requestUrl = new URL(request.url, 'http://localhost');
|
|
157
|
+
const { pathname } = requestUrl;
|
|
158
|
+
if (pathname === '/sse') {
|
|
159
|
+
this.handleSse(response);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const apiProxyPath = getApiProxyPath(pathname);
|
|
164
|
+
if (apiProxyPath !== null && this.apiProxyOrigin) {
|
|
165
|
+
await this.handleApiProxy(request, response, requestUrl, apiProxyPath);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
170
|
+
this.writeText(response, 405, 'Method not allowed.');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const candidates = getStaticCandidatePaths(pathname);
|
|
175
|
+
const resolved = await resolveStaticFile(this.buildRoot, candidates);
|
|
176
|
+
if (!resolved) {
|
|
177
|
+
this.writeText(response, 404, 'Not found.');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const lowerRelativePath = resolved.relativePath.toLowerCase();
|
|
182
|
+
const extension = path.extname(lowerRelativePath).toLowerCase();
|
|
183
|
+
response.setHeader('Content-Type', MIME_TYPES[extension] ?? 'application/octet-stream');
|
|
184
|
+
setCacheHeaders(response, lowerRelativePath);
|
|
185
|
+
|
|
186
|
+
if (method === 'HEAD') {
|
|
187
|
+
response.statusCode = 200;
|
|
188
|
+
response.end();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const stream = createReadStream(resolved.absolutePath);
|
|
193
|
+
stream.once('error', () => {
|
|
194
|
+
if (!response.headersSent) {
|
|
195
|
+
this.writeText(response, 500, 'Failed to read file.');
|
|
196
|
+
} else {
|
|
197
|
+
response.destroy();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
stream.pipe(response);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async handleApiProxy(
|
|
204
|
+
request: IncomingMessage,
|
|
205
|
+
response: ServerResponse<IncomingMessage>,
|
|
206
|
+
requestUrl: URL,
|
|
207
|
+
apiProxyPath: string
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
const targetUrl = new URL(apiProxyPath + requestUrl.search, this.apiProxyOrigin);
|
|
210
|
+
|
|
211
|
+
await new Promise<void>((resolve) => {
|
|
212
|
+
const proxyRequest = http.request(targetUrl, {
|
|
213
|
+
agent: false,
|
|
214
|
+
method: request.method,
|
|
215
|
+
headers: {
|
|
216
|
+
...request.headers,
|
|
217
|
+
host: targetUrl.host,
|
|
218
|
+
connection: 'close',
|
|
219
|
+
},
|
|
220
|
+
}, (proxyResponse) => {
|
|
221
|
+
response.writeHead(proxyResponse.statusCode ?? 502, proxyResponse.headers);
|
|
222
|
+
proxyResponse.pipe(response);
|
|
223
|
+
proxyResponse.once('end', resolve);
|
|
224
|
+
proxyResponse.once('error', () => {
|
|
225
|
+
if (!response.headersSent) {
|
|
226
|
+
this.writeText(response, 502, 'Backend proxy read failed.');
|
|
227
|
+
} else {
|
|
228
|
+
response.destroy();
|
|
229
|
+
}
|
|
230
|
+
resolve();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
proxyRequest.once('error', () => {
|
|
235
|
+
if (!response.headersSent) {
|
|
236
|
+
this.writeText(response, 502, 'Backend proxy failed.');
|
|
237
|
+
} else {
|
|
238
|
+
response.destroy();
|
|
239
|
+
}
|
|
240
|
+
resolve();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
244
|
+
proxyRequest.end();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
request.pipe(proxyRequest);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private handleSse(response: ServerResponse<IncomingMessage>): void {
|
|
253
|
+
response.writeHead(200, {
|
|
254
|
+
'Content-Type': 'text/event-stream',
|
|
255
|
+
'Cache-Control': 'no-cache',
|
|
256
|
+
Connection: 'keep-alive',
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const client = { response };
|
|
260
|
+
this.clients.add(client);
|
|
261
|
+
response.write('\n');
|
|
262
|
+
|
|
263
|
+
response.once('close', () => {
|
|
264
|
+
this.clients.delete(client);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private async broadcastEvent(eventName: string, data: string): Promise<void> {
|
|
269
|
+
await this.broadcastRaw(`event: ${eventName}\ndata: ${data}\n\n`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private async broadcastRaw(message: string): Promise<void> {
|
|
273
|
+
for (const client of Array.from(this.clients)) {
|
|
274
|
+
try {
|
|
275
|
+
client.response.write(message);
|
|
276
|
+
} catch {
|
|
277
|
+
this.clients.delete(client);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private writeText(
|
|
283
|
+
response: ServerResponse<IncomingMessage>,
|
|
284
|
+
statusCode: number,
|
|
285
|
+
body: string
|
|
286
|
+
): void {
|
|
287
|
+
response.statusCode = statusCode;
|
|
288
|
+
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
289
|
+
response.end(body);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function getStaticCandidatePaths(pathname: string): readonly string[] {
|
|
294
|
+
const relativePath = normalizeRequestPath(pathname);
|
|
295
|
+
const candidates: string[] = [];
|
|
296
|
+
|
|
297
|
+
if (relativePath) {
|
|
298
|
+
candidates.push(...getGenericFileCandidates(relativePath));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (relativePath === '') {
|
|
302
|
+
candidates.push('pages/home/index.html');
|
|
303
|
+
} else if (/^index\.(?!html$)[^/]+$/i.test(relativePath)) {
|
|
304
|
+
candidates.push(path.posix.join('pages', 'home', relativePath));
|
|
305
|
+
} else if (/^[^/]+\/index\.(js|css)$/i.test(relativePath)) {
|
|
306
|
+
const [pageName, fileName] = relativePath.split('/');
|
|
307
|
+
candidates.push(path.posix.join('pages', pageName, fileName));
|
|
308
|
+
} else if (!path.posix.extname(relativePath) && !hasReservedPrefix(relativePath)) {
|
|
309
|
+
candidates.push(path.posix.join('pages', relativePath, 'index.html'));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return Array.from(new Set(candidates.map(candidate => candidate.replace(/^\/+/, ''))));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function getApiProxyPath(pathname: string): string | null {
|
|
316
|
+
if (pathname === '/api') {
|
|
317
|
+
return '/';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (pathname.startsWith('/api/')) {
|
|
321
|
+
const normalizedPath = path.posix.normalize(pathname.slice('/api'.length));
|
|
322
|
+
return normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function resolveStaticFile(
|
|
329
|
+
buildRoot: string,
|
|
330
|
+
relativePaths: readonly string[]
|
|
331
|
+
): Promise<{ absolutePath: string; relativePath: string } | null> {
|
|
332
|
+
for (const relativePath of relativePaths) {
|
|
333
|
+
const absolutePath = path.resolve(buildRoot, relativePath);
|
|
334
|
+
if (!absolutePath.startsWith(buildRoot + path.sep) && absolutePath !== buildRoot) {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
await access(absolutePath);
|
|
340
|
+
return { absolutePath, relativePath };
|
|
341
|
+
} catch {
|
|
342
|
+
// Try the next candidate.
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function normalizeRequestPath(pathname: string): string {
|
|
350
|
+
const decoded = decodeURIComponent(pathname);
|
|
351
|
+
const normalized = path.posix.normalize(decoded);
|
|
352
|
+
const stripped = normalized.replace(/^(\.\.(\/|\\|$))+/, '').replace(/^\/+/, '');
|
|
353
|
+
return stripped.replace(/\/+$/, '');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function getGenericFileCandidates(relativePath: string): readonly string[] {
|
|
357
|
+
const hasExtension = path.posix.extname(relativePath) !== '';
|
|
358
|
+
const candidates = hasExtension
|
|
359
|
+
? [relativePath]
|
|
360
|
+
: [relativePath, `${relativePath}.html`, path.posix.join(relativePath, 'index.html')];
|
|
361
|
+
|
|
362
|
+
return candidates;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function hasReservedPrefix(relativePath: string): boolean {
|
|
366
|
+
return RESERVED_PREFIXES.some((prefix) => relativePath === prefix || relativePath.startsWith(`${prefix}/`));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function setCacheHeaders(response: ServerResponse<IncomingMessage>, relativePath: string): void {
|
|
370
|
+
if (CONTENT_HASH_PATTERN.test(relativePath)) {
|
|
371
|
+
response.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (relativePath.endsWith('refresh.js') || relativePath.endsWith('hmr.js')) {
|
|
376
|
+
response.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
377
|
+
response.setHeader('Pragma', 'no-cache');
|
|
378
|
+
response.setHeader('Expires', '0');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const extension = path.extname(relativePath).toLowerCase();
|
|
383
|
+
if (extension === '.html' || extension === '') {
|
|
384
|
+
response.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
385
|
+
response.setHeader('Pragma', 'no-cache');
|
|
386
|
+
response.setHeader('Expires', '0');
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (STATIC_EXTENSIONS.has(extension)) {
|
|
391
|
+
response.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
|
392
|
+
}
|
|
393
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { assetsRoot } from './paths.ts';
|
|
4
|
+
|
|
5
|
+
const featuresRoot = path.join(assetsRoot, 'features');
|
|
6
|
+
|
|
7
|
+
export interface StaticFeatureAsset {
|
|
8
|
+
readonly sourcePath: string;
|
|
9
|
+
readonly targetPath: string;
|
|
10
|
+
readonly executable?: boolean;
|
|
11
|
+
readonly overwrite?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getSpaAssets(): readonly StaticFeatureAsset[] {
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
sourcePath: path.join(featuresRoot, 'router', 'router.ts'),
|
|
18
|
+
targetPath: path.join('src', 'frontend', 'app', 'router.ts'),
|
|
19
|
+
overwrite: true,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
sourcePath: path.join(featuresRoot, 'router', 'router-types.ts'),
|
|
23
|
+
targetPath: path.join('src', 'frontend', 'app', 'router-types.ts'),
|
|
24
|
+
overwrite: true,
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getClientNavAssets(): readonly StaticFeatureAsset[] {
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
sourcePath: path.join(featuresRoot, 'client_nav', 'client_nav.ts'),
|
|
33
|
+
targetPath: path.join('src', 'frontend', 'app', 'scripts', 'features', 'client-nav.ts'),
|
|
34
|
+
overwrite: true,
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getSearchAssets(): readonly StaticFeatureAsset[] {
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
sourcePath: path.join(featuresRoot, 'search', 'search.ts'),
|
|
43
|
+
targetPath: path.join('src', 'frontend', 'app', 'scripts', 'features', 'search.ts'),
|
|
44
|
+
overwrite: true,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
sourcePath: path.join(featuresRoot, 'search', 'search.css'),
|
|
48
|
+
targetPath: path.join('src', 'frontend', 'app', 'styles', 'features', 'search.css'),
|
|
49
|
+
overwrite: true,
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getContentNavAssets(): readonly StaticFeatureAsset[] {
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
sourcePath: path.join(featuresRoot, 'content_nav', 'content_nav.ts'),
|
|
58
|
+
targetPath: path.join('src', 'frontend', 'app', 'scripts', 'features', 'content-nav.ts'),
|
|
59
|
+
overwrite: true,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
sourcePath: path.join(featuresRoot, 'content_nav', 'content_nav.css'),
|
|
63
|
+
targetPath: path.join('src', 'frontend', 'app', 'styles', 'features', 'content-nav.css'),
|
|
64
|
+
overwrite: true,
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const pageScriptTemplate = `// Client-side script for this page.
|
|
70
|
+
// Add your interactive behavior here. This runs after the static HTML renders.
|
|
71
|
+
|
|
72
|
+
console.info('[webstir] Page script loaded.');
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
export function renderGithubPagesDeployScript(): string {
|
|
76
|
+
return `#!/usr/bin/env bash
|
|
77
|
+
set -euo pipefail
|
|
78
|
+
|
|
79
|
+
ROOT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
|
|
80
|
+
DIST_DIR="$ROOT_DIR/dist/frontend"
|
|
81
|
+
REMOTE="\${GH_PAGES_REMOTE:-origin}"
|
|
82
|
+
BRANCH="\${GH_PAGES_BRANCH:-gh-pages}"
|
|
83
|
+
COMMIT_MESSAGE="\${GH_PAGES_COMMIT_MESSAGE:-Deploy}"
|
|
84
|
+
COMMIT_NAME="\${GH_PAGES_COMMIT_NAME:-github-actions[bot]}"
|
|
85
|
+
COMMIT_EMAIL="\${GH_PAGES_COMMIT_EMAIL:-github-actions[bot]@users.noreply.github.com}"
|
|
86
|
+
|
|
87
|
+
WORKTREE_DIR=""
|
|
88
|
+
cleanup() {
|
|
89
|
+
if [[ -n "\${WORKTREE_DIR}" && -d "\${WORKTREE_DIR}" ]]; then
|
|
90
|
+
git worktree remove --force "$WORKTREE_DIR" >/dev/null 2>&1 || true
|
|
91
|
+
rm -rf "$WORKTREE_DIR"
|
|
92
|
+
fi
|
|
93
|
+
}
|
|
94
|
+
trap cleanup EXIT
|
|
95
|
+
|
|
96
|
+
publish_site() {
|
|
97
|
+
if [[ -n "\${WEBSTIR_PUBLISH_CMD:-}" ]]; then
|
|
98
|
+
echo "[gh-pages] Running WEBSTIR_PUBLISH_CMD..."
|
|
99
|
+
bash -lc "\${WEBSTIR_PUBLISH_CMD}"
|
|
100
|
+
return
|
|
101
|
+
fi
|
|
102
|
+
|
|
103
|
+
echo "[gh-pages] Running Bun publish fallback..."
|
|
104
|
+
bunx --bun webstir-frontend publish -w "$ROOT_DIR" -m ssg
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
echo "[gh-pages] Publishing static site..."
|
|
108
|
+
publish_site
|
|
109
|
+
|
|
110
|
+
if [[ ! -d "$DIST_DIR" ]]; then
|
|
111
|
+
echo "[gh-pages] Expected dist at $DIST_DIR but it was not found." >&2
|
|
112
|
+
exit 1
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
git fetch "$REMOTE" "$BRANCH" >/dev/null 2>&1 || true
|
|
116
|
+
|
|
117
|
+
WORKTREE_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t webstir-gh-pages)"
|
|
118
|
+
if git show-ref --verify --quiet "refs/remotes/$REMOTE/$BRANCH"; then
|
|
119
|
+
git worktree add "$WORKTREE_DIR" "$REMOTE/$BRANCH" >/dev/null
|
|
120
|
+
else
|
|
121
|
+
git worktree add -b "$BRANCH" "$WORKTREE_DIR" >/dev/null
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
rm -rf "$WORKTREE_DIR"/*
|
|
125
|
+
for entry in "$WORKTREE_DIR"/.*; do
|
|
126
|
+
name="$(basename "$entry")"
|
|
127
|
+
if [[ "$name" == "." || "$name" == ".." || "$name" == ".git" ]]; then
|
|
128
|
+
continue
|
|
129
|
+
fi
|
|
130
|
+
rm -rf "$entry"
|
|
131
|
+
done
|
|
132
|
+
cp -R "$DIST_DIR"/. "$WORKTREE_DIR"/
|
|
133
|
+
touch "$WORKTREE_DIR/.nojekyll"
|
|
134
|
+
|
|
135
|
+
if [[ -z "$(git -C "$WORKTREE_DIR" config user.name || true)" ]]; then
|
|
136
|
+
git -C "$WORKTREE_DIR" config user.name "$COMMIT_NAME"
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
if [[ -z "$(git -C "$WORKTREE_DIR" config user.email || true)" ]]; then
|
|
140
|
+
git -C "$WORKTREE_DIR" config user.email "$COMMIT_EMAIL"
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
git -C "$WORKTREE_DIR" add -A
|
|
144
|
+
if git -C "$WORKTREE_DIR" diff --cached --quiet; then
|
|
145
|
+
echo "[gh-pages] No changes to deploy."
|
|
146
|
+
exit 0
|
|
147
|
+
fi
|
|
148
|
+
|
|
149
|
+
git -C "$WORKTREE_DIR" commit -m "$COMMIT_MESSAGE"
|
|
150
|
+
if [[ -n "\${GH_PAGES_NO_PUSH:-}" ]]; then
|
|
151
|
+
echo "[gh-pages] Skipping push (GH_PAGES_NO_PUSH is set)."
|
|
152
|
+
exit 0
|
|
153
|
+
fi
|
|
154
|
+
|
|
155
|
+
git -C "$WORKTREE_DIR" push "$REMOTE" HEAD:"$BRANCH"
|
|
156
|
+
echo "[gh-pages] Deployed to $REMOTE/$BRANCH"
|
|
157
|
+
`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function renderGithubPagesWorkflow(): string {
|
|
161
|
+
return `name: Deploy GitHub Pages
|
|
162
|
+
|
|
163
|
+
on:
|
|
164
|
+
push:
|
|
165
|
+
branches:
|
|
166
|
+
- main
|
|
167
|
+
workflow_dispatch:
|
|
168
|
+
|
|
169
|
+
permissions:
|
|
170
|
+
contents: write
|
|
171
|
+
|
|
172
|
+
concurrency:
|
|
173
|
+
group: gh-pages
|
|
174
|
+
cancel-in-progress: true
|
|
175
|
+
|
|
176
|
+
jobs:
|
|
177
|
+
deploy:
|
|
178
|
+
runs-on: ubuntu-latest
|
|
179
|
+
steps:
|
|
180
|
+
- name: Checkout repository
|
|
181
|
+
uses: actions/checkout@v4
|
|
182
|
+
with:
|
|
183
|
+
fetch-depth: 0
|
|
184
|
+
|
|
185
|
+
- name: Setup Bun
|
|
186
|
+
uses: oven-sh/setup-bun@v2
|
|
187
|
+
with:
|
|
188
|
+
bun-version: 1.3.5
|
|
189
|
+
|
|
190
|
+
- name: Install dependencies
|
|
191
|
+
run: bun install --frozen-lockfile
|
|
192
|
+
|
|
193
|
+
- name: Deploy
|
|
194
|
+
run: bun run deploy
|
|
195
|
+
`;
|
|
196
|
+
}
|