create-what 0.8.4 → 0.11.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 (3) hide show
  1. package/README.md +52 -3
  2. package/index.js +641 -61
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -23,30 +23,68 @@ bun create what@latest my-app
23
23
  npm create what@latest my-app -- --yes
24
24
  ```
25
25
 
26
+ ### Full-stack template
27
+
28
+ ```bash
29
+ npm create what@latest my-app -- --fullstack
30
+ cd my-app
31
+ npm install
32
+ npm run dev # real SSR + ISR server -> http://localhost:3000
33
+ ```
34
+
35
+ File-routed SSR with server loaders, server actions, client hydration, and
36
+ origin-first ISR (stale-while-revalidate + on-demand revalidation + poll
37
+ regeneration). Buildless: the server serves the client entry and the framework
38
+ as native ES modules — no bundler, works on any Node host, no CDN required.
39
+
40
+ In production, set `WHAT_REVALIDATE_SECRET` (the server refuses to start
41
+ without it when `NODE_ENV=production`).
42
+
26
43
  ## Options
27
44
 
28
45
  The scaffolder prompts you for:
29
46
 
30
47
  1. **Project name** -- directory to create
31
- 2. **React compat** -- include `what-react` for using React libraries (zustand, TanStack Query, etc.)
32
- 3. **CSS approach** -- vanilla CSS, Tailwind CSS v4, or StyleX
48
+ 2. **Template** -- SPA (default) or full-stack (`--fullstack` / `--template=fullstack`)
49
+ 3. **React compat** (SPA only) -- include `what-react` for using React libraries (zustand, TanStack Query, etc.)
50
+ 4. **CSS approach** (SPA only) -- vanilla CSS, Tailwind CSS v4, or StyleX
33
51
 
34
52
  ## What You Get
35
53
 
54
+ ### SPA (default)
55
+
36
56
  ```
37
57
  my-app/
38
58
  src/
39
- main.jsx # App entry point with counter example
59
+ main.jsx # App entry point with counter example
40
60
  styles.css # Styles (vanilla, Tailwind, or StyleX)
41
61
  public/
42
62
  favicon.svg # What Framework logo
43
63
  index.html # HTML entry
44
64
  vite.config.js # Pre-configured Vite (What compiler or React compat)
65
+ eslint.config.js # eslint-plugin-what (compiler preset)
45
66
  tsconfig.json # TypeScript config
46
67
  package.json
47
68
  .gitignore
48
69
  ```
49
70
 
71
+ ### Full-stack (`--fullstack`)
72
+
73
+ ```
74
+ my-app/
75
+ src/
76
+ pages/ # File-routed pages (loader + page config + component)
77
+ actions/ # Server actions (mutations + cache revalidation)
78
+ routes.js # Route table
79
+ entry-client.js # Client hydration entry
80
+ db.js # In-memory demo data (swap for SQLite/Postgres)
81
+ styles.css
82
+ server.js # Node adapter + ISR engine + revalidate webhook
83
+ what.config.js # Deploy adapter + ISR defaults
84
+ eslint.config.js # eslint-plugin-what (recommended preset)
85
+ package.json
86
+ ```
87
+
50
88
  ### With React compat enabled
51
89
 
52
90
  The scaffold includes a working zustand demo showing a React state library running on What's signal engine.
@@ -61,11 +99,22 @@ StyleX is configured via `vite-plugin-stylex`. The counter example uses `stylex.
61
99
 
62
100
  ## Scripts
63
101
 
102
+ ### SPA
103
+
64
104
  | Script | Command |
65
105
  |---|---|
66
106
  | `npm run dev` | Start Vite dev server |
67
107
  | `npm run build` | Production build |
68
108
  | `npm run preview` | Preview production build |
109
+ | `npm run lint` | ESLint (eslint-plugin-what) |
110
+
111
+ ### Full-stack
112
+
113
+ | Script | Command |
114
+ |---|---|
115
+ | `npm run dev` | SSR + ISR server with auto-restart on change |
116
+ | `npm start` | Same server, no watcher (production entry point) |
117
+ | `npm run lint` | ESLint (eslint-plugin-what) |
69
118
 
70
119
  ## Links
71
120
 
package/index.js CHANGED
@@ -18,6 +18,8 @@ const positional = args.filter(a => !a.startsWith('-'));
18
18
  const flags = new Set(args.filter(a => a.startsWith('-')));
19
19
  const skipPrompts = flags.has('--yes') || flags.has('-y');
20
20
  const showHelp = flags.has('--help') || flags.has('-h');
21
+ let templateFlag = flags.has('--fullstack') ? 'fullstack' : null;
22
+ for (const f of flags) { const m = f.match(/^--template=(.+)$/); if (m) templateFlag = m[1]; }
21
23
  const packageVersion = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
22
24
  const whatVersionRange = `^${packageVersion}`;
23
25
 
