@webjsdev/cli 0.10.29 → 0.10.31
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/bin/webjs.js +1 -1
- package/lib/api-gallery.js +229 -0
- package/lib/create.js +452 -158
- package/lib/saas-template.js +39 -15
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +70 -2
- package/templates/.claude/hooks/check-server-imports.mjs +86 -0
- package/templates/.claude/hooks/check-server-imports.sh +26 -0
- package/templates/.claude/settings.json +9 -0
- package/templates/.cursorrules +41 -2
- package/templates/.github/copilot-instructions.md +41 -2
- package/templates/AGENTS.md +160 -10
- package/templates/CONVENTIONS.md +150 -11
- package/templates/gallery/app/examples/todo/page.ts +34 -0
- package/templates/gallery/app/features/async-render/page.ts +14 -0
- package/templates/gallery/app/features/broadcast/feed/route.ts +19 -0
- package/templates/gallery/app/features/broadcast/page.ts +24 -0
- package/templates/gallery/app/features/caching/page.ts +39 -0
- package/templates/gallery/app/features/client-router/page.ts +34 -0
- package/templates/gallery/app/features/client-router/second/page.ts +20 -0
- package/templates/gallery/app/features/components/page.ts +14 -0
- package/templates/gallery/app/features/directives/page.ts +14 -0
- package/templates/gallery/app/features/env/page.ts +36 -0
- package/templates/gallery/app/features/file-storage/file/[key]/route.ts +19 -0
- package/templates/gallery/app/features/file-storage/page.ts +62 -0
- package/templates/gallery/app/features/forms/page.ts +72 -0
- package/templates/gallery/app/features/metadata/page.ts +55 -0
- package/templates/gallery/app/features/optimistic-ui/page.ts +14 -0
- package/templates/gallery/app/features/rate-limit/page.ts +29 -0
- package/templates/gallery/app/features/rate-limit/ping/middleware.ts +8 -0
- package/templates/gallery/app/features/rate-limit/ping/route.ts +7 -0
- package/templates/gallery/app/features/route-handler/data/route.ts +8 -0
- package/templates/gallery/app/features/route-handler/page.ts +13 -0
- package/templates/gallery/app/features/routing/[id]/page.ts +25 -0
- package/templates/gallery/app/features/routing/page.ts +47 -0
- package/templates/gallery/app/features/server-actions/page.ts +14 -0
- package/templates/gallery/app/features/service-worker/page.ts +35 -0
- package/templates/gallery/app/features/websockets/echo/route.ts +19 -0
- package/templates/gallery/app/features/websockets/page.ts +25 -0
- package/templates/gallery/modules/async-render/components/server-clock.ts +26 -0
- package/templates/gallery/modules/async-render/queries/server-greeting.server.ts +9 -0
- package/templates/gallery/modules/broadcast/components/broadcast-feed.ts +61 -0
- package/templates/gallery/modules/components/components/counter-card.ts +35 -0
- package/templates/gallery/modules/directives/components/directive-demo.ts +53 -0
- package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +18 -0
- package/templates/gallery/modules/optimistic-ui/actions/like-post.server.ts +9 -0
- package/templates/gallery/modules/optimistic-ui/components/like-button.ts +35 -0
- package/templates/gallery/modules/rate-limit/components/rate-probe.ts +49 -0
- package/templates/gallery/modules/server-actions/actions/greet.server.ts +12 -0
- package/templates/gallery/modules/server-actions/components/greeter.ts +30 -0
- package/templates/gallery/modules/server-actions/utils/format.server.ts +8 -0
- package/templates/gallery/modules/todo/actions/create-todo.server.ts +16 -0
- package/templates/gallery/modules/todo/actions/delete-todo.server.ts +15 -0
- package/templates/gallery/modules/todo/actions/toggle-todo.server.ts +21 -0
- package/templates/gallery/modules/todo/components/todo-app.ts +140 -0
- package/templates/gallery/modules/todo/queries/list-todos.server.ts +19 -0
- package/templates/gallery/modules/todo/types.ts +12 -0
- package/templates/gallery/modules/websockets/components/ws-echo.ts +62 -0
- package/templates/lib/utils/ui.ts +4 -4
- package/templates/test/hello/browser/hello.test.js +12 -6
- package/templates/test/hello/e2e/hello.test.ts +11 -9
- package/templates/test/hello/hello.test.ts +4 -6
- package/templates/web-test-runner.config.js +84 -9
package/lib/create.js
CHANGED
|
@@ -101,7 +101,10 @@ async function readUiComponent(name) {
|
|
|
101
101
|
// it to the scaffolded app's aliased path (cn lives at lib/utils/cn.ts).
|
|
102
102
|
return raw
|
|
103
103
|
.replaceAll("'../lib/utils.ts'", "'#lib/utils/cn.ts'")
|
|
104
|
-
.replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"')
|
|
104
|
+
.replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"')
|
|
105
|
+
// onBeforeCache lives in its own client-only module so cn() stays pure (#819).
|
|
106
|
+
.replaceAll("'../lib/dom.ts'", "'#lib/utils/dom.ts'")
|
|
107
|
+
.replaceAll('"../lib/dom.ts"', '"#lib/utils/dom.ts"');
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
/**
|
|
@@ -131,6 +134,28 @@ async function copyUiComponents(appDir, names) {
|
|
|
131
134
|
}
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Copy the example gallery (idiomatic, densely-commented working examples) into
|
|
139
|
+
* the scaffolded app. Merges `templates/gallery/{app,modules}` over the app so
|
|
140
|
+
* single-feature demos land under `app/features/<name>/`, whole example apps
|
|
141
|
+
* under `app/examples/<name>/`, and their logic under `modules/<name>/`, the
|
|
142
|
+
* app-thin + modules-logic split webjs prescribes.
|
|
143
|
+
*
|
|
144
|
+
* Ships verbatim (no `{{APP_NAME}}` substitution): the examples are self-
|
|
145
|
+
* contained and reference only `@webjsdev/*`, drizzle, `#db/*`, and each other.
|
|
146
|
+
* The scaffold's own `app/page.ts` / `app/layout.ts` are written AFTER this and
|
|
147
|
+
* the gallery ships neither, so there is no clobber. `cp` merges into existing
|
|
148
|
+
* `app/` and `modules/` dirs rather than replacing them.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} appDir
|
|
151
|
+
*/
|
|
152
|
+
async function copyGallery(appDir) {
|
|
153
|
+
const galleryDir = join(TEMPLATES, 'gallery');
|
|
154
|
+
for (const sub of ['app', 'modules']) {
|
|
155
|
+
await cp(join(galleryDir, sub), join(appDir, sub), { recursive: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
134
159
|
/**
|
|
135
160
|
* Write `lib/utils/cn.ts` (the `cn()` helper) and `components.json` so the
|
|
136
161
|
* scaffolded app is pre-initialised for `webjs ui add`. Reads the registry's
|
|
@@ -144,21 +169,31 @@ async function writeUiBootstrap(appDir) {
|
|
|
144
169
|
// Caller (scaffoldApp) has already invoked assertUiRegistryAvailable(),
|
|
145
170
|
// so the source files below are guaranteed to exist.
|
|
146
171
|
|
|
147
|
-
// 1) lib/utils/cn.ts: the cn() helper
|
|
172
|
+
// 1) lib/utils/cn.ts: the cn() helper (pure; safe to import into a page).
|
|
148
173
|
const utilsContent = await readFile(
|
|
149
174
|
join(UI_REGISTRY_ROOT, 'lib', 'utils.ts'), 'utf8',
|
|
150
175
|
);
|
|
151
176
|
await mkdir(join(appDir, 'lib', 'utils'), { recursive: true });
|
|
152
177
|
await writeFile(join(appDir, 'lib', 'utils', 'cn.ts'), utilsContent);
|
|
153
178
|
|
|
179
|
+
// 1b) lib/utils/dom.ts: the client-only DOM helper (onBeforeCache). Split out
|
|
180
|
+
// of cn.ts so importing cn() does not pin a page to the browser (#819). Any
|
|
181
|
+
// `webjs ui add` component that uses onBeforeCache imports it from here.
|
|
182
|
+
const domContent = await readFile(
|
|
183
|
+
join(UI_REGISTRY_ROOT, 'lib', 'dom.ts'), 'utf8',
|
|
184
|
+
);
|
|
185
|
+
await writeFile(join(appDir, 'lib', 'utils', 'dom.ts'), domContent);
|
|
186
|
+
|
|
154
187
|
// 2) components.json: the same shape `webjsui init` writes for webjs
|
|
155
188
|
// projects (see packages/ui/src/utils/detect-project.js). The utils alias
|
|
156
189
|
// is lib/utils/cn so get-config.js's `+ '.ts'` resolves to lib/utils/cn.ts.
|
|
190
|
+
// The theme CSS lives at styles/globals.css, NOT app/globals.css: app/ is
|
|
191
|
+
// routing-only, so a non-routing stylesheet does not belong there.
|
|
157
192
|
const componentsJson = {
|
|
158
193
|
$schema: 'https://ui.webjs.dev/schema.json',
|
|
159
194
|
style: 'default',
|
|
160
195
|
tailwind: {
|
|
161
|
-
css: '
|
|
196
|
+
css: 'styles/globals.css',
|
|
162
197
|
baseColor: 'neutral',
|
|
163
198
|
cssVariables: true,
|
|
164
199
|
},
|
|
@@ -175,17 +210,20 @@ async function writeUiBootstrap(appDir) {
|
|
|
175
210
|
JSON.stringify(componentsJson, null, 2) + '\n',
|
|
176
211
|
);
|
|
177
212
|
|
|
178
|
-
// 3)
|
|
179
|
-
// references this path, and future `webjs ui add` calls append to it.
|
|
213
|
+
// 3) styles/globals.css: copy the neutral theme verbatim. components.json
|
|
214
|
+
// references this path, and future `webjs ui add` calls append to it. It
|
|
215
|
+
// lives OUTSIDE app/ because app/ is routing-only; the layout inlines the
|
|
216
|
+
// same tokens into a <style type="text/tailwindcss"> block so the browser
|
|
217
|
+
// Tailwind runtime resolves them with no build step.
|
|
180
218
|
const css = await readFile(
|
|
181
219
|
join(UI_REGISTRY_ROOT, 'themes', 'index.css'), 'utf8',
|
|
182
220
|
);
|
|
183
|
-
await mkdir(join(appDir, '
|
|
184
|
-
await writeFile(join(appDir, '
|
|
221
|
+
await mkdir(join(appDir, 'styles'), { recursive: true });
|
|
222
|
+
await writeFile(join(appDir, 'styles', 'globals.css'), css);
|
|
185
223
|
}
|
|
186
224
|
|
|
187
225
|
/**
|
|
188
|
-
* Read the
|
|
226
|
+
* Read the @webjsdev/ui theme CSS so we can inline it into the layout's
|
|
189
227
|
* `<style type="text/tailwindcss">` block. The Tailwind browser runtime
|
|
190
228
|
* picks up inline `<style type="text/tailwindcss">` content, so the theme
|
|
191
229
|
* tokens (`--color-primary`, `--color-card`, …) the registry components
|
|
@@ -200,7 +238,7 @@ async function readThemeCss() {
|
|
|
200
238
|
|
|
201
239
|
/**
|
|
202
240
|
* Fail loudly when the @webjsdev/ui registry is not on disk. The scaffold
|
|
203
|
-
* reads component sources, the cn() helper, and the
|
|
241
|
+
* reads component sources, the cn() helper, and the @webjsdev/ui theme from
|
|
204
242
|
* UI_REGISTRY_ROOT and weaves them into a generated app/page.ts that
|
|
205
243
|
* imports `components/ui/button.ts`. If the registry is missing, the
|
|
206
244
|
* generated app boots to ERR_MODULE_NOT_FOUND on first request, which is
|
|
@@ -236,6 +274,11 @@ function assertUiRegistryAvailable() {
|
|
|
236
274
|
*/
|
|
237
275
|
export async function scaffoldApp(name, cwd, opts = {}) {
|
|
238
276
|
const template = opts.template || 'full-stack';
|
|
277
|
+
// A human-friendly display title for the example home page. The npm `name`
|
|
278
|
+
// stays the raw slug (lowercase, hyphenated), but showing a hyphenated slug as
|
|
279
|
+
// a hero title looks unpolished, so title-case it for display ("my-app" ->
|
|
280
|
+
// "My App"). Replace this with your real brand anyway.
|
|
281
|
+
const displayName = name.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
239
282
|
// `install` is opt-in at the library level (so tests + programmatic
|
|
240
283
|
// callers get a side-effect-free scaffold by default). The CLI entry
|
|
241
284
|
// points (`webjs create` and `npx create-webjs-app`) explicitly set
|
|
@@ -251,6 +294,13 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
251
294
|
}
|
|
252
295
|
const isApi = template === 'api';
|
|
253
296
|
const isSaas = template === 'saas';
|
|
297
|
+
// The example gallery ships in every UI scaffold (full-stack AND saas). The
|
|
298
|
+
// copyGallery gate below is !isApi, since only the api template has no UI. saas
|
|
299
|
+
// overwrites db/schema.server.ts with its own schema (which includes the
|
|
300
|
+
// gallery's todos table) and renders the gallery below its auth landing.
|
|
301
|
+
// isFullStack distinguishes the plain full-stack app from saas for the parts
|
|
302
|
+
// that differ (its own home page and the create.js-written schema).
|
|
303
|
+
const isFullStack = !isApi && !isSaas;
|
|
254
304
|
|
|
255
305
|
// Database dialect (#563): sqlite (default) or postgres. Drizzle is the ORM;
|
|
256
306
|
// the schema/queries/actions are identical across dialects, only db/columns
|
|
@@ -381,9 +431,10 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
381
431
|
// tsserver plugin can't provide); tsserver dedupes by name, so loading
|
|
382
432
|
// it both ways is a no-op. Standalone, no Lit dependency. Editor-only.
|
|
383
433
|
'@webjsdev/intellisense': 'latest',
|
|
384
|
-
// NOTE: @webjsdev/ui is intentionally NOT pinned. The UI kit
|
|
385
|
-
// shadcn-
|
|
386
|
-
// into components/ui/ (they import
|
|
434
|
+
// NOTE: @webjsdev/ui is intentionally NOT pinned. The UI kit uses a
|
|
435
|
+
// copy-in model (shadcn-compatible conventions): `webjs ui add <name>`
|
|
436
|
+
// copies component source into components/ui/ (they import
|
|
437
|
+
// @webjsdev/core, not the kit), and the
|
|
387
438
|
// CLI resolves @webjsdev/ui from its own install.
|
|
388
439
|
},
|
|
389
440
|
// Dev + start task orchestration (#550). `webjs dev` / `webjs start` read
|
|
@@ -478,6 +529,8 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
478
529
|
'.claude/hooks/guard-branch-context.sh',
|
|
479
530
|
'.claude/hooks/nudge-uncommitted.sh',
|
|
480
531
|
'.claude/hooks/require-tests-with-src.sh',
|
|
532
|
+
'.claude/hooks/check-server-imports.sh',
|
|
533
|
+
'.claude/hooks/check-server-imports.mjs',
|
|
481
534
|
// Gemini CLI config + hooks
|
|
482
535
|
'.gemini/settings.json',
|
|
483
536
|
'.gemini/hooks/nudge-uncommitted.sh',
|
|
@@ -622,7 +675,7 @@ export const index = (...cols: PgColumn[]) =>
|
|
|
622
675
|
|
|
623
676
|
// Example schema (dialect-agnostic). Replace the User model with your own.
|
|
624
677
|
await writeFile(join(appDir, 'db', 'schema.server.ts'), `import { defineRelations } from 'drizzle-orm';
|
|
625
|
-
import { table, pk, text, createdAt } from './columns.server.ts';
|
|
678
|
+
import { table, pk, ${isFullStack ? 'uuidPk, ' : ''}text, ${isFullStack ? 'bool, ' : ''}createdAt } from './columns.server.ts';
|
|
626
679
|
|
|
627
680
|
// Example model. Feel free to delete or extend.
|
|
628
681
|
export const users = table('users', {
|
|
@@ -631,10 +684,19 @@ export const users = table('users', {
|
|
|
631
684
|
name: text(),
|
|
632
685
|
createdAt: createdAt(),
|
|
633
686
|
});
|
|
634
|
-
|
|
687
|
+
${isFullStack ? `
|
|
688
|
+
// Backs the example-gallery /examples/todo route (modules/todo). Delete it with
|
|
689
|
+
// the gallery when you prune the examples you do not use.
|
|
690
|
+
export const todos = table('todos', {
|
|
691
|
+
id: uuidPk(),
|
|
692
|
+
title: text().notNull(),
|
|
693
|
+
completed: bool().notNull().default(false),
|
|
694
|
+
createdAt: createdAt(),
|
|
695
|
+
});
|
|
696
|
+
` : ''}
|
|
635
697
|
// Relations live here (one defineRelations for the whole schema). Empty
|
|
636
698
|
// for now; add per-model relations as your schema grows.
|
|
637
|
-
export const relations = defineRelations({ users }, () => ({}));
|
|
699
|
+
export const relations = defineRelations({ users${isFullStack ? ', todos' : ''} }, () => ({}));
|
|
638
700
|
|
|
639
701
|
// Derived types, never hand-written.
|
|
640
702
|
export type User = typeof users.$inferSelect;
|
|
@@ -667,6 +729,7 @@ function tune<T extends { exec(sql: string): unknown }>(client: T): T {
|
|
|
667
729
|
|
|
668
730
|
async function open() {
|
|
669
731
|
if ((globalThis as { Bun?: unknown }).Bun) {
|
|
732
|
+
// @ts-expect-error bun:sqlite is a Bun builtin with no Node typings
|
|
670
733
|
const { Database } = await import('bun:sqlite');
|
|
671
734
|
const { drizzle } = await import('drizzle-orm/bun-sqlite');
|
|
672
735
|
return drizzle({ client: tune(new Database(url)), relations: schema.relations });
|
|
@@ -782,6 +845,28 @@ export default cors({
|
|
|
782
845
|
await writeFile(join(appDir, 'app', 'api', 'health', 'route.ts'), `export async function GET() {
|
|
783
846
|
return Response.json({ status: 'ok', timestamp: Date.now() });
|
|
784
847
|
}
|
|
848
|
+
`);
|
|
849
|
+
// Root API index. The api template has no UI, so \`/\` is a route handler,
|
|
850
|
+
// not a page: it lists the available endpoints instead of returning a bare
|
|
851
|
+
// 404 (friendlier than an empty root for an API-only app).
|
|
852
|
+
await writeFile(join(appDir, 'app', 'route.ts'), `export async function GET(request: Request) {
|
|
853
|
+
const base = new URL(request.url).origin;
|
|
854
|
+
return Response.json({
|
|
855
|
+
name: '${name}',
|
|
856
|
+
endpoints: {
|
|
857
|
+
health: \`\${base}/api/health\`,
|
|
858
|
+
users: \`\${base}/api/users\`,
|
|
859
|
+
},
|
|
860
|
+
// The backend-features showcase (delete app/api/features + its modules to prune).
|
|
861
|
+
features: {
|
|
862
|
+
validate: \`\${base}/api/features/validate\`,
|
|
863
|
+
'rate-limit': \`\${base}/api/features/rate-limit\`,
|
|
864
|
+
stream: \`\${base}/api/features/stream\`,
|
|
865
|
+
files: \`\${base}/api/features/files\`,
|
|
866
|
+
ws: \`\${base.replace(/^http/, 'ws')}/api/features/ws\`,
|
|
867
|
+
},
|
|
868
|
+
});
|
|
869
|
+
}
|
|
785
870
|
`);
|
|
786
871
|
await mkdir(join(appDir, 'modules', 'users', 'actions'), { recursive: true });
|
|
787
872
|
await mkdir(join(appDir, 'modules', 'users', 'queries'), { recursive: true });
|
|
@@ -864,6 +949,13 @@ export type ActionResult<T> =
|
|
|
864
949
|
| { success: true; data: T }
|
|
865
950
|
| { success: false; error: string; status: number };
|
|
866
951
|
`);
|
|
952
|
+
|
|
953
|
+
// The api backend-features showcase: endpoints under app/api/features/**
|
|
954
|
+
// demonstrating the server-side surface (route() adapter + validation, rate
|
|
955
|
+
// limiting, streaming, file storage, WebSockets + broadcast) plus env
|
|
956
|
+
// validation. The api counterpart of the UI gallery. Prune what you skip.
|
|
957
|
+
const { writeApiGallery } = await import('./api-gallery.js');
|
|
958
|
+
await writeApiGallery(appDir);
|
|
867
959
|
}
|
|
868
960
|
|
|
869
961
|
if (!isApi) {
|
|
@@ -904,7 +996,7 @@ export type ActionResult<T> =
|
|
|
904
996
|
|
|
905
997
|
// Pre-initialise @webjsdev/ui so the scaffold boots ready for
|
|
906
998
|
// `webjs ui add <name>`: writes components.json + lib/utils/cn.ts +
|
|
907
|
-
//
|
|
999
|
+
// styles/globals.css (the @webjsdev/ui theme).
|
|
908
1000
|
await writeUiBootstrap(appDir);
|
|
909
1001
|
|
|
910
1002
|
// Copy the standard ui-* component kit the scaffold's example pages
|
|
@@ -914,13 +1006,21 @@ export type ActionResult<T> =
|
|
|
914
1006
|
'button', 'card', 'alert', 'badge', 'separator', 'label', 'input',
|
|
915
1007
|
]);
|
|
916
1008
|
|
|
917
|
-
// The
|
|
1009
|
+
// The gallery: idiomatic, densely-commented single-feature demos under
|
|
1010
|
+
// app/features/ plus one whole example app under app/examples/, with logic
|
|
1011
|
+
// in modules/, linked from the home page. Shipped in the default full-stack
|
|
1012
|
+
// scaffold so an agent gains context by browsing real code; prune
|
|
1013
|
+
// per-feature (delete the route + its module) for what the app does not
|
|
1014
|
+
// use. See CONVENTIONS.md "prune what the app does not use".
|
|
1015
|
+
if (!isApi) await copyGallery(appDir);
|
|
1016
|
+
|
|
1017
|
+
// The @webjsdev/ui theme tokens (`--color-primary`, `--color-card`, …) the
|
|
918
1018
|
// ui-* components consume. We read the registry's themes/index.css at
|
|
919
1019
|
// create time and inline it into the layout's
|
|
920
1020
|
// `<style type="text/tailwindcss">` block so the Tailwind browser
|
|
921
|
-
// runtime picks it up. Same content also lives at
|
|
1021
|
+
// runtime picks it up. Same content also lives at styles/globals.css for
|
|
922
1022
|
// `webjsui` tooling.
|
|
923
|
-
const
|
|
1023
|
+
const UI_THEME = (await readThemeCss())
|
|
924
1024
|
// Escape backticks + ${} so the CSS survives interpolation into the
|
|
925
1025
|
// layout's template literal below.
|
|
926
1026
|
.replace(/\\/g, '\\\\')
|
|
@@ -947,7 +1047,7 @@ import '#components/theme-toggle.ts';
|
|
|
947
1047
|
*
|
|
948
1048
|
* Light DOM + Tailwind by default. Design tokens live in :root and are
|
|
949
1049
|
* mapped into the Tailwind palette via @theme, so classes like
|
|
950
|
-
* text-
|
|
1050
|
+
* text-foreground, bg-card, font-serif, duration-fast, text-display all work.
|
|
951
1051
|
*
|
|
952
1052
|
* Nav + footer links repeat the same class bundle, so they're extracted
|
|
953
1053
|
* into small JS helpers below. Each helper runs at SSR time inside
|
|
@@ -955,7 +1055,7 @@ import '#components/theme-toggle.ts';
|
|
|
955
1055
|
*/
|
|
956
1056
|
|
|
957
1057
|
const navLink = (href: string, label: string) => html\`
|
|
958
|
-
<a href=\${href} class="text-
|
|
1058
|
+
<a href=\${href} class="text-muted-foreground no-underline font-medium text-[13px] leading-none tracking-[0.005em] transition-colors duration-fast hover:text-foreground">\${label}</a>
|
|
959
1059
|
\`;
|
|
960
1060
|
|
|
961
1061
|
export default function RootLayout({ children }: { children: unknown }) {
|
|
@@ -975,7 +1075,7 @@ export default function RootLayout({ children }: { children: unknown }) {
|
|
|
975
1075
|
var el = document.documentElement;
|
|
976
1076
|
if (t === 'light' || t === 'dark') el.dataset.theme = t;
|
|
977
1077
|
else delete el.dataset.theme;
|
|
978
|
-
// Keep
|
|
1078
|
+
// Keep the .dark class the @webjsdev/ui kit uses in sync with the effective theme so the
|
|
979
1079
|
// copied ui-* components (button, card, etc.) follow light/dark too.
|
|
980
1080
|
// Dark is the default unless the OS prefers light or 'light' is set.
|
|
981
1081
|
var dark = t === 'dark' || (t !== 'light' && !mq.matches);
|
|
@@ -1010,27 +1110,124 @@ export default function RootLayout({ children }: { children: unknown }) {
|
|
|
1010
1110
|
<!--
|
|
1011
1111
|
Webjs UI theme. Design tokens (--color-primary,
|
|
1012
1112
|
--color-card, --radius, etc.) the ui-* components consume.
|
|
1013
|
-
The same content is also at
|
|
1113
|
+
The same content is also at styles/globals.css. We inline it here so
|
|
1014
1114
|
the Tailwind browser runtime resolves the tokens without a build step.
|
|
1015
1115
|
Edit base palette via the :root / .dark blocks below.
|
|
1016
1116
|
-->
|
|
1017
1117
|
<style type="text/tailwindcss">
|
|
1018
|
-
${
|
|
1118
|
+
${UI_THEME}
|
|
1019
1119
|
</style>
|
|
1020
1120
|
<style type="text/tailwindcss">
|
|
1021
|
-
@theme
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1121
|
+
/* ONE theme, canonical shadcn-style tokens. The @webjsdev/ui theme above
|
|
1122
|
+
provides the token STRUCTURE and the @theme inline mappings that generate
|
|
1123
|
+
bg-background, text-foreground, bg-card, bg-primary, bg-accent,
|
|
1124
|
+
text-muted-foreground, border-border, ring-ring, and the rest. Here we
|
|
1125
|
+
set those tokens' VALUES to this app's brand palette, so the ui-*
|
|
1126
|
+
components AND the example chrome read ONE source of truth. Any component
|
|
1127
|
+
added later with webjs ui add <name> inherits it automatically. This
|
|
1128
|
+
block is emitted after the ui theme, so these values win on every Tailwind
|
|
1129
|
+
recompile. Dark-first, with light via the theme toggle (data-theme) or the
|
|
1130
|
+
OS. Follows the shadcn model: --primary is the BRAND color (orange, used
|
|
1131
|
+
for primary buttons, links, and emphasis), while --accent stays a NEUTRAL
|
|
1132
|
+
hover tint so the ui kit's outline/ghost/dropdown hover states keep proper
|
|
1133
|
+
contrast. Use bg-primary / text-primary for brand, bg-accent for hovers.
|
|
1134
|
+
Add a new design token the canonical way, a --x variable below plus a
|
|
1135
|
+
--color-x: var(--x) line in the @theme inline block, then use it as bg-x /
|
|
1136
|
+
text-x. Reach for opacity modifiers
|
|
1137
|
+
(bg-primary/10, hover:bg-primary/90, text-muted-foreground/70) before
|
|
1138
|
+
inventing a new token. */
|
|
1139
|
+
:root {
|
|
1140
|
+
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1141
|
+
--font-serif: ui-serif, 'Iowan Old Style', Palatino, Georgia, serif;
|
|
1142
|
+
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1143
|
+
--header-h: 56px;
|
|
1144
|
+
/* A translucent brand tint, derived from --primary so it tracks
|
|
1145
|
+
light/dark automatically. Used for the logo glow and focus ring. */
|
|
1146
|
+
--primary-tint: color-mix(in oklch, var(--primary) 20%, transparent);
|
|
1147
|
+
}
|
|
1148
|
+
/* dark (the default, and the explicit .dark the toggle sets) */
|
|
1149
|
+
:root, .dark {
|
|
1150
|
+
color-scheme: dark;
|
|
1151
|
+
--background: oklch(0.14 0.01 55);
|
|
1152
|
+
--foreground: oklch(0.96 0.015 60);
|
|
1153
|
+
--card: oklch(0.18 0.01 55);
|
|
1154
|
+
--card-foreground: oklch(0.96 0.015 60);
|
|
1155
|
+
--popover: oklch(0.18 0.01 55);
|
|
1156
|
+
--popover-foreground: oklch(0.96 0.015 60);
|
|
1157
|
+
/* --primary is the orange BRAND color (shadcn model: primary = brand). */
|
|
1158
|
+
--primary: oklch(0.7 0.16 52);
|
|
1159
|
+
--primary-foreground: oklch(0.17 0.02 52);
|
|
1160
|
+
--secondary: oklch(0.22 0.01 55);
|
|
1161
|
+
--secondary-foreground: oklch(0.96 0.015 60);
|
|
1162
|
+
--muted: oklch(0.16 0.01 55);
|
|
1163
|
+
--muted-foreground: oklch(0.72 0.02 60);
|
|
1164
|
+
/* --accent stays a NEUTRAL hover tint (shadcn model), so the ui kit's
|
|
1165
|
+
outline/ghost/dropdown hover states keep proper contrast. */
|
|
1166
|
+
--accent: oklch(0.27 0.008 55);
|
|
1167
|
+
--accent-foreground: oklch(0.96 0.015 60);
|
|
1168
|
+
--border: oklch(0.26 0.012 55 / 0.9);
|
|
1169
|
+
--border-strong: oklch(0.38 0.012 55 / 0.9);
|
|
1170
|
+
--input: oklch(0.26 0.012 55 / 0.9);
|
|
1171
|
+
--ring: oklch(0.7 0.16 52);
|
|
1172
|
+
--logo-from: oklch(0.8 0.16 58);
|
|
1173
|
+
--logo-to: oklch(0.62 0.18 44);
|
|
1174
|
+
}
|
|
1175
|
+
/* light (explicit via the toggle) */
|
|
1176
|
+
:root[data-theme='light'] {
|
|
1177
|
+
color-scheme: light;
|
|
1178
|
+
--background: oklch(0.985 0.008 80);
|
|
1179
|
+
--foreground: oklch(0.18 0.015 60);
|
|
1180
|
+
--card: oklch(1 0 0);
|
|
1181
|
+
--card-foreground: oklch(0.18 0.015 60);
|
|
1182
|
+
--popover: oklch(1 0 0);
|
|
1183
|
+
--popover-foreground: oklch(0.18 0.015 60);
|
|
1184
|
+
--primary: oklch(0.54 0.16 52);
|
|
1185
|
+
--primary-foreground: oklch(1 0 0);
|
|
1186
|
+
--secondary: oklch(0.96 0.008 80);
|
|
1187
|
+
--secondary-foreground: oklch(0.18 0.015 60);
|
|
1188
|
+
--muted: oklch(0.96 0.008 80);
|
|
1189
|
+
--muted-foreground: oklch(0.42 0.02 65);
|
|
1190
|
+
--accent: oklch(0.955 0.006 80);
|
|
1191
|
+
--accent-foreground: oklch(0.18 0.015 60);
|
|
1192
|
+
--border: oklch(0.88 0.01 75 / 0.95);
|
|
1193
|
+
--border-strong: oklch(0.78 0.01 75 / 0.95);
|
|
1194
|
+
--input: oklch(0.88 0.01 75 / 0.95);
|
|
1195
|
+
--ring: oklch(0.54 0.16 52);
|
|
1196
|
+
--logo-from: oklch(0.63 0.17 50);
|
|
1197
|
+
--logo-to: oklch(0.44 0.11 52);
|
|
1198
|
+
}
|
|
1199
|
+
/* light (OS preference, when the user has made no explicit choice) */
|
|
1200
|
+
@media (prefers-color-scheme: light) {
|
|
1201
|
+
:root:not(.dark):not([data-theme='dark']) {
|
|
1202
|
+
color-scheme: light;
|
|
1203
|
+
--background: oklch(0.985 0.008 80);
|
|
1204
|
+
--foreground: oklch(0.18 0.015 60);
|
|
1205
|
+
--card: oklch(1 0 0);
|
|
1206
|
+
--card-foreground: oklch(0.18 0.015 60);
|
|
1207
|
+
--popover: oklch(1 0 0);
|
|
1208
|
+
--popover-foreground: oklch(0.18 0.015 60);
|
|
1209
|
+
--primary: oklch(0.54 0.16 52);
|
|
1210
|
+
--primary-foreground: oklch(1 0 0);
|
|
1211
|
+
--secondary: oklch(0.96 0.008 80);
|
|
1212
|
+
--secondary-foreground: oklch(0.18 0.015 60);
|
|
1213
|
+
--muted: oklch(0.96 0.008 80);
|
|
1214
|
+
--muted-foreground: oklch(0.42 0.02 65);
|
|
1215
|
+
--accent: oklch(0.955 0.006 80);
|
|
1216
|
+
--accent-foreground: oklch(0.18 0.015 60);
|
|
1217
|
+
--border: oklch(0.88 0.01 75 / 0.95);
|
|
1218
|
+
--border-strong: oklch(0.78 0.01 75 / 0.95);
|
|
1219
|
+
--input: oklch(0.88 0.01 75 / 0.95);
|
|
1220
|
+
--ring: oklch(0.54 0.16 52);
|
|
1221
|
+
--logo-from: oklch(0.63 0.17 50);
|
|
1222
|
+
--logo-to: oklch(0.44 0.11 52);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
@theme inline {
|
|
1226
|
+
/* Only tokens the @webjsdev/ui theme does not already map live here. It
|
|
1227
|
+
already maps --color-background/foreground/card/primary/secondary/
|
|
1228
|
+
muted/accent/border/input/ring/destructive. */
|
|
1029
1229
|
--color-border-strong: var(--border-strong);
|
|
1030
|
-
--color-
|
|
1031
|
-
--color-accent-hover: var(--accent-hover);
|
|
1032
|
-
--color-accent-fg: var(--accent-fg);
|
|
1033
|
-
--color-accent-tint: var(--accent-tint);
|
|
1230
|
+
--color-primary-tint: var(--primary-tint);
|
|
1034
1231
|
--font-sans: var(--font-sans);
|
|
1035
1232
|
--font-serif: var(--font-serif);
|
|
1036
1233
|
--font-mono: var(--font-mono);
|
|
@@ -1043,71 +1240,21 @@ ${SHADCN_THEME}
|
|
|
1043
1240
|
}
|
|
1044
1241
|
</style>
|
|
1045
1242
|
<style>
|
|
1046
|
-
|
|
1047
|
-
color-scheme: light dark;
|
|
1048
|
-
/* ---------- dark (default) ---------- */
|
|
1049
|
-
--fg: oklch(0.96 0.015 60);
|
|
1050
|
-
--fg-muted: oklch(0.72 0.02 60);
|
|
1051
|
-
--fg-subtle: oklch(0.55 0.02 60);
|
|
1052
|
-
--bg: oklch(0.14 0.01 55);
|
|
1053
|
-
--bg-elev: oklch(0.18 0.01 55);
|
|
1054
|
-
--bg-subtle: oklch(0.16 0.01 55);
|
|
1055
|
-
--border: oklch(0.26 0.012 55 / 0.9);
|
|
1056
|
-
--border-strong: oklch(0.38 0.012 55 / 0.9);
|
|
1057
|
-
--accent: oklch(0.78 0.14 55);
|
|
1058
|
-
--accent-hover: oklch(0.85 0.14 55);
|
|
1059
|
-
--accent-fg: oklch(0.15 0.01 55);
|
|
1060
|
-
--accent-tint: oklch(0.78 0.14 55 / 0.14);
|
|
1061
|
-
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1062
|
-
--font-serif: ui-serif, 'Iowan Old Style', Palatino, Georgia, serif;
|
|
1063
|
-
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1064
|
-
}
|
|
1065
|
-
:root[data-theme='light'] {
|
|
1066
|
-
--fg: oklch(0.18 0.015 60);
|
|
1067
|
-
--fg-muted: oklch(0.42 0.02 65);
|
|
1068
|
-
--fg-subtle: oklch(0.62 0.015 70);
|
|
1069
|
-
--bg: oklch(0.985 0.008 80);
|
|
1070
|
-
--bg-elev: oklch(1 0 0);
|
|
1071
|
-
--bg-subtle: oklch(0.96 0.008 80);
|
|
1072
|
-
--border: oklch(0.88 0.01 75 / 0.95);
|
|
1073
|
-
--border-strong: oklch(0.78 0.01 75 / 0.95);
|
|
1074
|
-
--accent: oklch(0.58 0.15 55);
|
|
1075
|
-
--accent-hover: oklch(0.5 0.15 55);
|
|
1076
|
-
--accent-fg: oklch(1 0 0);
|
|
1077
|
-
--accent-tint: oklch(0.58 0.15 55 / 0.1);
|
|
1078
|
-
}
|
|
1079
|
-
@media (prefers-color-scheme: light) {
|
|
1080
|
-
:root:not([data-theme='dark']) {
|
|
1081
|
-
--fg: oklch(0.18 0.015 60);
|
|
1082
|
-
--fg-muted: oklch(0.42 0.02 65);
|
|
1083
|
-
--fg-subtle: oklch(0.62 0.015 70);
|
|
1084
|
-
--bg: oklch(0.985 0.008 80);
|
|
1085
|
-
--bg-elev: oklch(1 0 0);
|
|
1086
|
-
--bg-subtle: oklch(0.96 0.008 80);
|
|
1087
|
-
--border: oklch(0.88 0.01 75 / 0.95);
|
|
1088
|
-
--border-strong: oklch(0.78 0.01 75 / 0.95);
|
|
1089
|
-
--accent: oklch(0.58 0.15 55);
|
|
1090
|
-
--accent-hover: oklch(0.5 0.15 55);
|
|
1091
|
-
--accent-fg: oklch(1 0 0);
|
|
1092
|
-
--accent-tint: oklch(0.58 0.15 55 / 0.1);
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
/* Body + pseudo-elements utility classes can't reach. */
|
|
1243
|
+
/* Base styles utility classes can't reach. */
|
|
1096
1244
|
html, body { margin: 0; }
|
|
1097
|
-
:root { --header-h: 56px; } /* fixed-header offset, kept exact by the script above */
|
|
1098
1245
|
body {
|
|
1099
1246
|
padding-top: var(--header-h);
|
|
1100
|
-
background: var(--
|
|
1101
|
-
color: var(--
|
|
1247
|
+
background: var(--background);
|
|
1248
|
+
color: var(--foreground);
|
|
1102
1249
|
font: 16px/1.65 var(--font-sans);
|
|
1103
1250
|
-webkit-font-smoothing: antialiased;
|
|
1104
1251
|
}
|
|
1105
|
-
::selection { background: var(--
|
|
1252
|
+
::selection { background: color-mix(in oklch, var(--primary) 22%, transparent); color: var(--foreground); }
|
|
1106
1253
|
</style>
|
|
1107
1254
|
|
|
1108
|
-
<header class="fixed inset-x-0 top-0 z-20 flex items-center gap-6 px-4 sm:px-6 py-3 border-b border-border bg-[color-mix(in_oklch,var(--
|
|
1109
|
-
<a href="/" class="mr-auto inline-flex items-center gap-2 no-underline text-
|
|
1110
|
-
<span>${
|
|
1255
|
+
<header class="fixed inset-x-0 top-0 z-20 flex items-center gap-6 px-4 sm:px-6 py-3 border-b border-border bg-[color-mix(in_oklch,var(--background)_75%,transparent)] backdrop-blur-[18px]">
|
|
1256
|
+
<a href="/" class="mr-auto inline-flex items-center gap-2 no-underline text-foreground font-semibold text-[15px] leading-none tracking-tight">
|
|
1257
|
+
<span>${displayName}</span>
|
|
1111
1258
|
</a>
|
|
1112
1259
|
<nav class="flex gap-4 items-center">
|
|
1113
1260
|
<!-- Example nav. Replace with the real navigation for your app. -->
|
|
@@ -1123,13 +1270,30 @@ ${SHADCN_THEME}
|
|
|
1123
1270
|
drop the cap and mx-auto for an edge-to-edge layout. A wide layout left
|
|
1124
1271
|
inside the 760px reading column overflows into a horizontal scrollbar.
|
|
1125
1272
|
-->
|
|
1126
|
-
<
|
|
1127
|
-
|
|
1128
|
-
|
|
1273
|
+
<div class="flex flex-col min-h-[calc(100dvh-var(--header-h))]">
|
|
1274
|
+
<main class="flex-1 w-full max-w-[760px] mx-auto px-4 sm:px-6 pt-[72px] pb-12">
|
|
1275
|
+
\${children}
|
|
1276
|
+
</main>
|
|
1277
|
+
<!-- "Built with webjs" attribution. Keep it or replace it with your own
|
|
1278
|
+
footer; the gradient mark uses the --logo-from/--logo-to tokens. -->
|
|
1279
|
+
<footer class="border-t border-border">
|
|
1280
|
+
<div class="max-w-[760px] mx-auto px-4 sm:px-6 py-6 flex items-center justify-center">
|
|
1281
|
+
<a href="https://webjs.dev" class="inline-flex items-center gap-2 no-underline text-sm text-muted-foreground hover:text-foreground transition-colors">
|
|
1282
|
+
<span>Built with</span>
|
|
1283
|
+
<span class="w-[18px] h-[18px] rounded-[6px] bg-gradient-to-br from-[var(--logo-from)] to-[var(--logo-to)] shadow-[0_2px_10px_var(--primary-tint)]"></span>
|
|
1284
|
+
<span class="font-semibold text-foreground">webjs</span>
|
|
1285
|
+
</a>
|
|
1286
|
+
</div>
|
|
1287
|
+
</footer>
|
|
1288
|
+
</div>
|
|
1129
1289
|
\`;
|
|
1130
1290
|
}
|
|
1131
1291
|
`);
|
|
1132
1292
|
|
|
1293
|
+
// The gallery-index home (links every feature demo + the example app) ships
|
|
1294
|
+
// only in the full-stack scaffold, which is the only one that ships the
|
|
1295
|
+
// gallery. saas gets its own landing below.
|
|
1296
|
+
if (isFullStack) {
|
|
1133
1297
|
await writeFile(join(appDir, 'app', 'page.ts'), `// webjs-scaffold-placeholder. This is the example homepage. Replace it with your app's real page, then delete this line. webjs check fails while the marker remains.
|
|
1134
1298
|
import { html } from '@webjsdev/core';
|
|
1135
1299
|
import { rubric, displayH1, accentLink } from '#lib/utils/ui.ts';
|
|
@@ -1140,69 +1304,189 @@ import {
|
|
|
1140
1304
|
cardHeaderClass,
|
|
1141
1305
|
cardTitleClass,
|
|
1142
1306
|
cardDescriptionClass,
|
|
1143
|
-
cardContentClass,
|
|
1144
1307
|
} from '#components/ui/card.ts';
|
|
1145
|
-
import { alertClass, alertTitleClass, alertDescriptionClass } from '#components/ui/alert.ts';
|
|
1146
|
-
import { separatorClass } from '#components/ui/separator.ts';
|
|
1147
1308
|
|
|
1148
1309
|
export const metadata = {
|
|
1149
|
-
title: '${
|
|
1310
|
+
title: '${displayName}: built with webjs',
|
|
1150
1311
|
};
|
|
1151
1312
|
|
|
1313
|
+
// Two kinds of reference the scaffold ships. FEATURES are single-concept demos
|
|
1314
|
+
// (one webjs feature each, under app/features/, logic in modules/). EXAMPLES are
|
|
1315
|
+
// whole apps that compose several features (under app/examples/). Prune what you
|
|
1316
|
+
// do not use (delete the route AND its modules/<name>), then reshape this page.
|
|
1317
|
+
const features = [
|
|
1318
|
+
{ href: '/features/routing', title: 'Routing', blurb: 'A static route plus a dynamic [id] segment that reads params. The file-based router in miniature.' },
|
|
1319
|
+
{ href: '/features/components', title: 'Components', blurb: 'The WebComponent factory, reactive props, instance signals, and slot projection in light DOM.' },
|
|
1320
|
+
{ href: '/features/server-actions', title: 'Server actions', blurb: 'A use-server RPC action next to a server-only .server.ts utility, and why the boundary matters.' },
|
|
1321
|
+
{ href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
|
|
1322
|
+
{ href: '/features/async-render', title: 'Async render', blurb: 'A component that awaits server data in async render(), so the resolved value is in the first paint.' },
|
|
1323
|
+
{ href: '/features/directives', title: 'Directives', blurb: 'The lit-html directive set: repeat for keyed lists, watch(signal) for a fine-grained node swap.' },
|
|
1324
|
+
{ href: '/features/route-handler', title: 'Route handlers', blurb: 'A server-only route.ts HTTP endpoint returning JSON, the webjs equivalent of a Next route handler.' },
|
|
1325
|
+
{ href: '/features/forms', title: 'Forms', blurb: 'A no-JS progressive-enhancement form posting to the page action, with server-side validation errors.' },
|
|
1326
|
+
{ href: '/features/metadata', title: 'Metadata', blurb: 'Static metadata plus generateMetadata(ctx), which reads the request to compute the title and Open Graph tags.' },
|
|
1327
|
+
{ href: '/features/caching', title: 'Caching', blurb: 'export const revalidate caches the page HTML per URL, with the safety rule for when a shared cache is allowed.' },
|
|
1328
|
+
{ href: '/features/env', title: 'Env vars', blurb: 'The server-only vs WEBJS_PUBLIC_ boundary, read during SSR so secrets never reach the browser.' },
|
|
1329
|
+
{ href: '/features/client-router', title: 'Client router', blurb: 'Automatic soft navigation: fragment-only fetches, hover prefetch, scroll restore, and graceful no-JS fallback.' },
|
|
1330
|
+
{ href: '/features/service-worker', title: 'Service worker', blurb: 'The opt-in offline enhancement, registered from a browser-only lifecycle hook (never a page or layout).' },
|
|
1331
|
+
{ href: '/features/websockets', title: 'WebSockets', blurb: 'A WS(ws, req) route endpoint plus the connectWS() client, echoing messages over a live socket.' },
|
|
1332
|
+
{ href: '/features/broadcast', title: 'Broadcast', blurb: 'Fan a message out to every connected client on a WebSocket path, so all open tabs stay in sync.' },
|
|
1333
|
+
{ href: '/features/rate-limit', title: 'Rate limiting', blurb: 'The rateLimit() middleware scoped to one endpoint, returning a 429 with Retry-After past the window.' },
|
|
1334
|
+
{ href: '/features/file-storage', title: 'File storage', blurb: 'A no-JS multipart upload streamed into the FileStore, then served back through a streaming route.' },
|
|
1335
|
+
];
|
|
1336
|
+
const examples = [
|
|
1337
|
+
{ href: '/examples/todo', title: 'Optimistic todo', blurb: 'A whole app composing several features: the declarative optimistic() list API, progressive-enhancement forms, accessible labels, the modules split, and SQLite.' },
|
|
1338
|
+
];
|
|
1339
|
+
|
|
1340
|
+
const galleryCard = (item: { href: string; title: string; blurb: string }) => html\`
|
|
1341
|
+
<a href=\${item.href} class="block no-underline">
|
|
1342
|
+
<div class="\${cardClass()} h-full transition-colors hover:border-border-strong">
|
|
1343
|
+
<div class=\${cardHeaderClass()}>
|
|
1344
|
+
<h3 class=\${cardTitleClass()}>\${item.title}</h3>
|
|
1345
|
+
<p class=\${cardDescriptionClass()}>\${item.blurb}</p>
|
|
1346
|
+
</div>
|
|
1347
|
+
</div>
|
|
1348
|
+
</a>
|
|
1349
|
+
\`;
|
|
1350
|
+
|
|
1152
1351
|
export default function Home() {
|
|
1153
1352
|
return html\`
|
|
1154
|
-
<section class="mb-
|
|
1353
|
+
<section class="mb-14">
|
|
1155
1354
|
\${rubric('welcome')}
|
|
1156
|
-
\${displayH1(html\`Hello from <span class="text-
|
|
1157
|
-
<p class="text-lede leading-[1.5] text-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1355
|
+
\${displayH1(html\`Hello from <span class="text-primary italic">${displayName}</span>.\`)}
|
|
1356
|
+
<p class="text-lede leading-[1.5] text-muted-foreground max-w-[56ch] m-0 mb-6">
|
|
1357
|
+
This scaffold ships a gallery below: single-feature demos and one whole
|
|
1358
|
+
example app, all small, idiomatic, and heavily commented. Browse them for
|
|
1359
|
+
context, then replace this page with your own. See
|
|
1360
|
+
\${accentLink('https://docs.webjs.dev', 'the docs')} for the full reference.
|
|
1161
1361
|
</p>
|
|
1162
1362
|
<div class="flex gap-3 items-center">
|
|
1163
|
-
<
|
|
1164
|
-
<button class=\${buttonClass({ variant: 'outline' })}>View docs</button>
|
|
1363
|
+
<a href="/examples/todo" class=\${buttonClass()}>Open the todo app</a>
|
|
1165
1364
|
<span class=\${badgeClass({ variant: 'secondary' })}>v0.1</span>
|
|
1166
1365
|
</div>
|
|
1167
1366
|
</section>
|
|
1168
1367
|
|
|
1169
|
-
<
|
|
1170
|
-
<
|
|
1171
|
-
|
|
1172
|
-
<
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1368
|
+
<section class="mb-12">
|
|
1369
|
+
<h2 class="font-serif text-[1.6rem] tracking-[-0.02em] font-bold m-0 mb-1">Features</h2>
|
|
1370
|
+
<p class="text-muted-foreground text-sm m-0 mb-5">
|
|
1371
|
+
One webjs concept each, under <code class="font-mono text-[0.9em]">app/features/</code>
|
|
1372
|
+
with logic in <code class="font-mono text-[0.9em]">modules/</code>. Delete the ones you do not need.
|
|
1373
|
+
</p>
|
|
1374
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
1375
|
+
\${features.map(galleryCard)}
|
|
1176
1376
|
</div>
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1377
|
+
</section>
|
|
1378
|
+
|
|
1379
|
+
<section>
|
|
1380
|
+
<h2 class="font-serif text-[1.6rem] tracking-[-0.02em] font-bold m-0 mb-1">Example apps</h2>
|
|
1381
|
+
<p class="text-muted-foreground text-sm m-0 mb-5">
|
|
1382
|
+
Whole apps that compose several features, under <code class="font-mono text-[0.9em]">app/examples/</code>.
|
|
1383
|
+
</p>
|
|
1384
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
1385
|
+
\${examples.map(galleryCard)}
|
|
1386
|
+
</div>
|
|
1387
|
+
</section>
|
|
1388
|
+
\`;
|
|
1389
|
+
}
|
|
1390
|
+
`);
|
|
1391
|
+
} else {
|
|
1392
|
+
// saas home: the auth landing (hero + login/signup/dashboard) stays the
|
|
1393
|
+
// headline, and the webjs feature gallery sits BELOW it, so a saas app is
|
|
1394
|
+
// also a learning surface. Keep the `features` list in sync with the
|
|
1395
|
+
// full-stack home above (both ship the same gallery).
|
|
1396
|
+
await writeFile(join(appDir, 'app', 'page.ts'), `// webjs-scaffold-placeholder. This is the example homepage. Replace it with your app's real landing page, then delete this line. webjs check fails while the marker remains.
|
|
1397
|
+
import { html } from '@webjsdev/core';
|
|
1398
|
+
import { rubric, displayH1, accentLink } from '#lib/utils/ui.ts';
|
|
1399
|
+
import {
|
|
1400
|
+
cardClass,
|
|
1401
|
+
cardHeaderClass,
|
|
1402
|
+
cardTitleClass,
|
|
1403
|
+
cardDescriptionClass,
|
|
1404
|
+
} from '#components/ui/card.ts';
|
|
1405
|
+
|
|
1406
|
+
export const metadata = {
|
|
1407
|
+
title: '${displayName}: built with webjs',
|
|
1408
|
+
};
|
|
1409
|
+
|
|
1410
|
+
// The webjs feature gallery, shown below the auth landing. FEATURES are
|
|
1411
|
+
// single-concept demos (under app/features/, logic in modules/); EXAMPLES are
|
|
1412
|
+
// whole apps (under app/examples/). Prune what you do not use (delete the route
|
|
1413
|
+
// AND its modules/<name>). Keep this list in sync with the full-stack home.
|
|
1414
|
+
const features = [
|
|
1415
|
+
{ href: '/features/routing', title: 'Routing', blurb: 'A static route plus a dynamic [id] segment that reads params. The file-based router in miniature.' },
|
|
1416
|
+
{ href: '/features/components', title: 'Components', blurb: 'The WebComponent factory, reactive props, instance signals, and slot projection in light DOM.' },
|
|
1417
|
+
{ href: '/features/server-actions', title: 'Server actions', blurb: 'A use-server RPC action next to a server-only .server.ts utility, and why the boundary matters.' },
|
|
1418
|
+
{ href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
|
|
1419
|
+
{ href: '/features/async-render', title: 'Async render', blurb: 'A component that awaits server data in async render(), so the resolved value is in the first paint.' },
|
|
1420
|
+
{ href: '/features/directives', title: 'Directives', blurb: 'The lit-html directive set: repeat for keyed lists, watch(signal) for a fine-grained node swap.' },
|
|
1421
|
+
{ href: '/features/route-handler', title: 'Route handlers', blurb: 'A server-only route.ts HTTP endpoint returning JSON, the webjs equivalent of a Next route handler.' },
|
|
1422
|
+
{ href: '/features/forms', title: 'Forms', blurb: 'A no-JS progressive-enhancement form posting to the page action, with server-side validation errors.' },
|
|
1423
|
+
{ href: '/features/metadata', title: 'Metadata', blurb: 'Static metadata plus generateMetadata(ctx), which reads the request to compute the title and Open Graph tags.' },
|
|
1424
|
+
{ href: '/features/caching', title: 'Caching', blurb: 'export const revalidate caches the page HTML per URL, with the safety rule for when a shared cache is allowed.' },
|
|
1425
|
+
{ href: '/features/env', title: 'Env vars', blurb: 'The server-only vs WEBJS_PUBLIC_ boundary, read during SSR so secrets never reach the browser.' },
|
|
1426
|
+
{ href: '/features/client-router', title: 'Client router', blurb: 'Automatic soft navigation: fragment-only fetches, hover prefetch, scroll restore, and graceful no-JS fallback.' },
|
|
1427
|
+
{ href: '/features/service-worker', title: 'Service worker', blurb: 'The opt-in offline enhancement, registered from a browser-only lifecycle hook (never a page or layout).' },
|
|
1428
|
+
{ href: '/features/websockets', title: 'WebSockets', blurb: 'A WS(ws, req) route endpoint plus the connectWS() client, echoing messages over a live socket.' },
|
|
1429
|
+
{ href: '/features/broadcast', title: 'Broadcast', blurb: 'Fan a message out to every connected client on a WebSocket path, so all open tabs stay in sync.' },
|
|
1430
|
+
{ href: '/features/rate-limit', title: 'Rate limiting', blurb: 'The rateLimit() middleware scoped to one endpoint, returning a 429 with Retry-After past the window.' },
|
|
1431
|
+
{ href: '/features/file-storage', title: 'File storage', blurb: 'A no-JS multipart upload streamed into the FileStore, then served back through a streaming route.' },
|
|
1432
|
+
];
|
|
1433
|
+
const examples = [
|
|
1434
|
+
{ href: '/examples/todo', title: 'Optimistic todo', blurb: 'A whole app composing several features: the declarative optimistic() list API, progressive-enhancement forms, accessible labels, the modules split, and SQLite.' },
|
|
1435
|
+
];
|
|
1436
|
+
|
|
1437
|
+
const galleryCard = (item: { href: string; title: string; blurb: string }) => html\`
|
|
1438
|
+
<a href=\${item.href} class="block no-underline">
|
|
1439
|
+
<div class="\${cardClass()} h-full transition-colors hover:border-border-strong">
|
|
1440
|
+
<div class=\${cardHeaderClass()}>
|
|
1441
|
+
<h3 class=\${cardTitleClass()}>\${item.title}</h3>
|
|
1442
|
+
<p class=\${cardDescriptionClass()}>\${item.blurb}</p>
|
|
1187
1443
|
</div>
|
|
1188
1444
|
</div>
|
|
1445
|
+
</a>
|
|
1446
|
+
\`;
|
|
1189
1447
|
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
<section class="
|
|
1193
|
-
|
|
1194
|
-
<
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
assignedNodes / slotchange API.
|
|
1448
|
+
export default function Home() {
|
|
1449
|
+
return html\`
|
|
1450
|
+
<section class="mb-14">
|
|
1451
|
+
\${rubric('welcome')}
|
|
1452
|
+
\${displayH1(html\`Hello from <span class="text-primary italic">${displayName}</span>.\`)}
|
|
1453
|
+
<p class="text-lede leading-[1.5] text-muted-foreground max-w-[56ch] m-0 mb-6">
|
|
1454
|
+
The saas starter: email and password auth, a protected dashboard, and a
|
|
1455
|
+
User model, all wired up. Replace this hero with your product's landing;
|
|
1456
|
+
the webjs feature gallery below is reference, prune what you do not need.
|
|
1457
|
+
See \${accentLink('https://docs.webjs.dev', 'the docs')} for the full reference.
|
|
1201
1458
|
</p>
|
|
1459
|
+
<div class="flex gap-3 items-center">
|
|
1460
|
+
<a href="/login" class="inline-flex items-center px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm no-underline transition-all hover:bg-primary/90 active:scale-[0.97]">Log in</a>
|
|
1461
|
+
<a href="/signup" class="text-primary no-underline font-medium text-sm">Create an account</a>
|
|
1462
|
+
<a href="/dashboard" class="text-muted-foreground no-underline font-medium text-sm hover:text-foreground transition-colors">Dashboard</a>
|
|
1463
|
+
</div>
|
|
1464
|
+
</section>
|
|
1465
|
+
|
|
1466
|
+
<section class="mb-12">
|
|
1467
|
+
<h2 class="font-serif text-[1.6rem] tracking-[-0.02em] font-bold m-0 mb-1">Features</h2>
|
|
1468
|
+
<p class="text-muted-foreground text-sm m-0 mb-5">
|
|
1469
|
+
One webjs concept each, under <code class="font-mono text-[0.9em]">app/features/</code>
|
|
1470
|
+
with logic in <code class="font-mono text-[0.9em]">modules/</code>. Delete the ones you do not need.
|
|
1471
|
+
</p>
|
|
1472
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
1473
|
+
\${features.map(galleryCard)}
|
|
1474
|
+
</div>
|
|
1475
|
+
</section>
|
|
1476
|
+
|
|
1477
|
+
<section>
|
|
1478
|
+
<h2 class="font-serif text-[1.6rem] tracking-[-0.02em] font-bold m-0 mb-1">Example apps</h2>
|
|
1479
|
+
<p class="text-muted-foreground text-sm m-0 mb-5">
|
|
1480
|
+
Whole apps that compose several features, under <code class="font-mono text-[0.9em]">app/examples/</code>.
|
|
1481
|
+
</p>
|
|
1482
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
1483
|
+
\${examples.map(galleryCard)}
|
|
1484
|
+
</div>
|
|
1202
1485
|
</section>
|
|
1203
1486
|
\`;
|
|
1204
1487
|
}
|
|
1205
1488
|
`);
|
|
1489
|
+
}
|
|
1206
1490
|
|
|
1207
1491
|
// AGENTS.md is copied via the `templateFiles` loop above, from
|
|
1208
1492
|
// `packages/cli/templates/AGENTS.md` with `{{APP_NAME}}` substitution.
|
|
@@ -1245,7 +1529,7 @@ export class ThemeToggle extends WebComponent {
|
|
|
1245
1529
|
const el = document.documentElement;
|
|
1246
1530
|
if (next === 'system') delete el.dataset.theme;
|
|
1247
1531
|
else el.dataset.theme = next;
|
|
1248
|
-
// Keep
|
|
1532
|
+
// Keep the .dark class the @webjsdev/ui kit uses in sync so the ui-* components follow the theme.
|
|
1249
1533
|
const dark = next === 'dark'
|
|
1250
1534
|
|| (next === 'system' && !window.matchMedia('(prefers-color-scheme: light)').matches);
|
|
1251
1535
|
el.classList.toggle('dark', dark);
|
|
@@ -1257,7 +1541,7 @@ export class ThemeToggle extends WebComponent {
|
|
|
1257
1541
|
const icon = t === 'light' ? ICONS.sun : t === 'dark' ? ICONS.moon : ICONS.system;
|
|
1258
1542
|
return html\`
|
|
1259
1543
|
<button
|
|
1260
|
-
class="inline-flex items-center justify-center w-9 h-9 p-0 border border-border rounded-full bg-
|
|
1544
|
+
class="inline-flex items-center justify-center w-9 h-9 p-0 border border-border rounded-full bg-card text-muted-foreground cursor-pointer transition-all duration-150 hover:text-foreground hover:border-border-strong active:scale-[0.94] focus-visible:outline-none focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary-tint"
|
|
1261
1545
|
@click=\${() => this.cycle()}
|
|
1262
1546
|
aria-label="Cycle theme (currently \${label})"
|
|
1263
1547
|
title="Theme: \${label.toLowerCase()}"
|
|
@@ -1307,7 +1591,7 @@ ThemeToggle.register('theme-toggle');
|
|
|
1307
1591
|
app/layout.ts, page.ts, login/, signup/
|
|
1308
1592
|
app/dashboard/{page,settings,middleware}.ts ← protected
|
|
1309
1593
|
app/api/auth/[...path]/route.ts ← auth API
|
|
1310
|
-
|
|
1594
|
+
styles/globals.css ← @webjsdev/ui theme tokens
|
|
1311
1595
|
components.json ← preconfigured for \`webjs ui add\`
|
|
1312
1596
|
components/ui/{button,card,alert,badge,separator,label,input,
|
|
1313
1597
|
dialog,form,field,switch,checkbox}.ts
|
|
@@ -1320,15 +1604,21 @@ ThemeToggle.register('theme-toggle');
|
|
|
1320
1604
|
`);
|
|
1321
1605
|
} else {
|
|
1322
1606
|
console.log(` ${name}/
|
|
1323
|
-
app/layout.ts, page.ts ←
|
|
1324
|
-
app/
|
|
1607
|
+
app/layout.ts, page.ts ← home links to the gallery
|
|
1608
|
+
app/features/{routing,components,server-actions,optimistic-ui,
|
|
1609
|
+
async-render,directives,route-handler}/
|
|
1610
|
+
← single-feature demos
|
|
1611
|
+
app/examples/todo/ ← one whole example app (composes features)
|
|
1612
|
+
styles/globals.css ← @webjsdev/ui theme tokens
|
|
1325
1613
|
components.json ← preconfigured for \`webjs ui add\`
|
|
1326
1614
|
components/ui/{button,card,alert,badge,separator,label,input}.ts
|
|
1327
1615
|
components/theme-toggle.ts ← light DOM web component
|
|
1328
1616
|
lib/utils/cn.ts ← cn() helper for ui-* components
|
|
1329
1617
|
lib/utils/ui.ts ← Tailwind class-bundle helpers
|
|
1330
1618
|
public/tailwind-browser.js ← Tailwind runtime
|
|
1331
|
-
modules/
|
|
1619
|
+
modules/{components,server-actions,optimistic-ui,async-render,
|
|
1620
|
+
directives,todo}/ ← feature + example logic (prune what you skip)
|
|
1621
|
+
db/{schema,columns,connection}.server.ts ← Drizzle (User + Todo)
|
|
1332
1622
|
CONVENTIONS.md, AGENTS.md, CLAUDE.md
|
|
1333
1623
|
`);
|
|
1334
1624
|
}
|
|
@@ -1345,14 +1635,17 @@ ThemeToggle.register('theme-toggle');
|
|
|
1345
1635
|
✓ Convention validation via \`webjs check\`
|
|
1346
1636
|
|
|
1347
1637
|
For AI agents, read this before editing scaffolded files:
|
|
1348
|
-
• The example layout
|
|
1349
|
-
|
|
1350
|
-
the
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1638
|
+
• The example layout, home page, the gallery under app/features/ and
|
|
1639
|
+
app/examples/ (+ their modules) and schema are REFERENCE ONLY. Replace
|
|
1640
|
+
them with the app the user asked for, and prune the routes you do not
|
|
1641
|
+
use (delete the app/features/<name> or app/examples/<name> route AND
|
|
1642
|
+
its modules/<name>). Don't ship the scaffold's examples as the final
|
|
1643
|
+
product.
|
|
1644
|
+
• This fresh app intentionally FAILS \`webjs check\` with
|
|
1645
|
+
no-scaffold-placeholder violations on the example content (the home
|
|
1646
|
+
page, the layout, and each gallery route). That is the signal to
|
|
1647
|
+
replace or prune each. Delete the marker comment line as you do, and
|
|
1648
|
+
the check goes green.
|
|
1356
1649
|
• Use Drizzle + SQLite for app data. It's already wired up. Define
|
|
1357
1650
|
real models in db/schema.server.ts, then run \`webjs db generate\`
|
|
1358
1651
|
and \`webjs db migrate\`. NEVER store app data in JSON files,
|
|
@@ -1362,7 +1655,7 @@ For AI agents, read this before editing scaffolded files:
|
|
|
1362
1655
|
the closest scaffold and adapt it.
|
|
1363
1656
|
• Read AGENTS.md + CONVENTIONS.md in the new project before writing
|
|
1364
1657
|
any code. They are the contract.
|
|
1365
|
-
• Need more detail? Full hosted docs are at https://docs.webjs.
|
|
1658
|
+
• Need more detail? Full hosted docs are at https://docs.webjs.dev
|
|
1366
1659
|
(every API, directive, recipe, and deployment guide).
|
|
1367
1660
|
`);
|
|
1368
1661
|
|
|
@@ -1390,13 +1683,14 @@ For AI agents, read this before editing scaffolded files:
|
|
|
1390
1683
|
// templates ship with @webjsdev/ui already initialised; the api
|
|
1391
1684
|
// template has no UI but may add one later.
|
|
1392
1685
|
const installSegment = installed ? '' : `${pm} install && `;
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1395
|
-
//
|
|
1396
|
-
//
|
|
1397
|
-
//
|
|
1398
|
-
//
|
|
1399
|
-
|
|
1686
|
+
// Some examples query the db on their first request, so a migration must be
|
|
1687
|
+
// authored first: `db:generate` writes it and the `webjs.dev.before` migrate
|
|
1688
|
+
// applies it on `run dev` (Drizzle splits Prisma's `migrate dev` into
|
|
1689
|
+
// generate-then-migrate). The saas example queries users (auth); the
|
|
1690
|
+
// full-stack scaffold ships the gallery's /examples/todo route (queries todos).
|
|
1691
|
+
// The api template has no such first-request query, so it boots with just
|
|
1692
|
+
// `run dev`; once you add a db route, `db:generate` then `run dev` is the loop.
|
|
1693
|
+
const dbSegment = isApi ? '' : `${pm} run db:generate && `;
|
|
1400
1694
|
const runCommand = `cd ${name} && ${installSegment}${dbSegment}${pm} run dev`;
|
|
1401
1695
|
// Postgres needs a reachable DATABASE_URL before any migrate (sqlite uses a
|
|
1402
1696
|
// local file with no .env). Point it at a running database; `dev` / `start`
|