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.
- package/README.md +52 -3
- package/index.js +641 -61
- 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. **
|
|
32
|
-
3. **
|
|
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
|
|
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
|
-
-
|
|
33
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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,
|
|
361
|
+
return `import { mount, signal } from 'what-framework';
|
|
333
362
|
|
|
334
363
|
function App() {
|
|
335
|
-
const count =
|
|
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,
|
|
369
|
+
<p>Compiler-first JSX, fine-grained signals.</p>
|
|
341
370
|
|
|
342
371
|
<section className="counter">
|
|
343
|
-
<button onClick={() => count
|
|
372
|
+
<button onClick={() => count(c => c - 1)}>-</button>
|
|
344
373
|
<output>{count()}</output>
|
|
345
|
-
<button onClick={() => count
|
|
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,
|
|
385
|
+
return `import { mount, signal } from 'what-framework';
|
|
357
386
|
|
|
358
387
|
function App() {
|
|
359
|
-
const count =
|
|
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
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
|
723
|
+
<button onClick={() => count(c => c - 1)}>-</button>
|
|
695
724
|
<output>{count()}</output>
|
|
696
|
-
<button onClick={() => count
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 => {
|