@@ -29,8 +31,11 @@ if (showHelp) {
29
31
  create-what [project-name] [options]
30
32
 
31
33
  Options:
32
- -y, --yes Skip prompts and use defaults
33
- -h, --help Show this help message
34
+ --fullstack Scaffold a full-stack SSR app (file routes, loaders,
35
+ actions, origin-first ISR) instead of an SPA
36
+ --template=<name> 'spa' (default) or 'fullstack'
37
+ -y, --yes Skip prompts and use defaults
38
+ -h, --help Show this help message
34
39
  `);
35
40
  process.exit(0);
36
41
  }
@@ -110,7 +115,7 @@ async function gatherOptions() {
110
115
 
111
116
  if (skipPrompts) {
112
117
  projectName = projectName || 'my-what-app';
113
- return { projectName, reactCompat, cssApproach };
118
+ return { projectName, reactCompat, cssApproach, template: templateFlag || 'spa' };
114
119
  }
115
120
 
116
121
  const prompter = createPrompter();
@@ -121,33 +126,61 @@ async function gatherOptions() {
121
126
  projectName = (await prompter.ask(' Project name: ')).trim() || 'my-what-app';
122
127
  }
123
128
 
124
- reactCompat = await prompter.confirm('Add React library support? (what-react)');
125
-
126
- cssApproach = await prompter.select('CSS approach:', [
127
- { label: 'None (vanilla CSS)', value: 'none' },
128
- { label: 'Tailwind CSS v4', value: 'tailwind' },
129
- { label: 'StyleX', value: 'stylex' },
129
+ const template = templateFlag || await prompter.select('Template:', [
130
+ { label: 'SPA (client-side single-page app)', value: 'spa' },
131
+ { label: 'Full-stack (SSR + file routes + loaders + actions + ISR)', value: 'fullstack' },
130
132
  ]);
131
133
 
134
+ // The full-stack template is buildless (native ESM served by server.js), so
135
+ // the Vite-based React-compat / CSS tooling options don't apply to it.
136
+ if (template !== 'fullstack') {
137
+ reactCompat = await prompter.confirm('Add React library support? (what-react)');
138
+
139
+ cssApproach = await prompter.select('CSS approach:', [
140
+ { label: 'None (vanilla CSS)', value: 'none' },
141
+ { label: 'Tailwind CSS v4', value: 'tailwind' },
142
+ { label: 'StyleX', value: 'stylex' },
143
+ ]);
144
+ }
145
+
132
146
  prompter.close();
133
147
 
134
- return { projectName, reactCompat, cssApproach };
148
+ return { projectName, reactCompat, cssApproach, template };
135
149
  }
136
150
 
137
151
  // ---------------------------------------------------------------------------
138
152
  // File generators
139
153
  // ---------------------------------------------------------------------------
140
154
 
141
- function generatePackageJson(packageName, { reactCompat, cssApproach }) {
155
+ function generatePackageJson(packageName, { reactCompat, cssApproach, template }) {
142
156
  const deps = {
143
157
  'what-framework': whatVersionRange,
144
158
  };
145
- const devDeps = {
146
- vite: '^6.0.0',
147
- 'what-compiler': whatVersionRange,
148
- 'what-devtools-mcp': whatVersionRange,
149
- '@babel/core': '^7.23.0',
150
- };
159
+
160
+ // Full-stack apps are buildless: server.js does SSR + ISR and serves the
161
+ // client entry as native ES modules, so there is no Vite/compiler toolchain.
162
+ // `npm run dev` runs the real app with auto-restart on change.
163
+ const devDeps = template === 'fullstack'
164
+ ? {
165
+ 'what-devtools-mcp': whatVersionRange,
166
+ eslint: '^9.0.0',
167
+ 'eslint-plugin-what': whatVersionRange,
168
+ }
169
+ : {
170
+ vite: '^6.0.0',
171
+ 'what-compiler': whatVersionRange,
172
+ 'what-devtools-mcp': whatVersionRange,
173
+ '@babel/core': '^7.23.0',
174
+ eslint: '^9.0.0',
175
+ 'eslint-plugin-what': whatVersionRange,
176
+ };
177
+
178
+ const scripts = template === 'fullstack'
179
+ ? { dev: 'node --watch server.js', start: 'node server.js', lint: 'eslint .' }
180
+ : { dev: 'vite', build: 'vite build', preview: 'vite preview', lint: 'eslint .' };
181
+ if (template === 'fullstack') {
182
+ deps['what-isr'] = whatVersionRange;
183
+ }
151
184
 
152
185
  if (reactCompat) {
153
186
  deps['what-react'] = whatVersionRange;
@@ -171,11 +204,7 @@ function generatePackageJson(packageName, { reactCompat, cssApproach }) {
171
204
  private: true,
172
205
  version: '0.1.0',
173
206
  type: 'module',
174
- scripts: {
175
- dev: 'vite',
176
- build: 'vite build',
177
- preview: 'vite preview',
178
- },
207
+ scripts,
179
208
  dependencies: deps,
180
209
  devDependencies: devDeps,
181
210
  }, null, 2) + '\n';
@@ -329,20 +358,20 @@ function generateMainJsx({ reactCompat, cssApproach }) {
329
358
  }
330
359
 
331
360
  function generateMainDefault() {
332
- return `import { mount, useSignal } from 'what-framework';
361
+ return `import { mount, signal } from 'what-framework';
333
362
 
334
363
  function App() {
335
- const count = useSignal(0);
364
+ const count = signal(0);
336
365
 
337
366
  return (
338
367
  <main className="app-shell">
339
368
  <h1>What Framework</h1>
340
- <p>Compiler-first JSX, React-familiar authoring.</p>
369
+ <p>Compiler-first JSX, fine-grained signals.</p>
341
370
 
342
371
  <section className="counter">
343
- <button onClick={() => count.set(c => c - 1)}>-</button>
372
+ <button onClick={() => count(c => c - 1)}>-</button>
344
373
  <output>{count()}</output>
345
- <button onClick={() => count.set(c => c + 1)}>+</button>
374
+ <button onClick={() => count(c => c + 1)}>+</button>
346
375
  </section>
347
376
  </main>
348
377
  );
@@ -353,10 +382,10 @@ mount(<App />, '#app');
353
382
  }
354
383
 
355
384
  function generateMainWithTailwind() {
356
- return `import { mount, useSignal } from 'what-framework';
385
+ return `import { mount, signal } from 'what-framework';
357
386
 
358
387
  function App() {
359
- const count = useSignal(0);
388
+ const count = signal(0);
360
389
 
361
390
  return (
362
391
  <main className="min-h-screen flex items-center justify-center bg-slate-50">
@@ -367,14 +396,14 @@ function App() {
367
396
  <section className="mt-6 inline-flex items-center gap-3">
368
397
  <button
369
398
  className="w-9 h-9 rounded-lg border border-slate-300 bg-white text-slate-900 hover:border-blue-600 cursor-pointer"
370
- onClick={() => count.set(c => c - 1)}
399
+ onClick={() => count(c => c - 1)}
371
400
  >
372
401
  -
373
402
  </button>
374
403
  <output className="min-w-[2ch] text-center font-bold">{count()}</output>
375
404
  <button
376
405
  className="w-9 h-9 rounded-lg border border-slate-300 bg-white text-slate-900 hover:border-blue-600 cursor-pointer"
377
- onClick={() => count.set(c => c + 1)}
406
+ onClick={() => count(c => c + 1)}
378
407
  >
379
408
  +
380
409
  </button>
@@ -389,7 +418,7 @@ mount(<App />, '#app');
389
418
  }
390
419
 
391
420
  function generateMainWithStyleX() {
392
- return `import { mount, useSignal } from 'what-framework';
421
+ return `import { mount, signal } from 'what-framework';
393
422
  import * as stylex from '@stylexjs/stylex';
394
423
 
395
424
  const styles = stylex.create({
@@ -443,7 +472,7 @@ const styles = stylex.create({
443
472
  });
444
473
 
445
474
  function App() {
446
- const count = useSignal(0);
475
+ const count = signal(0);
447
476
 
448
477
  return (
449
478
  <main {...stylex.props(styles.page)}>
@@ -452,9 +481,9 @@ function App() {
452
481
  <p {...stylex.props(styles.subtitle)}>Compiler-first JSX + StyleX.</p>
453
482
 
454
483
  <section {...stylex.props(styles.counter)}>
455
- <button {...stylex.props(styles.button)} onClick={() => count.set(c => c - 1)}>-</button>
484
+ <button {...stylex.props(styles.button)} onClick={() => count(c => c - 1)}>-</button>
456
485
  <output {...stylex.props(styles.output)}>{count()}</output>
457
- <button {...stylex.props(styles.button)} onClick={() => count.set(c => c + 1)}>+</button>
486
+ <button {...stylex.props(styles.button)} onClick={() => count(c => c + 1)}>+</button>
458
487
  </section>
459
488
  </div>
460
489
  </main>
@@ -469,7 +498,7 @@ function generateMainWithReactCompat({ cssApproach }) {
469
498
  // React compat demo: uses zustand (a real React library) to show it works
470
499
  // with What Framework under the hood.
471
500
  if (cssApproach === 'tailwind') {
472
- return `import { mount, useSignal } from 'what-framework';
501
+ return `import { mount, signal } from 'what-framework';
473
502
  import { create } from 'zustand';
474
503
 
475
504
  // A real React state library — works with What Framework via what-react!
@@ -508,7 +537,7 @@ function BearCounter() {
508
537
  }
509
538
 
510
539
  function App() {
511
- const count = useSignal(0);
540
+ const count = signal(0);
512
541
 
513
542
  return (
514
543
  <main className="min-h-screen flex items-center justify-center bg-slate-50">
@@ -519,14 +548,14 @@ function App() {
519
548
  <section className="mt-6 inline-flex items-center gap-3">
520
549
  <button
521
550
  className="w-9 h-9 rounded-lg border border-slate-300 bg-white text-slate-900 hover:border-blue-600 cursor-pointer"
522
- onClick={() => count.set(c => c - 1)}
551
+ onClick={() => count(c => c - 1)}
523
552
  >
524
553
  -
525
554
  </button>
526
555
  <output className="min-w-[2ch] text-center font-bold">{count()}</output>
527
556
  <button
528
557
  className="w-9 h-9 rounded-lg border border-slate-300 bg-white text-slate-900 hover:border-blue-600 cursor-pointer"
529
- onClick={() => count.set(c => c + 1)}
558
+ onClick={() => count(c => c + 1)}
530
559
  >
531
560
  +
532
561
  </button>
@@ -543,7 +572,7 @@ mount(<App />, '#app');
543
572
  }
544
573
 
545
574
  if (cssApproach === 'stylex') {
546
- return `import { mount, useSignal } from 'what-framework';
575
+ return `import { mount, signal } from 'what-framework';
547
576
  import { create } from 'zustand';
548
577
  import * as stylex from '@stylexjs/stylex';
549
578
 
@@ -629,7 +658,7 @@ function BearCounter() {
629
658
  }
630
659
 
631
660
  function App() {
632
- const count = useSignal(0);
661
+ const count = signal(0);
633
662
 
634
663
  return (
635
664
  <main {...stylex.props(styles.page)}>
@@ -638,9 +667,9 @@ function App() {
638
667
  <p {...stylex.props(styles.subtitle)}>React compat + StyleX.</p>
639
668
 
640
669
  <section {...stylex.props(styles.counter)}>
641
- <button {...stylex.props(styles.button)} onClick={() => count.set(c => c - 1)}>-</button>
670
+ <button {...stylex.props(styles.button)} onClick={() => count(c => c - 1)}>-</button>
642
671
  <output {...stylex.props(styles.output)}>{count()}</output>
643
- <button {...stylex.props(styles.button)} onClick={() => count.set(c => c + 1)}>+</button>
672
+ <button {...stylex.props(styles.button)} onClick={() => count(c => c + 1)}>+</button>
644
673
  </section>
645
674
 
646
675
  <BearCounter />
@@ -654,7 +683,7 @@ mount(<App />, '#app');
654
683
  }
655
684
 
656
685
  // React compat with vanilla CSS
657
- return `import { mount, useSignal } from 'what-framework';
686
+ return `import { mount, signal } from 'what-framework';
658
687
  import { create } from 'zustand';
659
688
 
660
689
  // A real React state library — works with What Framework via what-react!
@@ -683,7 +712,7 @@ function BearCounter() {
683
712
  }
684
713
 
685
714
  function App() {
686
- const count = useSignal(0);
715
+ const count = signal(0);
687
716
 
688
717
  return (
689
718
  <main className="app-shell">
@@ -691,9 +720,9 @@ function App() {
691
720
  <p>React compat enabled — use React libraries with signals.</p>
692
721
 
693
722
  <section className="counter">
694
- <button onClick={() => count.set(c => c - 1)}>-</button>
723
+ <button onClick={() => count(c => c - 1)}>-</button>
695
724
  <output>{count()}</output>
696
- <button onClick={() => count.set(c => c + 1)}>+</button>
725
+ <button onClick={() => count(c => c + 1)}>+</button>
697
726
  </section>
698
727
 
699
728
  <BearCounter />
@@ -807,11 +836,18 @@ output {
807
836
  `;
808
837
  }
