@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.
Files changed (123) hide show
  1. package/README.md +69 -0
  2. package/assets/features/client_nav/client_nav.ts +469 -0
  3. package/assets/features/content_nav/content_nav.css +170 -0
  4. package/assets/features/content_nav/content_nav.ts +358 -0
  5. package/assets/features/router/router-types.ts +6 -0
  6. package/assets/features/router/router.ts +118 -0
  7. package/assets/features/search/search.css +204 -0
  8. package/assets/features/search/search.ts +627 -0
  9. package/assets/templates/api/src/backend/index.ts +13 -0
  10. package/assets/templates/api/src/backend/tsconfig.json +15 -0
  11. package/assets/templates/api/src/shared/router-types.ts +23 -0
  12. package/assets/templates/api/src/shared/tsconfig.json +10 -0
  13. package/assets/templates/api/src/shared/types/index.ts +4 -0
  14. package/assets/templates/full/src/backend/index.ts +13 -0
  15. package/assets/templates/full/src/backend/tsconfig.json +15 -0
  16. package/assets/templates/full/src/frontend/app/app.css +65 -0
  17. package/assets/templates/full/src/frontend/app/app.html +13 -0
  18. package/assets/templates/full/src/frontend/app/app.ts +188 -0
  19. package/assets/templates/full/src/frontend/app/error.ts +127 -0
  20. package/assets/templates/full/src/frontend/app/hmr.js +355 -0
  21. package/assets/templates/full/src/frontend/app/navigation.ts +8 -0
  22. package/assets/templates/full/src/frontend/app/refresh.js +114 -0
  23. package/assets/templates/full/src/frontend/app/router.ts +126 -0
  24. package/assets/templates/full/src/frontend/app/styles/base.css +2 -0
  25. package/assets/templates/full/src/frontend/app/styles/reset.css +48 -0
  26. package/assets/templates/full/src/frontend/pages/home/index.css +21 -0
  27. package/assets/templates/full/src/frontend/pages/home/index.html +10 -0
  28. package/assets/templates/full/src/frontend/pages/home/index.ts +18 -0
  29. package/assets/templates/full/src/frontend/pages/home/tests/home.test.ts +21 -0
  30. package/assets/templates/full/src/frontend/tsconfig.json +20 -0
  31. package/assets/templates/full/src/shared/router-types.ts +23 -0
  32. package/assets/templates/full/src/shared/tsconfig.json +10 -0
  33. package/assets/templates/full/src/shared/types/index.ts +4 -0
  34. package/assets/templates/shared/Errors.404.html +23 -0
  35. package/assets/templates/shared/Errors.500.html +23 -0
  36. package/assets/templates/shared/Errors.default.html +23 -0
  37. package/assets/templates/shared/types/global.d.ts +32 -0
  38. package/assets/templates/shared/types.global.d.ts +32 -0
  39. package/assets/templates/spa/src/frontend/app/app.css +65 -0
  40. package/assets/templates/spa/src/frontend/app/app.html +13 -0
  41. package/assets/templates/spa/src/frontend/app/app.ts +188 -0
  42. package/assets/templates/spa/src/frontend/app/error.ts +127 -0
  43. package/assets/templates/spa/src/frontend/app/hmr.js +355 -0
  44. package/assets/templates/spa/src/frontend/app/navigation.ts +8 -0
  45. package/assets/templates/spa/src/frontend/app/refresh.js +114 -0
  46. package/assets/templates/spa/src/frontend/app/router.ts +126 -0
  47. package/assets/templates/spa/src/frontend/app/styles/base.css +2 -0
  48. package/assets/templates/spa/src/frontend/app/styles/reset.css +48 -0
  49. package/assets/templates/spa/src/frontend/pages/home/index.css +21 -0
  50. package/assets/templates/spa/src/frontend/pages/home/index.html +10 -0
  51. package/assets/templates/spa/src/frontend/pages/home/index.ts +18 -0
  52. package/assets/templates/spa/src/frontend/pages/home/tests/home.test.ts +21 -0
  53. package/assets/templates/spa/src/frontend/tsconfig.json +20 -0
  54. package/assets/templates/spa/src/shared/router-types.ts +23 -0
  55. package/assets/templates/spa/src/shared/tsconfig.json +10 -0
  56. package/assets/templates/spa/src/shared/types/index.ts +4 -0
  57. package/assets/templates/ssg/src/frontend/app/app.css +12 -0
  58. package/assets/templates/ssg/src/frontend/app/app.html +43 -0
  59. package/assets/templates/ssg/src/frontend/app/app.ts +190 -0
  60. package/assets/templates/ssg/src/frontend/app/error.ts +127 -0
  61. package/assets/templates/ssg/src/frontend/app/hmr.js +370 -0
  62. package/assets/templates/ssg/src/frontend/app/refresh.js +163 -0
  63. package/assets/templates/ssg/src/frontend/app/scripts/components/drawer.ts +183 -0
  64. package/assets/templates/ssg/src/frontend/app/scripts/components/menu.ts +75 -0
  65. package/assets/templates/ssg/src/frontend/app/styles/base.css +77 -0
  66. package/assets/templates/ssg/src/frontend/app/styles/components/buttons.css +108 -0
  67. package/assets/templates/ssg/src/frontend/app/styles/components/drawer.css +12 -0
  68. package/assets/templates/ssg/src/frontend/app/styles/components/header.css +164 -0
  69. package/assets/templates/ssg/src/frontend/app/styles/components/markdown.css +25 -0
  70. package/assets/templates/ssg/src/frontend/app/styles/layout.css +41 -0
  71. package/assets/templates/ssg/src/frontend/app/styles/reset.css +56 -0
  72. package/assets/templates/ssg/src/frontend/app/styles/tokens.css +72 -0
  73. package/assets/templates/ssg/src/frontend/app/styles/utilities.css +14 -0
  74. package/assets/templates/ssg/src/frontend/content/_sidebar.json +14 -0
  75. package/assets/templates/ssg/src/frontend/content/content-pipeline.md +82 -0
  76. package/assets/templates/ssg/src/frontend/content/css-playbook.md +68 -0
  77. package/assets/templates/ssg/src/frontend/content/hosting.md +48 -0
  78. package/assets/templates/ssg/src/frontend/pages/about/index.css +33 -0
  79. package/assets/templates/ssg/src/frontend/pages/about/index.html +60 -0
  80. package/assets/templates/ssg/src/frontend/pages/docs/index.css +505 -0
  81. package/assets/templates/ssg/src/frontend/pages/docs/index.html +52 -0
  82. package/assets/templates/ssg/src/frontend/pages/docs/index.ts +495 -0
  83. package/assets/templates/ssg/src/frontend/pages/home/index.css +91 -0
  84. package/assets/templates/ssg/src/frontend/pages/home/index.html +38 -0
  85. package/assets/templates/ssg/src/frontend/pages/home/tests/home.test.ts +24 -0
  86. package/assets/templates/ssg/src/frontend/tsconfig.json +13 -0
  87. package/package.json +41 -0
  88. package/scripts/pack-standalone.mjs +127 -0
  89. package/scripts/sync-assets.mjs +87 -0
  90. package/src/add-backend.ts +164 -0
  91. package/src/add.ts +112 -0
  92. package/src/api-watch.ts +84 -0
  93. package/src/backend-inspect.ts +45 -0
  94. package/src/backend-runtime.ts +286 -0
  95. package/src/build-plan.ts +12 -0
  96. package/src/build.ts +10 -0
  97. package/src/cli.ts +569 -0
  98. package/src/compile-tests.ts +61 -0
  99. package/src/dev-server.ts +393 -0
  100. package/src/enable-assets.ts +196 -0
  101. package/src/enable.ts +477 -0
  102. package/src/execute.ts +85 -0
  103. package/src/format.ts +254 -0
  104. package/src/frontend-watch.ts +145 -0
  105. package/src/full-watch.ts +80 -0
  106. package/src/index.ts +20 -0
  107. package/src/init-assets.ts +96 -0
  108. package/src/init.ts +339 -0
  109. package/src/paths.ts +26 -0
  110. package/src/providers.ts +88 -0
  111. package/src/publish.ts +8 -0
  112. package/src/refresh.ts +56 -0
  113. package/src/repair.ts +414 -0
  114. package/src/runtime.ts +48 -0
  115. package/src/smoke.ts +161 -0
  116. package/src/stop-signal.ts +26 -0
  117. package/src/test.ts +215 -0
  118. package/src/types.ts +29 -0
  119. package/src/watch-daemon-client.ts +171 -0
  120. package/src/watch-events.ts +195 -0
  121. package/src/watch.ts +66 -0
  122. package/src/workspace-watcher.ts +251 -0
  123. 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
+ }