@b9g/shovel 0.2.4 → 0.2.6

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/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  All notable changes to Shovel will be documented in this file.
4
4
 
5
+ ## [0.2.6] - 2026-02-06
6
+
7
+ ### Features
8
+
9
+ - **UI framework selection in create-shovel** - Choose between Vanilla, HTMX, Alpine.js, and Crank.js when scaffolding static-site and full-stack templates ([#44](https://github.com/bikeshaving/shovel/pull/44))
10
+ - **Default `["app"]` logger category** - User application logs under `["app", ...]` now work out of the box without configuration. Framework logs under `["shovel", ...]`, third-party libraries remain silent unless opted in.
11
+ - **Default exports for cache and filesystem modules** - `@b9g/cache/memory`, `@b9g/cache-redis`, `@b9g/filesystem/node-fs`, `@b9g/filesystem/memory`, and `@b9g/filesystem/bun-s3` now have default exports, so `"export"` can be omitted from `shovel.json` config.
12
+
13
+ ### Bug Fixes
14
+
15
+ - **Cache API compliance** - Wildcard pattern matching (`"*"`) for cache and directory configs, `PostMessageCache` now accepts `RequestInfo | URL` per spec, `matchPattern()` restored for config lookups ([#43](https://github.com/bikeshaving/shovel/pull/43))
16
+ - **Direct cache in single-worker dev mode** - Dev workers now use `MemoryCache` directly instead of `PostMessageCache` when `workers: 1`, avoiding unnecessary serialization overhead
17
+ - **Node.js Request body duplex** - Added `duplex: "half"` to Node.js Request construction to fix body streaming
18
+ - **Website 404 errors** - Views now throw `NotFound` from `@b9g/http-errors` instead of raw errors, returning proper 404 responses
19
+ - **Fixed `@b9g/cache-redis` module path in docs** - Documentation referenced `@b9g/cache/redis` instead of the correct `@b9g/cache-redis`
20
+
21
+ ### Tests
22
+
23
+ - **PostMessageCache WPT tests** - 29 Web Platform Tests now run against PostMessageCache to verify serialization round-trip compliance
24
+ - **Pattern matching unit tests** - Wildcard, prefix, and exact-match priority tests for cache and directory factories
25
+ - **End-to-end cache tests** - Runtime tests for KV server, multi-cache independence, and wildcard priority
26
+
5
27
  ## [0.2.3] - 2026-02-02
6
28
 
7
29
  ### Features
package/README.md CHANGED
@@ -184,14 +184,12 @@ Shovel's configuration follows these principles:
184
184
  "caches": {
185
185
  "sessions": {
186
186
  "module": "@b9g/cache-redis",
187
- "export": "RedisCache",
188
187
  "url": "$REDIS_URL"
189
188
  }
190
189
  },
191
190
  "directories": {
192
191
  "uploads": {
193
192
  "module": "@b9g/filesystem-s3",
194
- "export": "S3Directory",
195
193
  "bucket": "$S3_BUCKET"
196
194
  }
197
195
  },
@@ -211,18 +209,16 @@ Shovel's configuration follows these principles:
211
209
 
212
210
  ### Caches
213
211
 
214
- Configure cache backends using `module` and `export`:
212
+ Configure cache backends using `module` (uses default export, or specify `export` for named exports):
215
213
 
216
214
  ```json
217
215
  {
218
216
  "caches": {
219
217
  "api-responses": {
220
- "module": "@b9g/cache/memory",
221
- "export": "MemoryCache"
218
+ "module": "@b9g/cache/memory"
222
219
  },
223
220
  "sessions": {
224
221
  "module": "@b9g/cache-redis",
225
- "export": "RedisCache",
226
222
  "url": "$REDIS_URL"
227
223
  }
228
224
  }
@@ -242,13 +238,11 @@ Configure directory backends. Platforms provide defaults for well-known director
242
238
  "directories": {
243
239
  "uploads": {
244
240
  "module": "@b9g/filesystem-s3",
245
- "export": "S3Directory",
246
241
  "bucket": "MY_BUCKET",
247
242
  "region": "us-east-1"
248
243
  },
249
244
  "data": {
250
245
  "module": "@b9g/filesystem/node-fs",
251
- "export": "NodeFSDirectory",
252
246
  "path": "./data"
253
247
  }
254
248
  }
package/bin/create.js CHANGED
@@ -68,12 +68,12 @@ async function main() {
68
68
  {
69
69
  value: "static-site",
70
70
  label: "Static Site",
71
- hint: "Serve files from public/ directory"
71
+ hint: "Server-rendered HTML pages"
72
72
  },
73
73
  {
74
74
  value: "full-stack",
75
75
  label: "Full Stack",
76
- hint: "HTML pages + API routes + static assets"
76
+ hint: "HTML pages + API routes"
77
77
  }
78
78
  ]
79
79
  });
@@ -81,6 +81,40 @@ async function main() {
81
81
  outro("Project creation cancelled");
82
82
  process.exit(0);
83
83
  }
84
+ let uiFramework = "vanilla";
85
+ if (template === "static-site" || template === "full-stack") {
86
+ const framework = await select({
87
+ message: "UI framework:",
88
+ initialValue: "crank",
89
+ options: [
90
+ {
91
+ value: "alpine",
92
+ label: "Alpine.js",
93
+ hint: "Lightweight reactivity with x-data directives"
94
+ },
95
+ {
96
+ value: "crank",
97
+ label: "Crank.js",
98
+ hint: "JSX components rendered on the server"
99
+ },
100
+ {
101
+ value: "htmx",
102
+ label: "HTMX",
103
+ hint: "HTML-driven interactions with hx- attributes"
104
+ },
105
+ {
106
+ value: "vanilla",
107
+ label: "Vanilla",
108
+ hint: "Plain HTML, no framework"
109
+ }
110
+ ]
111
+ });
112
+ if (typeof framework === "symbol") {
113
+ outro("Project creation cancelled");
114
+ process.exit(0);
115
+ }
116
+ uiFramework = framework;
117
+ }
84
118
  const typescript = await confirm({
85
119
  message: "Use TypeScript?",
86
120
  initialValue: true
@@ -119,7 +153,8 @@ async function main() {
119
153
  name: projectName,
120
154
  platform,
121
155
  template,
122
- typescript
156
+ typescript,
157
+ uiFramework
123
158
  };
124
159
  const s = spinner();
125
160
  s.start("Creating your Shovel project...");
@@ -132,7 +167,7 @@ async function main() {
132
167
  console.info("Next steps:");
133
168
  console.info(` cd ${projectName}`);
134
169
  console.info(` npm install`);
135
- console.info(` npm run dev`);
170
+ console.info(` npm run develop`);
136
171
  console.info("");
137
172
  console.info("Your app will be available at: http://localhost:7777");
138
173
  console.info("");
@@ -145,26 +180,26 @@ async function main() {
145
180
  async function createProject(config, projectPath) {
146
181
  await mkdir(projectPath, { recursive: true });
147
182
  await mkdir(join(projectPath, "src"), { recursive: true });
148
- if (config.template === "static-site" || config.template === "full-stack") {
149
- await mkdir(join(projectPath, "public"), { recursive: true });
183
+ const ext = config.uiFramework === "crank" ? config.typescript ? "tsx" : "jsx" : config.typescript ? "ts" : "js";
184
+ const startCmd = config.platform === "bun" ? "bun dist/server/supervisor.js" : "node dist/server/supervisor.js";
185
+ const dependencies = {
186
+ "@b9g/router": "^0.2.0",
187
+ "@b9g/shovel": "^0.2.0"
188
+ };
189
+ if (config.uiFramework === "crank") {
190
+ dependencies["@b9g/crank"] = "^0.7.2";
150
191
  }
151
- const ext = config.typescript ? "ts" : "js";
152
192
  const packageJson = {
153
193
  name: config.name,
154
194
  private: true,
155
195
  version: "0.0.1",
156
196
  type: "module",
157
197
  scripts: {
158
- dev: `shovel develop src/app.${ext} --platform ${config.platform}`,
198
+ develop: `shovel develop src/app.${ext} --platform ${config.platform}`,
159
199
  build: `shovel build src/app.${ext} --platform ${config.platform}`,
160
- start: "node dist/server/supervisor.js"
161
- },
162
- dependencies: {
163
- "@b9g/router": "^0.2.0",
164
- "@b9g/shovel": "^0.2.0",
165
- "@b9g/filesystem": "^0.1.8",
166
- "@b9g/cache": "^0.2.0"
200
+ start: startCmd
167
201
  },
202
+ dependencies,
168
203
  devDependencies: config.typescript ? {
169
204
  "@types/node": "^18.0.0",
170
205
  typescript: "^5.0.0"
@@ -177,18 +212,23 @@ async function createProject(config, projectPath) {
177
212
  const appFile = generateAppFile(config);
178
213
  await writeFile(join(projectPath, `src/app.${ext}`), appFile);
179
214
  if (config.typescript) {
215
+ const compilerOptions = {
216
+ target: "ES2022",
217
+ module: "ESNext",
218
+ moduleResolution: "bundler",
219
+ allowSyntheticDefaultImports: true,
220
+ esModuleInterop: true,
221
+ strict: true,
222
+ skipLibCheck: true,
223
+ forceConsistentCasingInFileNames: true,
224
+ lib: ["ES2022", "WebWorker"]
225
+ };
226
+ if (config.uiFramework === "crank") {
227
+ compilerOptions.jsx = "react-jsx";
228
+ compilerOptions.jsxImportSource = "@b9g/crank";
229
+ }
180
230
  const tsConfig = {
181
- compilerOptions: {
182
- target: "ES2022",
183
- module: "ESNext",
184
- moduleResolution: "bundler",
185
- allowSyntheticDefaultImports: true,
186
- esModuleInterop: true,
187
- strict: true,
188
- skipLibCheck: true,
189
- forceConsistentCasingInFileNames: true,
190
- lib: ["ES2022", "WebWorker"]
191
- },
231
+ compilerOptions,
192
232
  include: ["src/**/*"],
193
233
  exclude: ["node_modules", "dist"]
194
234
  };
@@ -196,9 +236,18 @@ async function createProject(config, projectPath) {
196
236
  join(projectPath, "tsconfig.json"),
197
237
  JSON.stringify(tsConfig, null, 2)
198
238
  );
199
- }
200
- if (config.template === "static-site" || config.template === "full-stack") {
201
- await createStaticFiles(config, projectPath);
239
+ const envDts = `/// <reference lib="WebWorker" />
240
+
241
+ // Shovel runs your code in a ServiceWorker-like environment.
242
+ // This augments the Worker types with ServiceWorker events
243
+ // so self.addEventListener("fetch", ...) etc. are properly typed.
244
+ interface WorkerGlobalScopeEventMap {
245
+ fetch: FetchEvent;
246
+ install: ExtendableEvent;
247
+ activate: ExtendableEvent;
248
+ }
249
+ `;
250
+ await writeFile(join(projectPath, "src/env.d.ts"), envDts);
202
251
  }
203
252
  const readme = generateReadme(config);
204
253
  await writeFile(join(projectPath, "README.md"), readme);
@@ -211,62 +260,6 @@ dist/
211
260
  `;
212
261
  await writeFile(join(projectPath, ".gitignore"), gitignore);
213
262
  }
214
- async function createStaticFiles(config, projectPath) {
215
- const indexHtml = `<!DOCTYPE html>
216
- <html lang="en">
217
- <head>
218
- <meta charset="UTF-8">
219
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
220
- <title>${config.name}</title>
221
- <link rel="stylesheet" href="/styles.css">
222
- </head>
223
- <body>
224
- <main>
225
- <h1>Welcome to ${config.name}</h1>
226
- <p>Edit <code>public/index.html</code> to get started.</p>
227
- ${config.template === "full-stack" ? '<p>API endpoint: <a href="/api/hello">/api/hello</a></p>' : ""}
228
- </main>
229
- </body>
230
- </html>
231
- `;
232
- await writeFile(join(projectPath, "public/index.html"), indexHtml);
233
- const stylesCss = `* {
234
- box-sizing: border-box;
235
- margin: 0;
236
- padding: 0;
237
- }
238
-
239
- body {
240
- font-family: system-ui, -apple-system, sans-serif;
241
- line-height: 1.6;
242
- color: #333;
243
- background: #fafafa;
244
- }
245
-
246
- main {
247
- max-width: 640px;
248
- margin: 4rem auto;
249
- padding: 2rem;
250
- }
251
-
252
- h1 {
253
- color: #2563eb;
254
- margin-bottom: 1rem;
255
- }
256
-
257
- code {
258
- background: #e5e7eb;
259
- padding: 0.2rem 0.4rem;
260
- border-radius: 4px;
261
- font-size: 0.9em;
262
- }
263
-
264
- a {
265
- color: #2563eb;
266
- }
267
- `;
268
- await writeFile(join(projectPath, "public/styles.css"), stylesCss);
269
- }
270
263
  function generateAppFile(config) {
271
264
  switch (config.template) {
272
265
  case "hello-world":
@@ -352,59 +345,282 @@ self.addEventListener("fetch", (event) => {
352
345
  });
353
346
  `;
354
347
  }
348
+ var css = ` * { box-sizing: border-box; margin: 0; padding: 0; }
349
+ body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #333; background: #fafafa; }
350
+ main { max-width: 640px; margin: 4rem auto; padding: 2rem; }
351
+ h1 { color: #2563eb; margin-bottom: 1rem; }
352
+ code { background: #e5e7eb; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.9em; }
353
+ a { color: #2563eb; }
354
+ ul { margin-top: 1rem; padding-left: 1.5rem; }
355
+ li { margin-bottom: 0.5rem; }
356
+ button { background: #2563eb; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-size: 1rem; }
357
+ button:hover { background: #1d4ed8; }
358
+ #result { margin-top: 1rem; padding: 1rem; background: #f3f4f6; border-radius: 4px; }`;
355
359
  function generateStaticSite(config) {
360
+ switch (config.uiFramework) {
361
+ case "vanilla":
362
+ return generateStaticSiteVanilla(config);
363
+ case "htmx":
364
+ return generateStaticSiteHtmx(config);
365
+ case "alpine":
366
+ return generateStaticSiteAlpine(config);
367
+ case "crank":
368
+ return generateStaticSiteCrank(config);
369
+ }
370
+ }
371
+ function generateStaticSiteVanilla(config) {
372
+ const ext = config.typescript ? "ts" : "js";
373
+ const t = config.typescript;
356
374
  return `// ${config.name} - Static Site
357
- // Serves files from the public/ directory
375
+ // Renders HTML pages server-side
358
376
 
359
377
  self.addEventListener("fetch", (event) => {
360
378
  event.respondWith(handleRequest(event.request));
361
379
  });
362
380
 
363
- async function handleRequest(request${config.typescript ? ": Request" : ""})${config.typescript ? ": Promise<Response>" : ""} {
381
+ async function handleRequest(request${t ? ": Request" : ""})${t ? ": Promise<Response>" : ""} {
364
382
  const url = new URL(request.url);
365
- let path = url.pathname;
366
383
 
367
- // Default to index.html for root
368
- if (path === "/") {
369
- path = "/index.html";
384
+ if (url.pathname === "/") {
385
+ return new Response(renderPage("Home", \`
386
+ <h1>Welcome to ${config.name}</h1>
387
+ <p>Edit <code>src/app.${ext}</code> to get started.</p>
388
+ <p><a href="/about">About</a></p>
389
+ \`), {
390
+ headers: { "Content-Type": "text/html" },
391
+ });
392
+ }
393
+
394
+ if (url.pathname === "/about") {
395
+ return new Response(renderPage("About", \`
396
+ <h1>About</h1>
397
+ <p>This is a static site built with <strong>Shovel</strong>.</p>
398
+ <p><a href="/">Home</a></p>
399
+ \`), {
400
+ headers: { "Content-Type": "text/html" },
401
+ });
370
402
  }
371
403
 
372
- // Try to serve from public directory
373
- try {
374
- const publicDir = await directories.open("public");
375
- const file = await publicDir.getFileHandle(path.slice(1)); // Remove leading /
376
- const blob = await file.getFile();
404
+ return new Response("Not Found", { status: 404 });
405
+ }
377
406
 
378
- return new Response(blob, {
379
- headers: {
380
- "Content-Type": getContentType(path),
381
- },
407
+ function renderPage(title${t ? ": string" : ""}, content${t ? ": string" : ""})${t ? ": string" : ""} {
408
+ return \`<!DOCTYPE html>
409
+ <html lang="en">
410
+ <head>
411
+ <meta charset="UTF-8">
412
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
413
+ <title>\${title} - ${config.name}</title>
414
+ <style>
415
+ ${css}
416
+ </style>
417
+ </head>
418
+ <body>
419
+ <main>\${content}</main>
420
+ </body>
421
+ </html>\`;
422
+ }
423
+ `;
424
+ }
425
+ function generateStaticSiteHtmx(config) {
426
+ const ext = config.typescript ? "ts" : "js";
427
+ const t = config.typescript;
428
+ return `// ${config.name} - Static Site with HTMX
429
+ // Server-rendered HTML with HTMX interactions
430
+
431
+ self.addEventListener("fetch", (event) => {
432
+ event.respondWith(handleRequest(event.request));
433
+ });
434
+
435
+ async function handleRequest(request${t ? ": Request" : ""})${t ? ": Promise<Response>" : ""} {
436
+ const url = new URL(request.url);
437
+
438
+ if (url.pathname === "/") {
439
+ return new Response(renderPage("Home", \`
440
+ <h1>Welcome to ${config.name}</h1>
441
+ <p>Edit <code>src/app.${ext}</code> to get started.</p>
442
+ <button hx-get="/greeting" hx-target="#result" hx-swap="innerHTML">Get Greeting</button>
443
+ <div id="result"></div>
444
+ <p style="margin-top: 1rem;"><a href="/about">About</a></p>
445
+ \`), {
446
+ headers: { "Content-Type": "text/html" },
447
+ });
448
+ }
449
+
450
+ if (url.pathname === "/greeting") {
451
+ const hour = new Date().getHours();
452
+ const greeting = hour < 12 ? "Good morning" : hour < 18 ? "Good afternoon" : "Good evening";
453
+ return new Response(\`<p>\${greeting}! The time is \${new Date().toLocaleTimeString()}.</p>\`, {
454
+ headers: { "Content-Type": "text/html" },
382
455
  });
383
- } catch {
384
- // File not found - return 404
385
- return new Response("Not Found", { status: 404 });
386
456
  }
457
+
458
+ if (url.pathname === "/about") {
459
+ return new Response(renderPage("About", \`
460
+ <h1>About</h1>
461
+ <p>This is a static site built with <strong>Shovel</strong> and <strong>HTMX</strong>.</p>
462
+ <p><a href="/">Home</a></p>
463
+ \`), {
464
+ headers: { "Content-Type": "text/html" },
465
+ });
466
+ }
467
+
468
+ return new Response("Not Found", { status: 404 });
387
469
  }
388
470
 
389
- function getContentType(path${config.typescript ? ": string" : ""})${config.typescript ? ": string" : ""} {
390
- const ext = path.split(".").pop()?.toLowerCase();
391
- const types${config.typescript ? ": Record<string, string>" : ""} = {
392
- html: "text/html",
393
- css: "text/css",
394
- js: "text/javascript",
395
- json: "application/json",
396
- png: "image/png",
397
- jpg: "image/jpeg",
398
- jpeg: "image/jpeg",
399
- gif: "image/gif",
400
- svg: "image/svg+xml",
401
- ico: "image/x-icon",
402
- };
403
- return types[ext || ""] || "application/octet-stream";
471
+ function renderPage(title${t ? ": string" : ""}, content${t ? ": string" : ""})${t ? ": string" : ""} {
472
+ return \`<!DOCTYPE html>
473
+ <html lang="en">
474
+ <head>
475
+ <meta charset="UTF-8">
476
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
477
+ <title>\${title} - ${config.name}</title>
478
+ <script src="https://unpkg.com/htmx.org@2.0.4"></script>
479
+ <style>
480
+ ${css}
481
+ </style>
482
+ </head>
483
+ <body>
484
+ <main>\${content}</main>
485
+ </body>
486
+ </html>\`;
487
+ }
488
+ `;
489
+ }
490
+ function generateStaticSiteAlpine(config) {
491
+ const ext = config.typescript ? "ts" : "js";
492
+ const t = config.typescript;
493
+ return `// ${config.name} - Static Site with Alpine.js
494
+ // Server-rendered HTML with Alpine.js interactions
495
+
496
+ self.addEventListener("fetch", (event) => {
497
+ event.respondWith(handleRequest(event.request));
498
+ });
499
+
500
+ async function handleRequest(request${t ? ": Request" : ""})${t ? ": Promise<Response>" : ""} {
501
+ const url = new URL(request.url);
502
+
503
+ if (url.pathname === "/") {
504
+ return new Response(renderPage("Home", \`
505
+ <h1>Welcome to ${config.name}</h1>
506
+ <p>Edit <code>src/app.${ext}</code> to get started.</p>
507
+ <div x-data="{ count: 0 }">
508
+ <button @click="count++">Clicked: <span x-text="count"></span></button>
509
+ </div>
510
+ <p style="margin-top: 1rem;"><a href="/about">About</a></p>
511
+ \`), {
512
+ headers: { "Content-Type": "text/html" },
513
+ });
514
+ }
515
+
516
+ if (url.pathname === "/about") {
517
+ return new Response(renderPage("About", \`
518
+ <h1>About</h1>
519
+ <p>This is a static site built with <strong>Shovel</strong> and <strong>Alpine.js</strong>.</p>
520
+ <p><a href="/">Home</a></p>
521
+ \`), {
522
+ headers: { "Content-Type": "text/html" },
523
+ });
524
+ }
525
+
526
+ return new Response("Not Found", { status: 404 });
527
+ }
528
+
529
+ function renderPage(title${t ? ": string" : ""}, content${t ? ": string" : ""})${t ? ": string" : ""} {
530
+ return \`<!DOCTYPE html>
531
+ <html lang="en">
532
+ <head>
533
+ <meta charset="UTF-8">
534
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
535
+ <title>\${title} - ${config.name}</title>
536
+ <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
537
+ <style>
538
+ ${css}
539
+ </style>
540
+ </head>
541
+ <body>
542
+ <main>\${content}</main>
543
+ </body>
544
+ </html>\`;
545
+ }
546
+ `;
547
+ }
548
+ function generateStaticSiteCrank(config) {
549
+ const t = config.typescript;
550
+ return `import {renderer} from "@b9g/crank/html";
551
+
552
+ // ${config.name} - Static Site with Crank.js
553
+ // Server-rendered HTML with JSX components
554
+
555
+ const css = \`
556
+ ${css}
557
+ \`;
558
+
559
+ function Page({title, children}${t ? ": {title: string, children: unknown}" : ""}) {
560
+ return (
561
+ <html lang="en">
562
+ <head>
563
+ <meta charset="UTF-8" />
564
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
565
+ <title>{title} - ${config.name}</title>
566
+ <style>{css}</style>
567
+ </head>
568
+ <body>
569
+ <main>{children}</main>
570
+ </body>
571
+ </html>
572
+ );
573
+ }
574
+
575
+ self.addEventListener("fetch", (event) => {
576
+ event.respondWith(handleRequest(event.request));
577
+ });
578
+
579
+ async function handleRequest(request${t ? ": Request" : ""})${t ? ": Promise<Response>" : ""} {
580
+ const url = new URL(request.url);
581
+ let html${t ? ": string" : ""};
582
+
583
+ if (url.pathname === "/") {
584
+ html = await renderer.render(
585
+ <Page title="Home">
586
+ <h1>Welcome to ${config.name}</h1>
587
+ <p>Edit <code>src/app.${t ? "tsx" : "jsx"}</code> to get started.</p>
588
+ <p><a href="/about">About</a></p>
589
+ </Page>
590
+ );
591
+ } else if (url.pathname === "/about") {
592
+ html = await renderer.render(
593
+ <Page title="About">
594
+ <h1>About</h1>
595
+ <p>This is a static site built with <strong>Shovel</strong> and <strong>Crank.js</strong>.</p>
596
+ <p><a href="/">Home</a></p>
597
+ </Page>
598
+ );
599
+ } else {
600
+ return new Response("Not Found", { status: 404 });
601
+ }
602
+
603
+ return new Response("<!DOCTYPE html>" + html, {
604
+ headers: { "Content-Type": "text/html" },
605
+ });
404
606
  }
405
607
  `;
406
608
  }
407
609
  function generateFullStack(config) {
610
+ switch (config.uiFramework) {
611
+ case "vanilla":
612
+ return generateFullStackVanilla(config);
613
+ case "htmx":
614
+ return generateFullStackHtmx(config);
615
+ case "alpine":
616
+ return generateFullStackAlpine(config);
617
+ case "crank":
618
+ return generateFullStackCrank(config);
619
+ }
620
+ }
621
+ function generateFullStackVanilla(config) {
622
+ const ext = config.typescript ? "ts" : "js";
623
+ const t = config.typescript;
408
624
  return `import { Router } from "@b9g/router";
409
625
 
410
626
  const router = new Router();
@@ -422,58 +638,272 @@ router.route("/api/echo").post(async (req) => {
422
638
  return Response.json({ echo: body });
423
639
  });
424
640
 
425
- // Serve static files from public/ for all other routes
426
- router.route("/*").get(async (req, ctx) => {
427
- const url = new URL(req.url);
428
- let path = url.pathname;
641
+ // HTML pages
642
+ router.route("/").get(() => {
643
+ return new Response(renderPage("Home", \`
644
+ <h1>Welcome to ${config.name}</h1>
645
+ <p>Edit <code>src/app.${ext}</code> to get started.</p>
646
+ <ul>
647
+ <li><a href="/about">About</a></li>
648
+ <li><a href="/api/hello">API: /api/hello</a></li>
649
+ </ul>
650
+ \`), {
651
+ headers: { "Content-Type": "text/html" },
652
+ });
653
+ });
429
654
 
430
- // Default to index.html for root
431
- if (path === "/") {
432
- path = "/index.html";
433
- }
655
+ router.route("/about").get(() => {
656
+ return new Response(renderPage("About", \`
657
+ <h1>About</h1>
658
+ <p>This is a full-stack app built with <strong>Shovel</strong>.</p>
659
+ <p><a href="/">Home</a></p>
660
+ \`), {
661
+ headers: { "Content-Type": "text/html" },
662
+ });
663
+ });
434
664
 
435
- try {
436
- const publicDir = await directories.open("public");
437
- const file = await publicDir.getFileHandle(path.slice(1));
438
- const blob = await file.getFile();
665
+ function renderPage(title${t ? ": string" : ""}, content${t ? ": string" : ""})${t ? ": string" : ""} {
666
+ return \`<!DOCTYPE html>
667
+ <html lang="en">
668
+ <head>
669
+ <meta charset="UTF-8">
670
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
671
+ <title>\${title} - ${config.name}</title>
672
+ <style>
673
+ ${css}
674
+ </style>
675
+ </head>
676
+ <body>
677
+ <main>\${content}</main>
678
+ </body>
679
+ </html>\`;
680
+ }
439
681
 
440
- return new Response(blob, {
441
- headers: {
442
- "Content-Type": getContentType(path),
443
- },
682
+ self.addEventListener("fetch", (event) => {
683
+ event.respondWith(router.handle(event.request));
684
+ });
685
+ `;
686
+ }
687
+ function generateFullStackHtmx(config) {
688
+ const ext = config.typescript ? "ts" : "js";
689
+ const t = config.typescript;
690
+ return `import { Router } from "@b9g/router";
691
+
692
+ const router = new Router();
693
+
694
+ // API routes \u2014 return HTML fragments when HTMX requests, JSON otherwise
695
+ router.route("/api/hello").get((req) => {
696
+ const data = {
697
+ message: "Hello from the API!",
698
+ timestamp: new Date().toISOString(),
699
+ };
700
+
701
+ if (req.headers.get("HX-Request")) {
702
+ return new Response(\`<p>\${data.message}</p><p><small>\${data.timestamp}</small></p>\`, {
703
+ headers: { "Content-Type": "text/html" },
444
704
  });
445
- } catch {
446
- // Try index.html for SPA routing
447
- try {
448
- const publicDir = await directories.open("public");
449
- const file = await publicDir.getFileHandle("index.html");
450
- const blob = await file.getFile();
451
- return new Response(blob, {
452
- headers: { "Content-Type": "text/html" },
453
- });
454
- } catch {
455
- return new Response("Not Found", { status: 404 });
456
- }
457
705
  }
706
+ return Response.json(data);
458
707
  });
459
708
 
460
- function getContentType(path${config.typescript ? ": string" : ""})${config.typescript ? ": string" : ""} {
461
- const ext = path.split(".").pop()?.toLowerCase();
462
- const types${config.typescript ? ": Record<string, string>" : ""} = {
463
- html: "text/html",
464
- css: "text/css",
465
- js: "text/javascript",
466
- json: "application/json",
467
- png: "image/png",
468
- jpg: "image/jpeg",
469
- jpeg: "image/jpeg",
470
- gif: "image/gif",
471
- svg: "image/svg+xml",
472
- ico: "image/x-icon",
473
- };
474
- return types[ext || ""] || "application/octet-stream";
709
+ router.route("/api/echo").post(async (req) => {
710
+ const body = await req.json();
711
+ return Response.json({ echo: body });
712
+ });
713
+
714
+ // HTML pages
715
+ router.route("/").get(() => {
716
+ return new Response(renderPage("Home", \`
717
+ <h1>Welcome to ${config.name}</h1>
718
+ <p>Edit <code>src/app.${ext}</code> to get started.</p>
719
+ <button hx-get="/api/hello" hx-target="#result" hx-swap="innerHTML">Call API</button>
720
+ <div id="result"></div>
721
+ <ul>
722
+ <li><a href="/about">About</a></li>
723
+ <li><a href="/api/hello">API: /api/hello</a></li>
724
+ </ul>
725
+ \`), {
726
+ headers: { "Content-Type": "text/html" },
727
+ });
728
+ });
729
+
730
+ router.route("/about").get(() => {
731
+ return new Response(renderPage("About", \`
732
+ <h1>About</h1>
733
+ <p>This is a full-stack app built with <strong>Shovel</strong> and <strong>HTMX</strong>.</p>
734
+ <p><a href="/">Home</a></p>
735
+ \`), {
736
+ headers: { "Content-Type": "text/html" },
737
+ });
738
+ });
739
+
740
+ function renderPage(title${t ? ": string" : ""}, content${t ? ": string" : ""})${t ? ": string" : ""} {
741
+ return \`<!DOCTYPE html>
742
+ <html lang="en">
743
+ <head>
744
+ <meta charset="UTF-8">
745
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
746
+ <title>\${title} - ${config.name}</title>
747
+ <script src="https://unpkg.com/htmx.org@2.0.4"></script>
748
+ <style>
749
+ ${css}
750
+ </style>
751
+ </head>
752
+ <body>
753
+ <main>\${content}</main>
754
+ </body>
755
+ </html>\`;
475
756
  }
476
757
 
758
+ self.addEventListener("fetch", (event) => {
759
+ event.respondWith(router.handle(event.request));
760
+ });
761
+ `;
762
+ }
763
+ function generateFullStackAlpine(config) {
764
+ const ext = config.typescript ? "ts" : "js";
765
+ const t = config.typescript;
766
+ return `import { Router } from "@b9g/router";
767
+
768
+ const router = new Router();
769
+
770
+ // API routes
771
+ router.route("/api/hello").get(() => {
772
+ return Response.json({
773
+ message: "Hello from the API!",
774
+ timestamp: new Date().toISOString(),
775
+ });
776
+ });
777
+
778
+ router.route("/api/echo").post(async (req) => {
779
+ const body = await req.json();
780
+ return Response.json({ echo: body });
781
+ });
782
+
783
+ // HTML pages
784
+ router.route("/").get(() => {
785
+ return new Response(renderPage("Home", \`
786
+ <h1>Welcome to ${config.name}</h1>
787
+ <p>Edit <code>src/app.${ext}</code> to get started.</p>
788
+ <div x-data="{ result: null }">
789
+ <button @click="fetch('/api/hello').then(r => r.json()).then(d => result = d)">Call API</button>
790
+ <div id="result" x-show="result">
791
+ <p x-text="result?.message"></p>
792
+ <p><small x-text="result?.timestamp"></small></p>
793
+ </div>
794
+ </div>
795
+ <ul>
796
+ <li><a href="/about">About</a></li>
797
+ <li><a href="/api/hello">API: /api/hello</a></li>
798
+ </ul>
799
+ \`), {
800
+ headers: { "Content-Type": "text/html" },
801
+ });
802
+ });
803
+
804
+ router.route("/about").get(() => {
805
+ return new Response(renderPage("About", \`
806
+ <h1>About</h1>
807
+ <p>This is a full-stack app built with <strong>Shovel</strong> and <strong>Alpine.js</strong>.</p>
808
+ <p><a href="/">Home</a></p>
809
+ \`), {
810
+ headers: { "Content-Type": "text/html" },
811
+ });
812
+ });
813
+
814
+ function renderPage(title${t ? ": string" : ""}, content${t ? ": string" : ""})${t ? ": string" : ""} {
815
+ return \`<!DOCTYPE html>
816
+ <html lang="en">
817
+ <head>
818
+ <meta charset="UTF-8">
819
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
820
+ <title>\${title} - ${config.name}</title>
821
+ <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
822
+ <style>
823
+ ${css}
824
+ </style>
825
+ </head>
826
+ <body>
827
+ <main>\${content}</main>
828
+ </body>
829
+ </html>\`;
830
+ }
831
+
832
+ self.addEventListener("fetch", (event) => {
833
+ event.respondWith(router.handle(event.request));
834
+ });
835
+ `;
836
+ }
837
+ function generateFullStackCrank(config) {
838
+ const t = config.typescript;
839
+ return `import { Router } from "@b9g/router";
840
+ import {renderer} from "@b9g/crank/html";
841
+
842
+ const router = new Router();
843
+
844
+ const css = \`
845
+ ${css}
846
+ \`;
847
+
848
+ function Page({title, children}${t ? ": {title: string, children: unknown}" : ""}) {
849
+ return (
850
+ <html lang="en">
851
+ <head>
852
+ <meta charset="UTF-8" />
853
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
854
+ <title>{title} - ${config.name}</title>
855
+ <style>{css}</style>
856
+ </head>
857
+ <body>
858
+ <main>{children}</main>
859
+ </body>
860
+ </html>
861
+ );
862
+ }
863
+
864
+ // API routes
865
+ router.route("/api/hello").get(() => {
866
+ return Response.json({
867
+ message: "Hello from the API!",
868
+ timestamp: new Date().toISOString(),
869
+ });
870
+ });
871
+
872
+ router.route("/api/echo").post(async (req) => {
873
+ const body = await req.json();
874
+ return Response.json({ echo: body });
875
+ });
876
+
877
+ // HTML pages
878
+ router.route("/").get(async () => {
879
+ const html = await renderer.render(
880
+ <Page title="Home">
881
+ <h1>Welcome to ${config.name}</h1>
882
+ <p>Edit <code>src/app.${t ? "tsx" : "jsx"}</code> to get started.</p>
883
+ <ul>
884
+ <li><a href="/about">About</a></li>
885
+ <li><a href="/api/hello">API: /api/hello</a></li>
886
+ </ul>
887
+ </Page>
888
+ );
889
+ return new Response("<!DOCTYPE html>" + html, {
890
+ headers: { "Content-Type": "text/html" },
891
+ });
892
+ });
893
+
894
+ router.route("/about").get(async () => {
895
+ const html = await renderer.render(
896
+ <Page title="About">
897
+ <h1>About</h1>
898
+ <p>This is a full-stack app built with <strong>Shovel</strong> and <strong>Crank.js</strong>.</p>
899
+ <p><a href="/">Home</a></p>
900
+ </Page>
901
+ );
902
+ return new Response("<!DOCTYPE html>" + html, {
903
+ headers: { "Content-Type": "text/html" },
904
+ });
905
+ });
906
+
477
907
  self.addEventListener("fetch", (event) => {
478
908
  event.respondWith(router.handle(event.request));
479
909
  });
@@ -483,25 +913,32 @@ function generateReadme(config) {
483
913
  const templateDescriptions = {
484
914
  "hello-world": "A minimal Shovel application",
485
915
  api: "A REST API with JSON endpoints",
486
- "static-site": "A static file server",
487
- "full-stack": "A full-stack app with API routes and static files"
916
+ "static-site": "A static site with server-rendered pages",
917
+ "full-stack": "A full-stack app with HTML pages and API routes"
918
+ };
919
+ const frameworkDescriptions = {
920
+ vanilla: "",
921
+ htmx: " using [HTMX](https://htmx.org)",
922
+ alpine: " using [Alpine.js](https://alpinejs.dev)",
923
+ crank: " using [Crank.js](https://crank.js.org)"
488
924
  };
925
+ const ext = config.uiFramework === "crank" ? config.typescript ? "tsx" : "jsx" : config.typescript ? "ts" : "js";
489
926
  return `# ${config.name}
490
927
 
491
- ${templateDescriptions[config.template]}, built with [Shovel](https://github.com/bikeshaving/shovel).
928
+ ${templateDescriptions[config.template]}${frameworkDescriptions[config.uiFramework]}, built with [Shovel](https://github.com/bikeshaving/shovel).
492
929
 
493
930
  ## Getting Started
494
931
 
495
932
  \`\`\`bash
496
933
  npm install
497
- npm run dev
934
+ npm run develop
498
935
  \`\`\`
499
936
 
500
937
  Open http://localhost:7777
501
938
 
502
939
  ## Scripts
503
940
 
504
- - \`npm run dev\` - Start development server
941
+ - \`npm run develop\` - Start development server
505
942
  - \`npm run build\` - Build for production
506
943
  - \`npm start\` - Run production build
507
944
 
@@ -510,8 +947,8 @@ Open http://localhost:7777
510
947
  \`\`\`
511
948
  ${config.name}/
512
949
  \u251C\u2500\u2500 src/
513
- \u2502 \u2514\u2500\u2500 app.${config.typescript ? "ts" : "js"} # Application entry point
514
- ${config.template === "static-site" || config.template === "full-stack" ? "\u251C\u2500\u2500 public/ # Static files\n\u2502 \u251C\u2500\u2500 index.html\n\u2502 \u2514\u2500\u2500 styles.css\n" : ""}\u251C\u2500\u2500 package.json
950
+ \u2502 \u2514\u2500\u2500 app.${ext} # Application entry point
951
+ \u251C\u2500\u2500 package.json
515
952
  ${config.typescript ? "\u251C\u2500\u2500 tsconfig.json\n" : ""}\u2514\u2500\u2500 README.md
516
953
  \`\`\`
517
954
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@b9g/shovel",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "ServiceWorker-first universal deployment platform. Write ServiceWorker apps once, deploy anywhere (Node/Bun/Cloudflare). Registry-based multi-app orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -15,14 +15,14 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@b9g/async-context": "^0.2.1",
18
- "@b9g/cache": "^0.2.1",
19
- "@b9g/filesystem": "^0.1.9",
18
+ "@b9g/cache": "^0.2.2",
19
+ "@b9g/filesystem": "^0.1.10",
20
20
  "@b9g/http-errors": "^0.2.1",
21
21
  "@b9g/node-webworker": "^0.2.1",
22
- "@b9g/platform": "^0.1.16",
23
- "@b9g/platform-bun": "^0.1.14",
24
- "@b9g/platform-cloudflare": "^0.1.14",
25
- "@b9g/platform-node": "^0.1.16",
22
+ "@b9g/platform": "^0.1.17",
23
+ "@b9g/platform-bun": "^0.1.15",
24
+ "@b9g/platform-cloudflare": "^0.1.15",
25
+ "@b9g/platform-node": "^0.1.17",
26
26
  "@clack/prompts": "^0.7.0",
27
27
  "@esbuild-plugins/node-globals-polyfill": "^0.2.3",
28
28
  "@esbuild-plugins/node-modules-polyfill": "^0.2.2",