809
838
 
810
- function generateReadme(packageName, { reactCompat, cssApproach }) {
811
- let notes = `- Canonical package name is \`what-framework\`.
839
+ function generateReadme(packageName, { reactCompat, cssApproach, template }) {
840
+ let notes = template === 'fullstack'
841
+ ? `- Canonical package name is \`what-framework\`.
842
+ - Pages are authored with \`h()\` in plain .js so they run isomorphically (Node
843
+ SSR + browser hydration) with no compile step.
844
+ - Event handlers accept both \`onClick\` and \`onclick\`.
845
+ - Lint with \`npm run lint\` (eslint-plugin-what).`
846
+ : `- Canonical package name is \`what-framework\`.
812
847
  - Uses the What compiler for JSX transforms and automatic reactivity.
813
848
  - Vite is preconfigured; use \`npm run dev/build/preview\`.
814
849
  - Event handlers accept both \`onClick\` and \`onclick\`; docs and templates use \`onClick\`.
850
+ - Lint with \`npm run lint\` (eslint-plugin-what).
815
851
  - Bun is also supported: \`bun create what@latest\`, \`bun run dev\`.`;
816
852
 
817
853
  if (reactCompat) {
@@ -830,6 +866,59 @@ function generateReadme(packageName, { reactCompat, cssApproach }) {
830
866
  - StyleX is configured via \`vite-plugin-stylex\`. Define styles with \`stylex.create()\` and apply with \`{...stylex.props()}\`.`;
831
867
  }
832
868
 
869
+ if (template === 'fullstack') {
870
+ return `# ${packageName}
871
+
872
+ A full-stack What Framework app: file-routed SSR, server loaders, server
873
+ actions, client hydration, and origin-first ISR (stale-while-revalidate +
874
+ on-demand revalidation + poll regeneration) — works on any host, no CDN
875
+ required. Buildless: the server serves the client entry and the framework as
876
+ native ES modules, so there is no bundle step.
877
+
878
+ ## Run
879
+
880
+ \`\`\`bash
881
+ npm install
882
+ npm run dev # SSR server with auto-restart on change → http://localhost:3000
883
+ npm start # same server, no watcher (production entry point)
884
+ \`\`\`
885
+
886
+ In production, set \`WHAT_REVALIDATE_SECRET\` (the server refuses to start
887
+ without it when \`NODE_ENV=production\`):
888
+
889
+ \`\`\`bash
890
+ WHAT_REVALIDATE_SECRET=$(node -e "console.log(require('node:crypto').randomBytes(24).toString('hex'))") \\
891
+ NODE_ENV=production npm start
892
+ \`\`\`
893
+
894
+ ## Structure
895
+
896
+ - \`src/pages/*\` — pages. Each may export \`page\` (route config), \`loader\` (server
897
+ data), \`getStaticPaths\` (pre-render), and a default component.
898
+ - \`src/actions/*\` — server actions (mutations), served at \`/__what_action\`. Call
899
+ \`revalidatePath\`/\`revalidateTag\` after a mutation to purge the ISR cache.
900
+ - \`src/routes.js\` — the route table (loaders/getStaticPaths/page bound per route).
901
+ - \`src/entry-client.js\` — client hydration entry: re-renders the matched page
902
+ with the server's loader data (\`#__what_data\`) and attaches interactivity.
903
+ - \`server.js\` — Node adapter + ISR engine + revalidate webhook + poll scheduler
904
+ + static serving (client entry, framework modules, \`public/\`).
905
+ - \`what.config.js\` — deploy adapter + ISR defaults.
906
+
907
+ ### ISR cheat-sheet
908
+
909
+ - \`page = { mode: 'static', revalidate: 60 }\` → cached, regenerated in the
910
+ background after 60s (stale-while-revalidate). Check the \`X-What-Cache\`
911
+ response header: MISS on first hit, HIT after.
912
+ - \`page = { mode: 'server' }\` → always fresh, never cached.
913
+ - \`revalidatePath('/')\` / \`revalidateTag('posts')\` → on-demand purge.
914
+ - \`POST /__what_revalidate\` with \`WHAT_REVALIDATE_SECRET\` → CMS-triggered purge.
915
+
916
+ ## Notes
917
+
918
+ ${notes}
919
+ `;
920
+ }
921
+
833
922
  return `# ${packageName}
834
923
 
835
924
  ## Run
@@ -847,6 +936,458 @@ ${notes}
847
936
  `;
848
937
  }
849
938
 
939
+ // ---------------------------------------------------------------------------
940
+ // Full-stack template — file-routed SSR pages, server loaders, getStaticPaths,
941
+ // origin-first ISR, and a server action that revalidates the cache. Authored
942
+ // with h() in plain .js so it runs with `node server.js` (no build step) while
943
+ // `npm run dev` still serves the client. Mirrors examples/blog (proven by its
944
+ // e2e suite). The full loop: SSR → loader → ISR (MISS→HIT) → action → revalidate.
945
+ // ---------------------------------------------------------------------------
946
+ function generateFullstackFiles(root, packageName) {
947
+ mkdirSync(resolve(root, 'src', 'pages'), { recursive: true });
948
+ mkdirSync(resolve(root, 'src', 'actions'), { recursive: true });
949
+
950
+ // Tiny in-memory "database". Swap for SQLite/Postgres — the loader/action
951
+ // contract is identical.
952
+ writeFileSync(resolve(root, 'src', 'db.js'), `// In-memory demo data. A real app would use SQLite/Postgres; the loader and
953
+ // action contracts are identical either way.
954
+
955
+ const posts = [
956
+ { slug: 'hello-world', title: 'Hello, World', body: 'The first post.', createdAt: 1 },
957
+ { slug: 'why-signals', title: 'Why Signals', body: 'Fine-grained reactivity, no virtual DOM, components run once.', createdAt: 2 },
958
+ ];
959
+
960
+ export function listPosts() {
961
+ return [...posts].sort((a, b) => b.createdAt - a.createdAt);
962
+ }
963
+
964
+ export function getPost(slug) {
965
+ return posts.find((p) => p.slug === slug) || null;
966
+ }
967
+
968
+ export function createPost({ title, body }) {
969
+ const slug = String(title).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
970
+ const post = { slug, title, body, createdAt: Date.now() };
971
+ posts.push(post);
972
+ return post;
973
+ }
974
+ `);
975
+
976
+ // Server action: mutate, then revalidate the cached pages it affects.
977
+ writeFileSync(resolve(root, 'src', 'actions', 'posts.js'), `// Server action: create a post, then revalidate the cached home listing.
978
+ // revalidatePath purges the origin ISR cache (and any CDN) so the new post
979
+ // appears immediately on the next request.
980
+
981
+ import { action, revalidatePath } from 'what-framework/server';
982
+ import { createPost } from '../db.js';
983
+
984
+ export const createPostAction = action(
985
+ async ({ title, body }) => {
986
+ const post = createPost({ title, body });
987
+ return { ok: true, slug: post.slug };
988
+ },
989
+ { id: 'createPost', revalidate: ['/'] } // purge the home listing on success
990
+ );
991
+ `);
992
+
993
+ // Home page — static + ISR, lists posts via a loader, plus a hydrated
994
+ // counter that proves the client entry attaches interactivity.
995
+ writeFileSync(resolve(root, 'src', 'pages', 'home.js'), `// Home page.
996
+ // export const page -> route config (JSON-safe: mode, revalidate, tags)
997
+ // export const loader -> runs on the server before render; result -> loaderData
998
+ // export default -> the page component (runs once, no re-renders)
999
+ //
1000
+ // This module is isomorphic: the server renders it to HTML, then
1001
+ // src/entry-client.js hydrates the same component in the browser (reusing the
1002
+ // server DOM and attaching the onclick handler + reactive text below).
1003
+
1004
+ import { h, Head, signal, useLoaderData } from 'what-framework';
1005
+ import { listPosts } from '../db.js';
1006
+
1007
+ export const page = { mode: 'static', revalidate: 60, tags: ['posts'] };
1008
+
1009
+ export const loader = () => ({ posts: listPosts() });
1010
+
1011
+ export default function Home() {
1012
+ const { posts } = useLoaderData();
1013
+ const likes = signal(0, 'likes');
1014
+ return h('main', { class: 'container' },
1015
+ h(Head, {
1016
+ title: '${packageName}',
1017
+ meta: [{ name: 'description', content: 'A full-stack app built with What Framework' }],
1018
+ }),
1019
+ h('h1', {}, '${packageName}'),
1020
+ h('p', {}, 'Server-rendered, ISR-cached, hydrated on the client.'),
1021
+ h('section', { class: 'like-demo' },
1022
+ h('button', { onclick: () => likes(n => n + 1) }, 'Like'),
1023
+ h('output', {}, () => String(likes())),
1024
+ h('span', { class: 'hint' }, 'hydrated — this button works in the browser')
1025
+ ),
1026
+ h('ul', {}, posts.map((p) =>
1027
+ h('li', { key: p.slug }, h('a', { href: \`/blog/\${p.slug}\` }, p.title))
1028
+ )),
1029
+ h('p', {}, h('a', { href: '/new' }, '+ New post'))
1030
+ );
1031
+ }
1032
+ `);
1033
+
1034
+ // Dynamic /blog/[slug] — loader + getStaticPaths + ISR + per-post <Head>.
1035
+ writeFileSync(resolve(root, 'src', 'pages', 'post.js'), `// Dynamic post page. getStaticPaths pre-renders known slugs at build; unknown
1036
+ // slugs render on first hit (fallback: 'blocking') and are then cached.
1037
+
1038
+ import { h, Head, useLoaderData } from 'what-framework';
1039
+ import { getPost, listPosts } from '../db.js';
1040
+
1041
+ export const page = { mode: 'static', revalidate: 60, tags: ['posts'] };
1042
+
1043
+ export const loader = ({ params }) => ({ post: getPost(params.slug) });
1044
+
1045
+ export async function getStaticPaths() {
1046
+ return {
1047
+ paths: listPosts().map((p) => ({ params: { slug: p.slug } })),
1048
+ fallback: 'blocking',
1049
+ };
1050
+ }
1051
+
1052
+ export default function Post() {
1053
+ const { post } = useLoaderData();
1054
+ if (!post) return h('main', { class: 'container' }, h('h1', {}, 'Not found'));
1055
+ return h('main', { class: 'container' },
1056
+ h(Head, {
1057
+ title: \`\${post.title} — ${packageName}\`,
1058
+ meta: [{ property: 'og:title', content: post.title }],
1059
+ }),
1060
+ h('article', {}, h('h1', {}, post.title), h('p', {}, post.body)),
1061
+ h('p', {}, h('a', { href: '/' }, '← Back'))
1062
+ );
1063
+ }
1064
+ `);
1065
+
1066
+ // /new — mode:'server' (never cached), posts to the createPost action.
1067
+ writeFileSync(resolve(root, 'src', 'pages', 'new.js'), `// New-post form. mode:'server' so it's always fresh. src/entry-client.js
1068
+ // enhances the form: it submits to the createPost server action as JSON with
1069
+ // the X-What-Action header (the action protocol), then navigates to the post.
1070
+
1071
+ import { h, Head } from 'what-framework';
1072
+
1073
+ export const page = { mode: 'server' };
1074
+
1075
+ export default function NewPost() {
1076
+ return h('main', { class: 'container' },
1077
+ h(Head, { title: 'New post — ${packageName}' }),
1078
+ h('h1', {}, 'New post'),
1079
+ h('form', { method: 'post', action: '/__what_action', 'data-action': 'createPost' },
1080
+ h('input', { name: 'title', placeholder: 'Title', required: true }),
1081
+ h('textarea', { name: 'body', placeholder: 'Write something…', required: true }),
1082
+ h('button', { type: 'submit' }, 'Publish')
1083
+ ),
1084
+ h('p', {}, h('a', { href: '/' }, '← Back'))
1085
+ );
1086
+ }
1087
+ `);
1088
+
1089
+ // Route table. In a JSX app the vite plugin generates this from src/pages/**;
1090
+ // here it's hand-written so the app runs with plain \`node server.js\`.
1091
+ writeFileSync(resolve(root, 'src', 'routes.js'), `// Each entry carries the page's default component plus its live
1092
+ // loader/getStaticPaths/page bindings.
1093
+
1094
+ import Home, { loader as homeLoader, page as homePage } from './pages/home.js';
1095
+ import Post, { loader as postLoader, getStaticPaths as postPaths, page as postPage } from './pages/post.js';
1096
+ import NewPost, { page as newPage } from './pages/new.js';
1097
+
1098
+ // Importing the action registers it so /__what_action can dispatch it.
1099
+ import './actions/posts.js';
1100
+
1101
+ export const routes = [
1102
+ { path: '/', component: Home, loader: homeLoader, page: homePage, mode: homePage.mode },
1103
+ { path: '/blog/:slug', component: Post, loader: postLoader, getStaticPaths: postPaths, page: postPage, mode: postPage.mode },
1104
+ { path: '/new', component: NewPost, page: newPage, mode: newPage.mode },
1105
+ ];
1106
+ `);
1107
+
1108
+ // Client hydration entry — loaded by the SSR document (see server.js). Reuses
1109
+ // the server-rendered DOM and attaches interactivity. No bundler involved:
1110
+ // the browser resolves 'what-framework' through the import map server.js
1111
+ // injects, and ./pages/* are served as plain ES modules.
1112
+ writeFileSync(resolve(root, 'src', 'entry-client.js'), `// Client hydration entry.
1113
+ // The SSR document loads this as a native ES module. It matches the current
1114
+ // URL to a page component, re-runs it with the server's loader data (pages
1115
+ // call useLoaderData(), which reads the #__what_data payload the server
1116
+ // embedded), and hydrate() walks the existing server-rendered DOM — attaching
1117
+ // event handlers and reactive bindings instead of recreating elements.
1118
+ //
1119
+ // NOTE: import page modules directly, never ./routes.js — the route table also
1120
+ // imports the server actions, which pull in Node-only modules.
1121
+
1122
+ import { h, hydrate } from 'what-framework';
1123
+ import Home from './pages/home.js';
1124
+ import Post from './pages/post.js';
1125
+ import NewPost from './pages/new.js';
1126
+
1127
+ function matchPage(pathname) {
1128
+ if (pathname === '/') return h(Home, {});
1129
+ const post = pathname.match(/^\\/blog\\/([^/]+)\\/?$/);
1130
+ if (post) return h(Post, { slug: decodeURIComponent(post[1]) });
1131
+ if (pathname === '/new') return h(NewPost, {});
1132
+ return null;
1133
+ }
1134
+
1135
+ const vnode = matchPage(location.pathname);
1136
+ if (vnode) hydrate(vnode, document.body);
1137
+
1138
+ // Progressive enhancement for server-action forms: submit as JSON to
1139
+ // /__what_action with the X-What-Action header (the served-action protocol),
1140
+ // then navigate to the result. The CSRF token comes from the double-submit
1141
+ // cookie the server sets on every HTML response (or the meta tag on
1142
+ // uncached pages) — same lookup the framework's own action() client uses.
1143
+ function csrfToken() {
1144
+ const meta = document.querySelector('meta[name="what-csrf-token"]');
1145
+ if (meta) return meta.getAttribute('content');
1146
+ const match = document.cookie.match(/(?:^|;\\s*)what-csrf=([^;]+)/);
1147
+ return match ? decodeURIComponent(match[1]) : null;
1148
+ }
1149
+
1150
+ for (const form of document.querySelectorAll('form[data-action]')) {
1151
+ form.addEventListener('submit', async (event) => {
1152
+ event.preventDefault();
1153
+ const args = Object.fromEntries(new FormData(form));
1154
+ const headers = { 'content-type': 'application/json', 'x-what-action': form.dataset.action };
1155
+ const token = csrfToken();
1156
+ if (token) headers['x-csrf-token'] = token;
1157
+ const res = await fetch('/__what_action', {
1158
+ method: 'POST',
1159
+ headers,
1160
+ credentials: 'same-origin',
1161
+ body: JSON.stringify({ args: [args] }),
1162
+ });
1163
+ const result = await res.json().catch(() => ({}));
1164
+ if (res.ok) {
1165
+ location.href = result.slug ? \`/blog/\${result.slug}\` : '/';
1166
+ } else {
1167
+ console.error('[what] action failed:', result.message || res.status);
1168
+ }
1169
+ });
1170
+ }
1171
+ `);
1172
+
1173
+ // Stylesheet for the SSR pages (linked from the document head in server.js).
1174
+ writeFileSync(resolve(root, 'src', 'styles.css'), `:root {
1175
+ color-scheme: light;
1176
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif;
1177
+ }
1178
+
1179
+ * {
1180
+ box-sizing: border-box;
1181
+ }
1182
+
1183
+ body {
1184
+ margin: 0;
1185
+ min-height: 100vh;
1186
+ background: #f4f6fb;
1187
+ color: #0f172a;
1188
+ }
1189
+
1190
+ .container {
1191
+ max-width: 640px;
1192
+ margin: 0 auto;
1193
+ padding: 2.5rem 1.25rem;
1194
+ }
1195
+
1196
+ a {
1197
+ color: #2563eb;
1198
+ }
1199
+
1200
+ button {
1201
+ border: 1px solid #9aa7bb;
1202
+ background: #ffffff;
1203
+ color: #0f172a;
1204
+ padding: 0.4rem 0.9rem;
1205
+ border-radius: 10px;
1206
+ cursor: pointer;
1207
+ }
1208
+
1209
+ button:hover {
1210
+ border-color: #2563eb;
1211
+ }
1212
+
1213
+ .like-demo {
1214
+ display: inline-flex;
1215
+ align-items: center;
1216
+ gap: 0.75rem;
1217
+ margin: 0.5rem 0 1rem;
1218
+ }
1219
+
1220
+ .like-demo output {
1221
+ min-width: 2ch;
1222
+ text-align: center;
1223
+ font-weight: 700;
1224
+ }
1225
+
1226
+ .like-demo .hint {
1227
+ color: #64748b;
1228
+ font-size: 0.875rem;
1229
+ }
1230
+
1231
+ form {
1232
+ display: grid;
1233
+ gap: 0.75rem;
1234
+ max-width: 420px;
1235
+ }
1236
+
1237
+ input,
1238
+ textarea {
1239
+ padding: 0.5rem 0.75rem;
1240
+ border: 1px solid #9aa7bb;
1241
+ border-radius: 10px;
1242
+ font: inherit;
1243
+ }
1244
+ `);
1245
+
1246
+ // Full-stack server: Node adapter + origin-first ISR + revalidate webhook +
1247
+ // poll scheduler + static serving for the buildless client (native ESM).
1248
+ writeFileSync(resolve(root, 'server.js'), `// Full-stack server. \`npm run dev\` (auto-restart) or \`node server.js\`
1249
+ // → http://localhost:3000. Wires the Node adapter + origin-first ISR engine +
1250
+ // on-demand revalidation webhook + poll scheduler. No CDN — and no bundler —
1251
+ // required: the client entry and the framework are served as native ES modules
1252
+ // (see the import map below).
1253
+
1254
+ import http from 'node:http';
1255
+ import { randomBytes } from 'node:crypto';
1256
+ import { existsSync, statSync, createReadStream } from 'node:fs';
1257
+ import { extname, join, resolve } from 'node:path';
1258
+ import { fileURLToPath } from 'node:url';
1259
+ import { createRequestHandler, toNodeListener } from 'what-framework/server';
1260
+ import {
1261
+ createCacheEngine,
1262
+ createMemoryStore,
1263
+ createRevalidateWebhook,
1264
+ createScheduler,
1265
+ } from 'what-isr';
1266
+ import { routes } from './src/routes.js';
1267
+
1268
+ const ROOT = fileURLToPath(new URL('.', import.meta.url));
1269
+ const isProd = process.env.NODE_ENV === 'production';
1270
+
1271
+ // Secret for the on-demand revalidation webhook (POST /__what_revalidate).
1272
+ // Required in production; auto-generated per run in dev so there is never a
1273
+ // shared default secret.
1274
+ let REVALIDATE_SECRET = process.env.WHAT_REVALIDATE_SECRET;
1275
+ if (!REVALIDATE_SECRET) {
1276
+ if (isProd) {
1277
+ console.error(
1278
+ '[${packageName}] WHAT_REVALIDATE_SECRET must be set in production.\\n' +
1279
+ \" Generate one: node -e \\\"console.log(require('node:crypto').randomBytes(24).toString('hex'))\\\"\"
1280
+ );
1281
+ process.exit(1);
1282
+ }
1283
+ REVALIDATE_SECRET = randomBytes(24).toString('hex');
1284
+ console.log(\`[dev] WHAT_REVALIDATE_SECRET not set — generated for this run: \${REVALIDATE_SECRET}\`);
1285
+ }
1286
+
1287
+ // Origin ISR cache. Swap createMemoryStore() for createFilesystemStore({dir})
1288
+ // to survive restarts, or createRedisStore({client}) for multi-instance.
1289
+ const cache = createCacheEngine({ store: createMemoryStore() });
1290
+
1291
+ // Keep the home listing warm every 5 minutes regardless of traffic.
1292
+ const scheduler = createScheduler(cache);
1293
+ scheduler.register({ path: '/', query: {}, config: routes[0].page }, { intervalMs: 5 * 60 * 1000 });
1294
+
1295
+ // The browser loads /src/entry-client.js as a native ES module; this import
1296
+ // map resolves its bare imports. Sources are served from node_modules below.
1297
+ const importMap = {
1298
+ imports: {
1299
+ 'what-framework': '/node_modules/what-framework/src/index.js',
1300
+ 'what-core': '/node_modules/what-core/src/index.js',
1301
+ },
1302
+ };
1303
+
1304
+ const documentOptions = {
1305
+ clientEntry: '/src/entry-client.js',
1306
+ head:
1307
+ \`<script type="importmap">\${JSON.stringify(importMap)}</script>\` +
1308
+ '<link rel="stylesheet" href="/src/styles.css">' +
1309
+ '<link rel="icon" type="image/svg+xml" href="/favicon.svg">',
1310
+ };
1311
+
1312
+ export function createHandler() {
1313
+ return createRequestHandler({
1314
+ routes,
1315
+ cache,
1316
+ revalidateWebhook: createRevalidateWebhook(cache, { secret: REVALIDATE_SECRET }),
1317
+ document: documentOptions,
1318
+ });
1319
+ }
1320
+
1321
+ // --- Static files (client entry, page modules, framework sources, public/) ---
1322
+
1323
+ const MIME = {
1324
+ '.js': 'text/javascript; charset=utf-8',
1325
+ '.mjs': 'text/javascript; charset=utf-8',
1326
+ '.css': 'text/css; charset=utf-8',
1327
+ '.json': 'application/json; charset=utf-8',
1328
+ '.svg': 'image/svg+xml',
1329
+ '.png': 'image/png',
1330
+ '.jpg': 'image/jpeg',
1331
+ '.ico': 'image/x-icon',
1332
+ '.txt': 'text/plain; charset=utf-8',
1333
+ '.woff2': 'font/woff2',
1334
+ };
1335
+
1336
+ // Only these prefixes are served from the project root; anything else falls
1337
+ // back to public/ (favicon etc.) and then to the app handler.
1338
+ const SERVED_PREFIXES = ['/src/', '/node_modules/what-framework/', '/node_modules/what-core/'];
1339
+
1340
+ function resolveStaticFile(pathname) {
1341
+ if (pathname.includes('..') || pathname.includes('\\0')) return null;
1342
+ const file = SERVED_PREFIXES.some((p) => pathname.startsWith(p))
1343
+ ? resolve(join(ROOT, pathname))
1344
+ : resolve(join(ROOT, 'public', pathname));
1345
+ if (!file.startsWith(resolve(ROOT))) return null;
1346
+ if (!existsSync(file) || !statSync(file).isFile()) return null;
1347
+ return file;
1348
+ }
1349
+
1350
+ // --- Start (node server.js / npm run dev), not when imported by tests ---
1351
+
1352
+ if (import.meta.url === \`file://\${process.argv[1]}\`) {
1353
+ const app = toNodeListener(createHandler());
1354
+
1355
+ const server = http.createServer((req, res) => {
1356
+ if (req.method === 'GET' || req.method === 'HEAD') {
1357
+ const pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
1358
+ const file = resolveStaticFile(pathname);
1359
+ if (file) {
1360
+ res.writeHead(200, { 'content-type': MIME[extname(file)] || 'application/octet-stream' });
1361
+ if (req.method === 'HEAD') return res.end();
1362
+ return createReadStream(file).pipe(res);
1363
+ }
1364
+ }
1365
+ app(req, res);
1366
+ });
1367
+
1368
+ scheduler.start();
1369
+ const stop = () => { try { scheduler.stop(); } catch {} server.close(); };
1370
+ process.once('SIGTERM', stop);
1371
+ process.once('SIGINT', stop);
1372
+
1373
+ const port = Number(process.env.PORT) || 3000;
1374
+ server.listen(port, () => console.log(\`${packageName} → http://localhost:\${port}\`));
1375
+ }
1376
+ `);
1377
+
1378
+ // App config — the CLI reads this to pick a deploy adapter + ISR defaults.
1379
+ writeFileSync(resolve(root, 'what.config.js'), `// What Framework app config. \`what build\` / \`what start\` read this to pick a
1380
+ // deploy adapter and ISR defaults.
1381
+ export default {
1382
+ adapter: 'node', // 'node' | 'vercel' | 'cloudflare' | 'static'
1383
+ isr: {
1384
+ store: 'memory', // 'memory' | 'filesystem' | 'redis'
1385
+ defaultRevalidate: 60,
1386
+ },
1387
+ };
1388
+ `);
1389
+ }
1390
+
850
1391
  // ---------------------------------------------------------------------------
851
1392
  // Main
852
1393
  // ---------------------------------------------------------------------------
@@ -1085,8 +1626,10 @@ count(c => c + 1) // update
1085
1626
  // package.json
1086
1627
  writeFileSync(resolve(root, 'package.json'), generatePackageJson(packageName, options));
1087
1628
 
1088
- // index.html
1089
- writeFileSync(resolve(root, 'index.html'), generateIndexHtml(packageName));
1629
+ // index.html (SPA only — the full-stack server renders its own document)
1630
+ if (options.template !== 'fullstack') {
1631
+ writeFileSync(resolve(root, 'index.html'), generateIndexHtml(packageName));
1632
+ }
1090
1633
 
1091
1634
  // favicon
1092
1635
  writeFileSync(resolve(root, 'public', 'favicon.svg'), `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
@@ -1101,8 +1644,35 @@ count(c => c + 1) // update
1101
1644
  </svg>
1102
1645
  `);
1103
1646
 
1104
- // vite.config.js
1105
- writeFileSync(resolve(root, 'vite.config.js'), generateViteConfig(options));
1647
+ // vite.config.js (SPA only — the full-stack template is buildless)
1648
+ if (options.template !== 'fullstack') {
1649
+ writeFileSync(resolve(root, 'vite.config.js'), generateViteConfig(options));
1650
+ }
1651
+
1652
+ // eslint.config.js — eslint-plugin-what flat config.
1653
+ // SPA: the What compiler handles event casing + reactive wrapping, so use
1654
+ // the `compiler` preset (the compiler-covered rules are off).
1655
+ // Fullstack: buildless h() authoring is the template's design, so use
1656
+ // `recommended` with the JSX-only h() rule disabled.
1657
+ writeFileSync(resolve(root, 'eslint.config.js'), options.template === 'fullstack'
1658
+ ? `import what from 'eslint-plugin-what';
1659
+
1660
+ export default [
1661
+ { ignores: ['node_modules'] },
1662
+ what.configs.recommended,
1663
+ // This template authors pages with h() on purpose (buildless, no compiler).
1664
+ { rules: { 'what/no-h-in-user-code': 'off' } },
1665
+ ];
1666
+ `
1667
+ : `import what from 'eslint-plugin-what';
1668
+
1669
+ export default [
1670
+ { ignores: ['dist', 'node_modules'] },
1671
+ // 'compiler' preset: this project uses the What compiler (via Vite), which
1672
+ // handles event casing and reactive JSX wrapping automatically.
1673
+ what.configs.compiler,
1674
+ ];
1675
+ `);
1106
1676
 
1107
1677
  // tsconfig.json
1108
1678
  writeFileSync(resolve(root, 'tsconfig.json'), JSON.stringify({
@@ -1136,14 +1706,20 @@ count(c => c + 1) // update
1136
1706
  recommendations: [],
1137
1707
  }, null, 2) + '\n');
1138
1708
 
1139
- // src/main.jsx
1140
- writeFileSync(resolve(root, 'src', 'main.jsx'), generateMainJsx(options));
1141
-
1142
- // src/styles.css
1143
- if (reactCompat && cssApproach === 'none') {
1144
- writeFileSync(resolve(root, 'src', 'styles.css'), generateStylesWithReactCompat());
1709
+ // Full-stack scaffold: file-routed SSR pages + server + client entry + ISR
1710
+ // config (writes its own src/styles.css). SPA scaffold: Vite entry + styles.
1711
+ if (options.template === 'fullstack') {
1712
+ generateFullstackFiles(root, packageName);
1145
1713
  } else {
1146
- writeFileSync(resolve(root, 'src', 'styles.css'), generateStyles(options));
1714
+ // src/main.jsx
1715
+ writeFileSync(resolve(root, 'src', 'main.jsx'), generateMainJsx(options));
1716
+
1717
+ // src/styles.css
1718
+ if (reactCompat && cssApproach === 'none') {
1719
+ writeFileSync(resolve(root, 'src', 'styles.css'), generateStylesWithReactCompat());
1720
+ } else {
1721
+ writeFileSync(resolve(root, 'src', 'styles.css'), generateStyles(options));
1722
+ }
1147
1723
  }
1148
1724
 
1149
1725
  // README.md
@@ -1163,7 +1739,11 @@ count(c => c + 1) // update
1163
1739
  console.log('\nNext steps:');
1164
1740
  console.log(` cd ${root}`);
1165
1741
  console.log(' npm install');
1166
- console.log(' npm run dev\n');
1742
+ if (options.template === 'fullstack') {
1743
+ console.log(' npm run dev # SSR + ISR server → http://localhost:3000\n');
1744
+ } else {
1745
+ console.log(' npm run dev\n');
1746
+ }
1167
1747
  }
1168
1748
 
1169
1749
  main().catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-what",
3
- "version": "0.8.4",
3
+ "version": "0.11.0",
4
4
  "description": "Scaffold a new What Framework project",
5
5
  "type": "module",
6
6
  "bin": {