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.
Files changed (3) hide show
  1. package/README.md +52 -3
  2. package/index.js +476 -70
  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
@@ -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
- reactCompat = await prompter.confirm('Add React library support? (what-react)');
135
-
136
- cssApproach = await prompter.select('CSS approach:', [
137
- { label: 'None (vanilla CSS)', value: 'none' },
138
- { label: 'Tailwind CSS v4', value: 'tailwind' },
139
- { label: 'StyleX', value: 'stylex' },
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 add the origin-first ISR engine + a Node server entry.
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: 'vite', build: 'vite build', start: 'node server.js', preview: 'vite preview' }
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-cache'] = whatVersionRange;
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 = `- Canonical package name is \`what-framework\`.
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 + on-demand revalidation +
852
- poll regeneration) — works on any host, no CDN required.
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 start # full-stack SSR server → http://localhost:3000
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
- Or develop the client in isolation with \`npm run dev\` (Vite, http://localhost:5173).
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
- - \`server.js\` — Node adapter + ISR engine + revalidate webhook + poll scheduler.
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 = () => ({ posts: listPosts() });
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 islands.'),
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 }) => ({ post: getPost(params.slug) });
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. The form posts to the
1022
- // createPost server action; progressively enhanced (works as a plain POST).
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
- // Full-stack server: Node adapter + origin-first ISR + revalidate webhook +
1062
- // poll scheduler. Works on any host no CDN required.
1063
- writeFileSync(resolve(root, 'server.js'), `// Full-stack server. \`node server.js\` → http://localhost:3000.
1064
- // Wires the Node adapter + origin-first ISR engine + on-demand revalidation
1065
- // webhook + poll scheduler. No CDN required.
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
- import { createServer, createRequestHandler } from 'what-framework/server';
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-cache';
1312
+ } from 'what-isr';
1074
1313
  import { routes } from './src/routes.js';
1075
1314
 
1076
- const REVALIDATE_SECRET = process.env.WHAT_REVALIDATE_SECRET || 'dev-secret';
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
- const cache = createCacheEngine({ store: createMemoryStore() });
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({ path: '/', query: {}, config: routes[0].page }, { intervalMs: 5 * 60 * 1000 });
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: { clientEntry: '/src/entry-client.js' },
1410
+ document: documentOptions,
1092
1411
  });
1093
1412
  }
1094
1413
 
1095
- // Started directly (node server.js), not when imported by tests.
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 server = createServer({
1098
- routes,
1099
- cache,
1100
- scheduler,
1101
- revalidateWebhook: createRevalidateWebhook(cache, { secret: REVALIDATE_SECRET }),
1102
- document: { clientEntry: '/src/entry-client.js' },
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
- writeFileSync(resolve(root, 'index.html'), generateIndexHtml(packageName));
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
- writeFileSync(resolve(root, 'vite.config.js'), generateViteConfig(options));
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
- // src/main.jsx
1412
- writeFileSync(resolve(root, 'src', 'main.jsx'), generateMainJsx(options));
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
- writeFileSync(resolve(root, 'src', 'styles.css'), generateStyles(options));
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
- console.log(' npm run dev\n');
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 => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-what",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Scaffold a new What Framework project",
5
5
  "type": "module",
6
6
  "bin": {