create-what 0.10.0 → 0.11.1
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 +476 -70
- 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
|
@@ -131,13 +131,17 @@ async function gatherOptions() {
|
|
|
131
131
|
{ label: 'Full-stack (SSR + file routes + loaders + actions + ISR)', value: 'fullstack' },
|
|
132
132
|
]);
|
|
133
133
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
}
|
|
141
145
|
|
|
142
146
|
prompter.close();
|
|
143
147
|
|
|
@@ -152,19 +156,30 @@ function generatePackageJson(packageName, { reactCompat, cssApproach, template }
|
|
|
152
156
|
const deps = {
|
|
153
157
|
'what-framework': whatVersionRange,
|
|
154
158
|
};
|
|
155
|
-
const devDeps = {
|
|
156
|
-
vite: '^6.0.0',
|
|
157
|
-
'what-compiler': whatVersionRange,
|
|
158
|
-
'what-devtools-mcp': whatVersionRange,
|
|
159
|
-
'@babel/core': '^7.23.0',
|
|
160
|
-
};
|
|
161
159
|
|
|
162
|
-
// Full-stack apps
|
|
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
|
+
|
|
163
178
|
const scripts = template === 'fullstack'
|
|
164
|
-
? { dev: '
|
|
165
|
-
: { dev: 'vite', build: 'vite build', preview: 'vite preview' };
|
|
179
|
+
? { dev: 'node --watch server.js', start: 'node server.js', lint: 'eslint .' }
|
|
180
|
+
: { dev: 'vite', build: 'vite build', preview: 'vite preview', lint: 'eslint .' };
|
|
166
181
|
if (template === 'fullstack') {
|
|
167
|
-
deps['what-
|
|
182
|
+
deps['what-isr'] = whatVersionRange;
|
|
168
183
|
}
|
|
169
184
|
|
|
170
185
|
if (reactCompat) {
|
|
@@ -822,10 +837,17 @@ output {
|
|
|
822
837
|
}
|
|
823
838
|
|
|
824
839
|
function generateReadme(packageName, { reactCompat, cssApproach, template }) {
|
|
825
|
-
let notes =
|
|
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\`.
|
|
826
847
|
- Uses the What compiler for JSX transforms and automatic reactivity.
|
|
827
848
|
- Vite is preconfigured; use \`npm run dev/build/preview\`.
|
|
828
849
|
- Event handlers accept both \`onClick\` and \`onclick\`; docs and templates use \`onClick\`.
|
|
850
|
+
- Lint with \`npm run lint\` (eslint-plugin-what).
|
|
829
851
|
- Bun is also supported: \`bun create what@latest\`, \`bun run dev\`.`;
|
|
830
852
|
|
|
831
853
|
if (reactCompat) {
|
|
@@ -848,17 +870,26 @@ function generateReadme(packageName, { reactCompat, cssApproach, template }) {
|
|
|
848
870
|
return `# ${packageName}
|
|
849
871
|
|
|
850
872
|
A full-stack What Framework app: file-routed SSR, server loaders, server
|
|
851
|
-
actions, and origin-first ISR (stale-while-revalidate +
|
|
852
|
-
poll regeneration) — works on any host, no CDN
|
|
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.
|
|
853
877
|
|
|
854
878
|
## Run
|
|
855
879
|
|
|
856
880
|
\`\`\`bash
|
|
857
881
|
npm install
|
|
858
|
-
npm
|
|
882
|
+
npm run dev # SSR server with auto-restart on change → http://localhost:3000
|
|
883
|
+
npm start # same server, no watcher (production entry point)
|
|
859
884
|
\`\`\`
|
|
860
885
|
|
|
861
|
-
|
|
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
|
+
\`\`\`
|
|
862
893
|
|
|
863
894
|
## Structure
|
|
864
895
|
|
|
@@ -867,13 +898,26 @@ Or develop the client in isolation with \`npm run dev\` (Vite, http://localhost:
|
|
|
867
898
|
- \`src/actions/*\` — server actions (mutations), served at \`/__what_action\`. Call
|
|
868
899
|
\`revalidatePath\`/\`revalidateTag\` after a mutation to purge the ISR cache.
|
|
869
900
|
- \`src/routes.js\` — the route table (loaders/getStaticPaths/page bound per route).
|
|
870
|
-
- \`
|
|
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. Static serving is deny-by-default: only allowlisted client
|
|
905
|
+
files (\`src/entry-client.js\`, \`src/styles.css\`, \`src/pages/\`,
|
|
906
|
+
\`src/components/\`, framework modules, \`public/\`) are served over HTTP —
|
|
907
|
+
server-only code (\`src/db.js\`, \`src/actions/\`, \`src/routes.js\`) returns 404.
|
|
871
908
|
- \`what.config.js\` — deploy adapter + ISR defaults.
|
|
872
909
|
|
|
910
|
+
The \`/new\` form works without JavaScript: it SSRs hidden \`_action\`,
|
|
911
|
+
\`what-csrf-token\` and \`_redirect\` fields, so a plain form-encoded POST to
|
|
912
|
+
\`/__what_action\` dispatches the action and redirects. With JavaScript,
|
|
913
|
+
\`src/entry-client.js\` upgrades it to a JSON fetch (no full-page reload).
|
|
914
|
+
Unknown posts (\`/blog/nope\`) return a real 404 and are never ISR-cached.
|
|
915
|
+
|
|
873
916
|
### ISR cheat-sheet
|
|
874
917
|
|
|
875
918
|
- \`page = { mode: 'static', revalidate: 60 }\` → cached, regenerated in the
|
|
876
|
-
background after 60s (stale-while-revalidate).
|
|
919
|
+
background after 60s (stale-while-revalidate). Check the \`X-What-Cache\`
|
|
920
|
+
response header: MISS on first hit, HIT after.
|
|
877
921
|
- \`page = { mode: 'server' }\` → always fresh, never cached.
|
|
878
922
|
- \`revalidatePath('/')\` / \`revalidateTag('posts')\` → on-demand purge.
|
|
879
923
|
- \`POST /__what_revalidate\` with \`WHAT_REVALIDATE_SECRET\` → CMS-triggered purge.
|
|
@@ -955,28 +999,44 @@ export const createPostAction = action(
|
|
|
955
999
|
);
|
|
956
1000
|
`);
|
|
957
1001
|
|
|
958
|
-
// Home page — static + ISR, lists posts via a loader
|
|
1002
|
+
// Home page — static + ISR, lists posts via a loader, plus a hydrated
|
|
1003
|
+
// counter that proves the client entry attaches interactivity.
|
|
959
1004
|
writeFileSync(resolve(root, 'src', 'pages', 'home.js'), `// Home page.
|
|
960
1005
|
// export const page -> route config (JSON-safe: mode, revalidate, tags)
|
|
961
1006
|
// export const loader -> runs on the server before render; result -> loaderData
|
|
962
1007
|
// export default -> the page component (runs once, no re-renders)
|
|
1008
|
+
//
|
|
1009
|
+
// This module is isomorphic: the server renders it to HTML, then
|
|
1010
|
+
// src/entry-client.js hydrates the same component in the browser (reusing the
|
|
1011
|
+
// server DOM and attaching the onclick handler + reactive text below).
|
|
1012
|
+
// Server-only code (the database) is imported lazily INSIDE the loader, which
|
|
1013
|
+
// only ever runs on the server — so the browser never requests /src/db.js
|
|
1014
|
+
// (and server.js refuses to serve it anyway).
|
|
963
1015
|
|
|
964
|
-
import { h, Head, useLoaderData } from 'what-framework';
|
|
965
|
-
import { listPosts } from '../db.js';
|
|
1016
|
+
import { h, Head, signal, useLoaderData } from 'what-framework';
|
|
966
1017
|
|
|
967
1018
|
export const page = { mode: 'static', revalidate: 60, tags: ['posts'] };
|
|
968
1019
|
|
|
969
|
-
export const loader = () =>
|
|
1020
|
+
export const loader = async () => {
|
|
1021
|
+
const { listPosts } = await import('../db.js'); // server-only
|
|
1022
|
+
return { posts: listPosts() };
|
|
1023
|
+
};
|
|
970
1024
|
|
|
971
1025
|
export default function Home() {
|
|
972
1026
|
const { posts } = useLoaderData();
|
|
1027
|
+
const likes = signal(0, 'likes');
|
|
973
1028
|
return h('main', { class: 'container' },
|
|
974
1029
|
h(Head, {
|
|
975
1030
|
title: '${packageName}',
|
|
976
1031
|
meta: [{ name: 'description', content: 'A full-stack app built with What Framework' }],
|
|
977
1032
|
}),
|
|
978
1033
|
h('h1', {}, '${packageName}'),
|
|
979
|
-
h('p', {}, 'Server-rendered, ISR-cached, hydrated
|
|
1034
|
+
h('p', {}, 'Server-rendered, ISR-cached, hydrated on the client.'),
|
|
1035
|
+
h('section', { class: 'like-demo' },
|
|
1036
|
+
h('button', { onclick: () => likes(n => n + 1) }, 'Like'),
|
|
1037
|
+
h('output', {}, () => String(likes())),
|
|
1038
|
+
h('span', { class: 'hint' }, 'hydrated — this button works in the browser')
|
|
1039
|
+
),
|
|
980
1040
|
h('ul', {}, posts.map((p) =>
|
|
981
1041
|
h('li', { key: p.slug }, h('a', { href: \`/blog/\${p.slug}\` }, p.title))
|
|
982
1042
|
)),
|
|
@@ -988,15 +1048,22 @@ export default function Home() {
|
|
|
988
1048
|
// Dynamic /blog/[slug] — loader + getStaticPaths + ISR + per-post <Head>.
|
|
989
1049
|
writeFileSync(resolve(root, 'src', 'pages', 'post.js'), `// Dynamic post page. getStaticPaths pre-renders known slugs at build; unknown
|
|
990
1050
|
// slugs render on first hit (fallback: 'blocking') and are then cached.
|
|
1051
|
+
// The database import lives inside the loader/getStaticPaths (server-only),
|
|
1052
|
+
// keeping /src/db.js out of the browser. A missing post sets notFound: true,
|
|
1053
|
+
// which server.js turns into a real 404 status that is never ISR-cached.
|
|
991
1054
|
|
|
992
1055
|
import { h, Head, useLoaderData } from 'what-framework';
|
|
993
|
-
import { getPost, listPosts } from '../db.js';
|
|
994
1056
|
|
|
995
1057
|
export const page = { mode: 'static', revalidate: 60, tags: ['posts'] };
|
|
996
1058
|
|
|
997
|
-
export const loader = ({ params }) =>
|
|
1059
|
+
export const loader = async ({ params }) => {
|
|
1060
|
+
const { getPost } = await import('../db.js'); // server-only
|
|
1061
|
+
const post = getPost(params.slug);
|
|
1062
|
+
return { post, notFound: !post };
|
|
1063
|
+
};
|
|
998
1064
|
|
|
999
1065
|
export async function getStaticPaths() {
|
|
1066
|
+
const { listPosts } = await import('../db.js'); // server-only
|
|
1000
1067
|
return {
|
|
1001
1068
|
paths: listPosts().map((p) => ({ params: { slug: p.slug } })),
|
|
1002
1069
|
fallback: 'blocking',
|
|
@@ -1018,18 +1085,42 @@ export default function Post() {
|
|
|
1018
1085
|
`);
|
|
1019
1086
|
|
|
1020
1087
|
// /new — mode:'server' (never cached), posts to the createPost action.
|
|
1021
|
-
writeFileSync(resolve(root, 'src', 'pages', 'new.js'), `// New-post form. mode:'server' so it's always fresh
|
|
1022
|
-
//
|
|
1088
|
+
writeFileSync(resolve(root, 'src', 'pages', 'new.js'), `// New-post form. mode:'server' so it's always fresh — which also means the
|
|
1089
|
+
// per-user CSRF token can be SSR'd into the form (never into shared cache).
|
|
1090
|
+
//
|
|
1091
|
+
// The form works two ways:
|
|
1092
|
+
// - No JS: a plain form-encoded POST to /__what_action. The hidden fields
|
|
1093
|
+
// carry the action id (_action), the double-submit CSRF token
|
|
1094
|
+
// (what-csrf-token) and the post-submit redirect (_redirect).
|
|
1095
|
+
// - With JS: src/entry-client.js intercepts submit and posts JSON with the
|
|
1096
|
+
// X-What-Action header (the action protocol), then navigates to the post.
|
|
1023
1097
|
|
|
1024
|
-
import { h, Head } from 'what-framework';
|
|
1098
|
+
import { h, Head, useLoaderData } from 'what-framework';
|
|
1025
1099
|
|
|
1026
1100
|
export const page = { mode: 'server' };
|
|
1027
1101
|
|
|
1102
|
+
// server.js passes the per-request CSRF token (the same one the framework
|
|
1103
|
+
// Set-Cookies and embeds as the <meta> tag) into loaders; fall back to the
|
|
1104
|
+
// visitor's existing cookie. Rendered into the hidden field below so the
|
|
1105
|
+
// no-JS form post passes the double-submit check.
|
|
1106
|
+
export const loader = ({ request, csrfToken }) => {
|
|
1107
|
+
if (csrfToken) return { csrfToken };
|
|
1108
|
+
const cookie = request && request.headers && typeof request.headers.get === 'function'
|
|
1109
|
+
? request.headers.get('cookie')
|
|
1110
|
+
: '';
|
|
1111
|
+
const match = cookie ? cookie.match(/(?:^|;\\s*)what-csrf=([^;]+)/) : null;
|
|
1112
|
+
return { csrfToken: match ? decodeURIComponent(match[1]) : '' };
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1028
1115
|
export default function NewPost() {
|
|
1116
|
+
const { csrfToken } = useLoaderData();
|
|
1029
1117
|
return h('main', { class: 'container' },
|
|
1030
1118
|
h(Head, { title: 'New post — ${packageName}' }),
|
|
1031
1119
|
h('h1', {}, 'New post'),
|
|
1032
1120
|
h('form', { method: 'post', action: '/__what_action', 'data-action': 'createPost' },
|
|
1121
|
+
h('input', { type: 'hidden', name: '_action', value: 'createPost' }),
|
|
1122
|
+
h('input', { type: 'hidden', name: 'what-csrf-token', value: csrfToken || '' }),
|
|
1123
|
+
h('input', { type: 'hidden', name: '_redirect', value: '/' }),
|
|
1033
1124
|
h('input', { name: 'title', placeholder: 'Title', required: true }),
|
|
1034
1125
|
h('textarea', { name: 'body', placeholder: 'Write something…', required: true }),
|
|
1035
1126
|
h('button', { type: 'submit' }, 'Publish')
|
|
@@ -1046,7 +1137,7 @@ export default function NewPost() {
|
|
|
1046
1137
|
|
|
1047
1138
|
import Home, { loader as homeLoader, page as homePage } from './pages/home.js';
|
|
1048
1139
|
import Post, { loader as postLoader, getStaticPaths as postPaths, page as postPage } from './pages/post.js';
|
|
1049
|
-
import NewPost, { page as newPage } from './pages/new.js';
|
|
1140
|
+
import NewPost, { loader as newLoader, page as newPage } from './pages/new.js';
|
|
1050
1141
|
|
|
1051
1142
|
// Importing the action registers it so /__what_action can dispatch it.
|
|
1052
1143
|
import './actions/posts.js';
|
|
@@ -1054,53 +1145,334 @@ import './actions/posts.js';
|
|
|
1054
1145
|
export const routes = [
|
|
1055
1146
|
{ path: '/', component: Home, loader: homeLoader, page: homePage, mode: homePage.mode },
|
|
1056
1147
|
{ path: '/blog/:slug', component: Post, loader: postLoader, getStaticPaths: postPaths, page: postPage, mode: postPage.mode },
|
|
1057
|
-
{ path: '/new', component: NewPost, page: newPage, mode: newPage.mode },
|
|
1148
|
+
{ path: '/new', component: NewPost, loader: newLoader, page: newPage, mode: newPage.mode },
|
|
1058
1149
|
];
|
|
1059
1150
|
`);
|
|
1060
1151
|
|
|
1061
|
-
//
|
|
1062
|
-
//
|
|
1063
|
-
|
|
1064
|
-
//
|
|
1065
|
-
|
|
1152
|
+
// Client hydration entry — loaded by the SSR document (see server.js). Reuses
|
|
1153
|
+
// the server-rendered DOM and attaches interactivity. No bundler involved:
|
|
1154
|
+
// the browser resolves 'what-framework' through the import map server.js
|
|
1155
|
+
// injects, and ./pages/* are served as plain ES modules.
|
|
1156
|
+
writeFileSync(resolve(root, 'src', 'entry-client.js'), `// Client hydration entry.
|
|
1157
|
+
// The SSR document loads this as a native ES module. It matches the current
|
|
1158
|
+
// URL to a page component, re-runs it with the server's loader data (pages
|
|
1159
|
+
// call useLoaderData(), which reads the #__what_data payload the server
|
|
1160
|
+
// embedded), and hydrate() walks the existing server-rendered DOM — attaching
|
|
1161
|
+
// event handlers and reactive bindings instead of recreating elements.
|
|
1162
|
+
//
|
|
1163
|
+
// NOTE: import page modules directly, never ./routes.js — the route table also
|
|
1164
|
+
// imports the server actions, which pull in Node-only modules.
|
|
1165
|
+
|
|
1166
|
+
import { h, hydrate } from 'what-framework';
|
|
1167
|
+
import Home from './pages/home.js';
|
|
1168
|
+
import Post from './pages/post.js';
|
|
1169
|
+
import NewPost from './pages/new.js';
|
|
1170
|
+
|
|
1171
|
+
function matchPage(pathname) {
|
|
1172
|
+
if (pathname === '/') return h(Home, {});
|
|
1173
|
+
const post = pathname.match(/^\\/blog\\/([^/]+)\\/?$/);
|
|
1174
|
+
if (post) return h(Post, { slug: decodeURIComponent(post[1]) });
|
|
1175
|
+
if (pathname === '/new') return h(NewPost, {});
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const vnode = matchPage(location.pathname);
|
|
1180
|
+
if (vnode) hydrate(vnode, document.body);
|
|
1181
|
+
|
|
1182
|
+
// Progressive enhancement for server-action forms: submit as JSON to
|
|
1183
|
+
// /__what_action with the X-What-Action header (the served-action protocol),
|
|
1184
|
+
// then navigate to the result. The CSRF token comes from the double-submit
|
|
1185
|
+
// cookie the server sets on every HTML response (or the meta tag on
|
|
1186
|
+
// uncached pages) — same lookup the framework's own action() client uses.
|
|
1187
|
+
function csrfToken() {
|
|
1188
|
+
const meta = document.querySelector('meta[name="what-csrf-token"]');
|
|
1189
|
+
if (meta) return meta.getAttribute('content');
|
|
1190
|
+
const match = document.cookie.match(/(?:^|;\\s*)what-csrf=([^;]+)/);
|
|
1191
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
for (const form of document.querySelectorAll('form[data-action]')) {
|
|
1195
|
+
form.addEventListener('submit', async (event) => {
|
|
1196
|
+
event.preventDefault();
|
|
1197
|
+
const args = Object.fromEntries(new FormData(form));
|
|
1198
|
+
// The hidden fields exist for the no-JS form-post fallback; the framework
|
|
1199
|
+
// consumes them server-side on that path. Strip them from the JSON args.
|
|
1200
|
+
for (const k of ['_action', '_csrf', '_redirect', 'what-csrf-token', 'data-action']) delete args[k];
|
|
1201
|
+
const headers = { 'content-type': 'application/json', 'x-what-action': form.dataset.action };
|
|
1202
|
+
const token = csrfToken();
|
|
1203
|
+
if (token) headers['x-csrf-token'] = token;
|
|
1204
|
+
const res = await fetch('/__what_action', {
|
|
1205
|
+
method: 'POST',
|
|
1206
|
+
headers,
|
|
1207
|
+
credentials: 'same-origin',
|
|
1208
|
+
body: JSON.stringify({ args: [args] }),
|
|
1209
|
+
});
|
|
1210
|
+
const result = await res.json().catch(() => ({}));
|
|
1211
|
+
if (res.ok) {
|
|
1212
|
+
location.href = result.slug ? \`/blog/\${result.slug}\` : '/';
|
|
1213
|
+
} else {
|
|
1214
|
+
console.error('[what] action failed:', result.message || res.status);
|
|
1215
|
+
}
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
`);
|
|
1219
|
+
|
|
1220
|
+
// Stylesheet for the SSR pages (linked from the document head in server.js).
|
|
1221
|
+
writeFileSync(resolve(root, 'src', 'styles.css'), `:root {
|
|
1222
|
+
color-scheme: light;
|
|
1223
|
+
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
* {
|
|
1227
|
+
box-sizing: border-box;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
body {
|
|
1231
|
+
margin: 0;
|
|
1232
|
+
min-height: 100vh;
|
|
1233
|
+
background: #f4f6fb;
|
|
1234
|
+
color: #0f172a;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
.container {
|
|
1238
|
+
max-width: 640px;
|
|
1239
|
+
margin: 0 auto;
|
|
1240
|
+
padding: 2.5rem 1.25rem;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
a {
|
|
1244
|
+
color: #2563eb;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
button {
|
|
1248
|
+
border: 1px solid #9aa7bb;
|
|
1249
|
+
background: #ffffff;
|
|
1250
|
+
color: #0f172a;
|
|
1251
|
+
padding: 0.4rem 0.9rem;
|
|
1252
|
+
border-radius: 10px;
|
|
1253
|
+
cursor: pointer;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
button:hover {
|
|
1257
|
+
border-color: #2563eb;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
.like-demo {
|
|
1261
|
+
display: inline-flex;
|
|
1262
|
+
align-items: center;
|
|
1263
|
+
gap: 0.75rem;
|
|
1264
|
+
margin: 0.5rem 0 1rem;
|
|
1265
|
+
}
|
|
1066
1266
|
|
|
1067
|
-
|
|
1267
|
+
.like-demo output {
|
|
1268
|
+
min-width: 2ch;
|
|
1269
|
+
text-align: center;
|
|
1270
|
+
font-weight: 700;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
.like-demo .hint {
|
|
1274
|
+
color: #64748b;
|
|
1275
|
+
font-size: 0.875rem;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
form {
|
|
1279
|
+
display: grid;
|
|
1280
|
+
gap: 0.75rem;
|
|
1281
|
+
max-width: 420px;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
input,
|
|
1285
|
+
textarea {
|
|
1286
|
+
padding: 0.5rem 0.75rem;
|
|
1287
|
+
border: 1px solid #9aa7bb;
|
|
1288
|
+
border-radius: 10px;
|
|
1289
|
+
font: inherit;
|
|
1290
|
+
}
|
|
1291
|
+
`);
|
|
1292
|
+
|
|
1293
|
+
// Full-stack server: Node adapter + origin-first ISR + revalidate webhook +
|
|
1294
|
+
// poll scheduler + static serving for the buildless client (native ESM).
|
|
1295
|
+
writeFileSync(resolve(root, 'server.js'), `// Full-stack server. \`npm run dev\` (auto-restart) or \`node server.js\`
|
|
1296
|
+
// → http://localhost:3000. Wires the Node adapter + origin-first ISR engine +
|
|
1297
|
+
// on-demand revalidation webhook + poll scheduler. No CDN — and no bundler —
|
|
1298
|
+
// required: the client entry and the framework are served as native ES modules
|
|
1299
|
+
// (see the import map below).
|
|
1300
|
+
|
|
1301
|
+
import http from 'node:http';
|
|
1302
|
+
import { randomBytes } from 'node:crypto';
|
|
1303
|
+
import { existsSync, statSync, createReadStream } from 'node:fs';
|
|
1304
|
+
import { extname, join, resolve } from 'node:path';
|
|
1305
|
+
import { fileURLToPath } from 'node:url';
|
|
1306
|
+
import { createRequestHandler, renderDocument, toNodeListener } from 'what-framework/server';
|
|
1068
1307
|
import {
|
|
1069
1308
|
createCacheEngine,
|
|
1070
1309
|
createMemoryStore,
|
|
1071
1310
|
createRevalidateWebhook,
|
|
1072
1311
|
createScheduler,
|
|
1073
|
-
} from 'what-
|
|
1312
|
+
} from 'what-isr';
|
|
1074
1313
|
import { routes } from './src/routes.js';
|
|
1075
1314
|
|
|
1076
|
-
const
|
|
1315
|
+
const ROOT = fileURLToPath(new URL('.', import.meta.url));
|
|
1316
|
+
const isProd = process.env.NODE_ENV === 'production';
|
|
1317
|
+
|
|
1318
|
+
// Secret for the on-demand revalidation webhook (POST /__what_revalidate).
|
|
1319
|
+
// Required in production; auto-generated per run in dev so there is never a
|
|
1320
|
+
// shared default secret.
|
|
1321
|
+
let REVALIDATE_SECRET = process.env.WHAT_REVALIDATE_SECRET;
|
|
1322
|
+
if (!REVALIDATE_SECRET) {
|
|
1323
|
+
if (isProd) {
|
|
1324
|
+
console.error(
|
|
1325
|
+
'[${packageName}] WHAT_REVALIDATE_SECRET must be set in production.\\n' +
|
|
1326
|
+
\" Generate one: node -e \\\"console.log(require('node:crypto').randomBytes(24).toString('hex'))\\\"\"
|
|
1327
|
+
);
|
|
1328
|
+
process.exit(1);
|
|
1329
|
+
}
|
|
1330
|
+
REVALIDATE_SECRET = randomBytes(24).toString('hex');
|
|
1331
|
+
console.log(\`[dev] WHAT_REVALIDATE_SECRET not set — generated for this run: \${REVALIDATE_SECRET}\`);
|
|
1332
|
+
}
|
|
1077
1333
|
|
|
1078
1334
|
// Origin ISR cache. Swap createMemoryStore() for createFilesystemStore({dir})
|
|
1079
1335
|
// to survive restarts, or createRedisStore({client}) for multi-instance.
|
|
1080
|
-
|
|
1336
|
+
// \`render\` powers scheduled regeneration (renderRoute is defined below;
|
|
1337
|
+
// function declarations hoist, so the reference is valid here).
|
|
1338
|
+
const cache = createCacheEngine({ store: createMemoryStore(), render: renderRoute });
|
|
1339
|
+
|
|
1340
|
+
// Non-200 renders (soft-404s like /blog/nonexistent) must never be ISR-cached —
|
|
1341
|
+
// a cached "Not found" would shadow a post created later and poison SEO. The
|
|
1342
|
+
// engine skips storing them; this wrapper also drops any stored non-200 entry
|
|
1343
|
+
// and forces the response uncacheable (belt and suspenders).
|
|
1344
|
+
const appCache = {
|
|
1345
|
+
...cache,
|
|
1346
|
+
async handle(routeMatch, render) {
|
|
1347
|
+
let result = await cache.handle(routeMatch, render);
|
|
1348
|
+
if (result && result.status && result.status !== 200) {
|
|
1349
|
+
try { await cache.store.delete(cache.keyFor(routeMatch)); } catch { /* best effort */ }
|
|
1350
|
+
const headers = { ...(result.headers || {}) };
|
|
1351
|
+
delete headers['Cache-Control'];
|
|
1352
|
+
delete headers['cache-control'];
|
|
1353
|
+
headers['Cache-Control'] = 'private, no-store';
|
|
1354
|
+
result = { ...result, headers };
|
|
1355
|
+
}
|
|
1356
|
+
return result;
|
|
1357
|
+
},
|
|
1358
|
+
};
|
|
1081
1359
|
|
|
1082
1360
|
// Keep the home listing warm every 5 minutes regardless of traffic.
|
|
1083
1361
|
const scheduler = createScheduler(cache);
|
|
1084
|
-
scheduler.register(
|
|
1362
|
+
scheduler.register(
|
|
1363
|
+
{ path: '/', query: {}, params: {}, config: routes[0].page, route: routes[0] },
|
|
1364
|
+
{ intervalMs: 5 * 60 * 1000 }
|
|
1365
|
+
);
|
|
1366
|
+
|
|
1367
|
+
// The browser loads /src/entry-client.js as a native ES module; this import
|
|
1368
|
+
// map resolves its bare imports. Sources are served from node_modules below.
|
|
1369
|
+
const importMap = {
|
|
1370
|
+
imports: {
|
|
1371
|
+
'what-framework': '/node_modules/what-framework/src/index.js',
|
|
1372
|
+
'what-core': '/node_modules/what-core/src/index.js',
|
|
1373
|
+
},
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
const documentOptions = {
|
|
1377
|
+
clientEntry: '/src/entry-client.js',
|
|
1378
|
+
head:
|
|
1379
|
+
\`<script type="importmap">\${JSON.stringify(importMap)}</script>\` +
|
|
1380
|
+
'<link rel="stylesheet" href="/src/styles.css">' +
|
|
1381
|
+
'<link rel="icon" type="image/svg+xml" href="/favicon.svg">',
|
|
1382
|
+
};
|
|
1383
|
+
|
|
1384
|
+
// Render a matched route. Mirrors the framework default renderer, plus:
|
|
1385
|
+
// - passes csrfToken through to loaders (so /new can SSR the no-JS form token)
|
|
1386
|
+
// - honors a loader's \`notFound: true\` with a real 404 status (the appCache
|
|
1387
|
+
// wrapper above keeps those responses out of the ISR cache)
|
|
1388
|
+
async function renderRoute(routeMatch) {
|
|
1389
|
+
const { route, params, query, request, csrfToken } = routeMatch;
|
|
1390
|
+
const reqCtx = { params, query, request, csrfToken };
|
|
1391
|
+
const loaderData = typeof route.loader === 'function' ? await route.loader(reqCtx) : undefined;
|
|
1392
|
+
const notFound = !!(loaderData && loaderData.notFound);
|
|
1393
|
+
const pageModule = { default: route.component, loader: () => loaderData };
|
|
1394
|
+
const opts = csrfToken ? { ...documentOptions, csrfToken } : documentOptions;
|
|
1395
|
+
const html = await renderDocument(pageModule, reqCtx, opts);
|
|
1396
|
+
return {
|
|
1397
|
+
html,
|
|
1398
|
+
status: notFound ? 404 : 200,
|
|
1399
|
+
tags: (routeMatch.config && routeMatch.config.tags) || [],
|
|
1400
|
+
path: routeMatch.path,
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1085
1403
|
|
|
1086
1404
|
export function createHandler() {
|
|
1087
1405
|
return createRequestHandler({
|
|
1088
1406
|
routes,
|
|
1089
|
-
cache,
|
|
1407
|
+
cache: appCache,
|
|
1408
|
+
render: renderRoute,
|
|
1090
1409
|
revalidateWebhook: createRevalidateWebhook(cache, { secret: REVALIDATE_SECRET }),
|
|
1091
|
-
document:
|
|
1410
|
+
document: documentOptions,
|
|
1092
1411
|
});
|
|
1093
1412
|
}
|
|
1094
1413
|
|
|
1095
|
-
//
|
|
1414
|
+
// --- Static files (client entry, page modules, framework sources, public/) ---
|
|
1415
|
+
|
|
1416
|
+
const MIME = {
|
|
1417
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
1418
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
1419
|
+
'.css': 'text/css; charset=utf-8',
|
|
1420
|
+
'.json': 'application/json; charset=utf-8',
|
|
1421
|
+
'.svg': 'image/svg+xml',
|
|
1422
|
+
'.png': 'image/png',
|
|
1423
|
+
'.jpg': 'image/jpeg',
|
|
1424
|
+
'.ico': 'image/x-icon',
|
|
1425
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
1426
|
+
'.woff2': 'font/woff2',
|
|
1427
|
+
};
|
|
1428
|
+
|
|
1429
|
+
// Deny-by-default static serving: ONLY the allowlisted client files below are
|
|
1430
|
+
// served from the project root. Server-only modules — src/db.js, src/actions/**
|
|
1431
|
+
// and src/routes.js — are importable by Node but 404 over HTTP, so DB code and
|
|
1432
|
+
// mutation logic never leak to the browser. When you add new client-side files
|
|
1433
|
+
// outside these locations, add them here explicitly.
|
|
1434
|
+
const SERVED_FILES = new Set(['/src/entry-client.js', '/src/styles.css']);
|
|
1435
|
+
const SERVED_PREFIXES = [
|
|
1436
|
+
'/src/pages/', // page modules (hydrated by entry-client)
|
|
1437
|
+
'/src/components/', // client-shared UI (create as needed)
|
|
1438
|
+
'/node_modules/what-framework/',
|
|
1439
|
+
'/node_modules/what-core/',
|
|
1440
|
+
];
|
|
1441
|
+
|
|
1442
|
+
function resolveStaticFile(pathname) {
|
|
1443
|
+
if (pathname.includes('..') || pathname.includes('\0')) return null;
|
|
1444
|
+
const allowed = SERVED_FILES.has(pathname) || SERVED_PREFIXES.some((p) => pathname.startsWith(p));
|
|
1445
|
+
const file = allowed
|
|
1446
|
+
? resolve(join(ROOT, pathname))
|
|
1447
|
+
: resolve(join(ROOT, 'public', pathname));
|
|
1448
|
+
if (!file.startsWith(resolve(ROOT))) return null;
|
|
1449
|
+
if (!existsSync(file) || !statSync(file).isFile()) return null;
|
|
1450
|
+
return file;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// --- Start (node server.js / npm run dev), not when imported by tests ---
|
|
1454
|
+
|
|
1096
1455
|
if (import.meta.url === \`file://\${process.argv[1]}\`) {
|
|
1097
|
-
const
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1456
|
+
const app = toNodeListener(createHandler());
|
|
1457
|
+
|
|
1458
|
+
const server = http.createServer((req, res) => {
|
|
1459
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
1460
|
+
const pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
|
|
1461
|
+
const file = resolveStaticFile(pathname);
|
|
1462
|
+
if (file) {
|
|
1463
|
+
res.writeHead(200, { 'content-type': MIME[extname(file)] || 'application/octet-stream' });
|
|
1464
|
+
if (req.method === 'HEAD') return res.end();
|
|
1465
|
+
return createReadStream(file).pipe(res);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
app(req, res);
|
|
1103
1469
|
});
|
|
1470
|
+
|
|
1471
|
+
scheduler.start();
|
|
1472
|
+
const stop = () => { try { scheduler.stop(); } catch {} server.close(); };
|
|
1473
|
+
process.once('SIGTERM', stop);
|
|
1474
|
+
process.once('SIGINT', stop);
|
|
1475
|
+
|
|
1104
1476
|
const port = Number(process.env.PORT) || 3000;
|
|
1105
1477
|
server.listen(port, () => console.log(\`${packageName} → http://localhost:\${port}\`));
|
|
1106
1478
|
}
|
|
@@ -1357,8 +1729,10 @@ count(c => c + 1) // update
|
|
|
1357
1729
|
// package.json
|
|
1358
1730
|
writeFileSync(resolve(root, 'package.json'), generatePackageJson(packageName, options));
|
|
1359
1731
|
|
|
1360
|
-
// index.html
|
|
1361
|
-
|
|
1732
|
+
// index.html (SPA only — the full-stack server renders its own document)
|
|
1733
|
+
if (options.template !== 'fullstack') {
|
|
1734
|
+
writeFileSync(resolve(root, 'index.html'), generateIndexHtml(packageName));
|
|
1735
|
+
}
|
|
1362
1736
|
|
|
1363
1737
|
// favicon
|
|
1364
1738
|
writeFileSync(resolve(root, 'public', 'favicon.svg'), `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
@@ -1373,8 +1747,35 @@ count(c => c + 1) // update
|
|
|
1373
1747
|
</svg>
|
|
1374
1748
|
`);
|
|
1375
1749
|
|
|
1376
|
-
// vite.config.js
|
|
1377
|
-
|
|
1750
|
+
// vite.config.js (SPA only — the full-stack template is buildless)
|
|
1751
|
+
if (options.template !== 'fullstack') {
|
|
1752
|
+
writeFileSync(resolve(root, 'vite.config.js'), generateViteConfig(options));
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// eslint.config.js — eslint-plugin-what flat config.
|
|
1756
|
+
// SPA: the What compiler handles event casing + reactive wrapping, so use
|
|
1757
|
+
// the `compiler` preset (the compiler-covered rules are off).
|
|
1758
|
+
// Fullstack: buildless h() authoring is the template's design, so use
|
|
1759
|
+
// `recommended` with the JSX-only h() rule disabled.
|
|
1760
|
+
writeFileSync(resolve(root, 'eslint.config.js'), options.template === 'fullstack'
|
|
1761
|
+
? `import what from 'eslint-plugin-what';
|
|
1762
|
+
|
|
1763
|
+
export default [
|
|
1764
|
+
{ ignores: ['node_modules'] },
|
|
1765
|
+
what.configs.recommended,
|
|
1766
|
+
// This template authors pages with h() on purpose (buildless, no compiler).
|
|
1767
|
+
{ rules: { 'what/no-h-in-user-code': 'off' } },
|
|
1768
|
+
];
|
|
1769
|
+
`
|
|
1770
|
+
: `import what from 'eslint-plugin-what';
|
|
1771
|
+
|
|
1772
|
+
export default [
|
|
1773
|
+
{ ignores: ['dist', 'node_modules'] },
|
|
1774
|
+
// 'compiler' preset: this project uses the What compiler (via Vite), which
|
|
1775
|
+
// handles event casing and reactive JSX wrapping automatically.
|
|
1776
|
+
what.configs.compiler,
|
|
1777
|
+
];
|
|
1778
|
+
`);
|
|
1378
1779
|
|
|
1379
1780
|
// tsconfig.json
|
|
1380
1781
|
writeFileSync(resolve(root, 'tsconfig.json'), JSON.stringify({
|
|
@@ -1408,19 +1809,20 @@ count(c => c + 1) // update
|
|
|
1408
1809
|
recommendations: [],
|
|
1409
1810
|
}, null, 2) + '\n');
|
|
1410
1811
|
|
|
1411
|
-
//
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
// Full-stack scaffold: file-routed SSR pages + server + ISR config.
|
|
1812
|
+
// Full-stack scaffold: file-routed SSR pages + server + client entry + ISR
|
|
1813
|
+
// config (writes its own src/styles.css). SPA scaffold: Vite entry + styles.
|
|
1415
1814
|
if (options.template === 'fullstack') {
|
|
1416
1815
|
generateFullstackFiles(root, packageName);
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
// src/styles.css
|
|
1420
|
-
if (reactCompat && cssApproach === 'none') {
|
|
1421
|
-
writeFileSync(resolve(root, 'src', 'styles.css'), generateStylesWithReactCompat());
|
|
1422
1816
|
} else {
|
|
1423
|
-
|
|
1817
|
+
// src/main.jsx
|
|
1818
|
+
writeFileSync(resolve(root, 'src', 'main.jsx'), generateMainJsx(options));
|
|
1819
|
+
|
|
1820
|
+
// src/styles.css
|
|
1821
|
+
if (reactCompat && cssApproach === 'none') {
|
|
1822
|
+
writeFileSync(resolve(root, 'src', 'styles.css'), generateStylesWithReactCompat());
|
|
1823
|
+
} else {
|
|
1824
|
+
writeFileSync(resolve(root, 'src', 'styles.css'), generateStyles(options));
|
|
1825
|
+
}
|
|
1424
1826
|
}
|
|
1425
1827
|
|
|
1426
1828
|
// README.md
|
|
@@ -1440,7 +1842,11 @@ count(c => c + 1) // update
|
|
|
1440
1842
|
console.log('\nNext steps:');
|
|
1441
1843
|
console.log(` cd ${root}`);
|
|
1442
1844
|
console.log(' npm install');
|
|
1443
|
-
|
|
1845
|
+
if (options.template === 'fullstack') {
|
|
1846
|
+
console.log(' npm run dev # SSR + ISR server → http://localhost:3000\n');
|
|
1847
|
+
} else {
|
|
1848
|
+
console.log(' npm run dev\n');
|
|
1849
|
+
}
|
|
1444
1850
|
}
|
|
1445
1851
|
|
|
1446
1852
|
main().catch(err => {
|