@webjsdev/cli 0.10.35 → 0.10.36
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/lib/create.js +14 -11
- package/lib/doctor.js +1 -1
- package/lib/saas-template.js +20 -6
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +1 -1
- package/templates/.github/copilot-instructions.md +1 -1
- package/templates/AGENTS.md +7 -6
- package/templates/CONVENTIONS.md +4 -4
- package/templates/gallery/app/features/boundaries/crash/page.ts +7 -0
- package/templates/gallery/app/features/boundaries/error.ts +16 -0
- package/templates/gallery/app/features/boundaries/gated/forbidden.ts +18 -0
- package/templates/gallery/app/features/boundaries/gated/page.ts +10 -0
- package/templates/gallery/app/features/boundaries/loading.ts +15 -0
- package/templates/gallery/app/features/boundaries/not-found.ts +17 -0
- package/templates/gallery/app/features/boundaries/page.ts +77 -0
- package/templates/gallery/app/features/boundaries/private/page.ts +10 -0
- package/templates/gallery/app/features/boundaries/private/unauthorized.ts +17 -0
- package/templates/gallery/app/features/client-router/page.ts +3 -0
- package/templates/gallery/app/features/components/page.ts +3 -0
- package/templates/gallery/app/features/file-storage/page.ts +2 -2
- package/templates/gallery/app/features/forms/page.ts +1 -1
- package/templates/gallery/app/features/routing/[id]/page.ts +13 -1
- package/templates/gallery/app/features/routing/page.ts +8 -0
- package/templates/gallery/app/manifest.ts +22 -0
- package/templates/gallery/app/robots.ts +16 -0
- package/templates/gallery/app/sitemap.ts +23 -0
- package/templates/gallery/modules/client-router/components/router-controls.ts +24 -0
- package/templates/gallery/modules/components/components/counter-card.ts +1 -1
- package/templates/gallery/modules/components/components/reactive-meter.ts +64 -0
- package/templates/gallery/modules/directives/components/directive-demo.ts +64 -19
- package/templates/gallery/modules/rate-limit/components/rate-probe.ts +1 -1
- package/templates/gallery/modules/server-actions/components/greeter.ts +1 -1
- package/templates/gallery/modules/todo/components/todo-app.ts +5 -5
package/lib/create.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `webjs create <name>`: scaffold a new
|
|
2
|
+
* `webjs create <name>`: scaffold a new WebJs app with opinionated defaults.
|
|
3
3
|
*
|
|
4
4
|
* Creates a directory with:
|
|
5
5
|
* - app/ with a root layout + page
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - components/ with a theme toggle
|
|
8
8
|
* - test/unit/ and test/e2e/ with example tests
|
|
9
9
|
* - CONVENTIONS.md, AGENTS.md, CLAUDE.md
|
|
10
|
-
* - package.json with
|
|
10
|
+
* - package.json with WebJs deps + test scripts
|
|
11
11
|
* - tsconfig.json for editor support
|
|
12
12
|
*/
|
|
13
13
|
|
|
@@ -160,7 +160,7 @@ async function copyUiComponents(appDir, names) {
|
|
|
160
160
|
* the scaffolded app. Merges `templates/gallery/{app,modules}` over the app so
|
|
161
161
|
* single-feature demos land under `app/features/<name>/`, whole example apps
|
|
162
162
|
* under `app/examples/<name>/`, and their logic under `modules/<name>/`, the
|
|
163
|
-
* app-thin + modules-logic split
|
|
163
|
+
* app-thin + modules-logic split WebJs prescribes.
|
|
164
164
|
*
|
|
165
165
|
* Ships verbatim (no `{{APP_NAME}}` substitution): the examples are self-
|
|
166
166
|
* contained and reference only `@webjsdev/*`, drizzle, `#db/*`, and each other.
|
|
@@ -392,7 +392,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
392
392
|
// Bun runtime (#541): the long-running server scripts (`dev` / `start`)
|
|
393
393
|
// are prefixed `bun --bun` so the app SERVES on Bun. The `--bun` overrides
|
|
394
394
|
// the `webjs` bin's `#!/usr/bin/env node` shebang (without it `bun run dev`
|
|
395
|
-
// would exec
|
|
395
|
+
// would exec WebJs under Node, silently running the "bun" app on Node).
|
|
396
396
|
// Baking it into the script body means a plain `bun run dev` (or even
|
|
397
397
|
// `npm run dev`) starts on Bun, so a user never has to remember the flag.
|
|
398
398
|
// The runtime-neutral tooling scripts below (test / db / check / typecheck
|
|
@@ -487,7 +487,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
487
487
|
noEmit: true,
|
|
488
488
|
allowImportingTsExtensions: true,
|
|
489
489
|
skipLibCheck: true,
|
|
490
|
-
//
|
|
490
|
+
// WebJs uses Node's built-in type-stripping (`process.features.
|
|
491
491
|
// typescript === 'strip'`) which preserves source positions
|
|
492
492
|
// byte-exactly. The constraint is that TypeScript must be
|
|
493
493
|
// "erasable": no `enum`, no `namespace` with values, no
|
|
@@ -581,7 +581,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
581
581
|
'.github/pull_request_template.md',
|
|
582
582
|
// CI is the test gate (the pre-commit hook only blocks main). Runs
|
|
583
583
|
// webjs check + the unit / browser / e2e layers on every PR and push
|
|
584
|
-
// to main, mirroring the
|
|
584
|
+
// to main, mirroring the WebJs framework's own CI.
|
|
585
585
|
'.github/workflows/ci.yml',
|
|
586
586
|
'.editorconfig',
|
|
587
587
|
// VS Code: associate the published webjs-config JSON Schema with the
|
|
@@ -1179,8 +1179,9 @@ ${UI_THEME}
|
|
|
1179
1179
|
Add a new design token the canonical way, a --x variable below plus a
|
|
1180
1180
|
--color-x: var(--x) line in the @theme inline block, then use it as bg-x /
|
|
1181
1181
|
text-x. Reach for opacity modifiers
|
|
1182
|
-
(bg-primary/10, hover:bg-primary/90,
|
|
1183
|
-
|
|
1182
|
+
(bg-primary/10, hover:bg-primary/90, border-border/60) before inventing a
|
|
1183
|
+
new token, but keep body text at full opacity so it stays above the AA
|
|
1184
|
+
contrast floor (a faded text-muted-foreground/70 measured 3.83:1). */
|
|
1184
1185
|
:root {
|
|
1185
1186
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1186
1187
|
--font-serif: ui-serif, 'Iowan Old Style', Palatino, Georgia, serif;
|
|
@@ -1358,11 +1359,12 @@ export const metadata = {
|
|
|
1358
1359
|
};
|
|
1359
1360
|
|
|
1360
1361
|
// Two kinds of reference the scaffold ships. FEATURES are single-concept demos
|
|
1361
|
-
// (one
|
|
1362
|
+
// (one WebJs feature each, under app/features/, logic in modules/). EXAMPLES are
|
|
1362
1363
|
// whole apps that compose several features (under app/examples/). Prune what you
|
|
1363
1364
|
// do not use (delete the route AND its modules/<name>), then reshape this page.
|
|
1364
1365
|
const features = [
|
|
1365
1366
|
{ href: '/features/routing', title: 'Routing', blurb: 'A static route plus a dynamic [id] segment that reads params. The file-based router in miniature.' },
|
|
1367
|
+
{ href: '/features/boundaries', title: 'Boundaries', blurb: 'The control-flow throws (forbidden / unauthorized / notFound) and the nearest boundary file that catches each.' },
|
|
1366
1368
|
{ href: '/features/components', title: 'Components', blurb: 'The WebComponent factory, reactive props, instance signals, and slot projection in light DOM.' },
|
|
1367
1369
|
{ 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.' },
|
|
1368
1370
|
{ href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
|
|
@@ -1437,7 +1439,7 @@ export default function Home() {
|
|
|
1437
1439
|
`);
|
|
1438
1440
|
} else {
|
|
1439
1441
|
// saas home: the auth landing (hero + login/signup/dashboard) stays the
|
|
1440
|
-
// headline, and the
|
|
1442
|
+
// headline, and the WebJs feature gallery sits BELOW it, so a saas app is
|
|
1441
1443
|
// also a learning surface. Keep the `features` list in sync with the
|
|
1442
1444
|
// full-stack home above (both ship the same gallery).
|
|
1443
1445
|
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.
|
|
@@ -1454,12 +1456,13 @@ export const metadata = {
|
|
|
1454
1456
|
title: '${displayName}: built with webjs',
|
|
1455
1457
|
};
|
|
1456
1458
|
|
|
1457
|
-
// The
|
|
1459
|
+
// The WebJs feature gallery, shown below the auth landing. FEATURES are
|
|
1458
1460
|
// single-concept demos (under app/features/, logic in modules/); EXAMPLES are
|
|
1459
1461
|
// whole apps (under app/examples/). Prune what you do not use (delete the route
|
|
1460
1462
|
// AND its modules/<name>). Keep this list in sync with the full-stack home.
|
|
1461
1463
|
const features = [
|
|
1462
1464
|
{ href: '/features/routing', title: 'Routing', blurb: 'A static route plus a dynamic [id] segment that reads params. The file-based router in miniature.' },
|
|
1465
|
+
{ href: '/features/boundaries', title: 'Boundaries', blurb: 'The control-flow throws (forbidden / unauthorized / notFound) and the nearest boundary file that catches each.' },
|
|
1463
1466
|
{ href: '/features/components', title: 'Components', blurb: 'The WebComponent factory, reactive props, instance signals, and slot projection in light DOM.' },
|
|
1464
1467
|
{ 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.' },
|
|
1465
1468
|
{ href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
|
package/lib/doctor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `webjs doctor`: a project-health checklist runner (issue #266).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* WebJs has unusually many fragile preconditions, each an independent failure
|
|
5
5
|
* mode a contributor onboarding to an existing repo only hits at runtime: the
|
|
6
6
|
* Node 24+ strip-types floor, the `erasableSyntaxOnly` TS flag, importmap pin
|
|
7
7
|
* freshness, env drift vs `.env.example`, `@webjsdev/*` version coherence, and
|
package/lib/saas-template.js
CHANGED
|
@@ -27,7 +27,12 @@ async function readUiComponent(name) {
|
|
|
27
27
|
// it to the scaffolded app's aliased path (cn lives at lib/utils/cn.ts).
|
|
28
28
|
return raw
|
|
29
29
|
.replaceAll("'../lib/utils.ts'", "'#lib/utils/cn.ts'")
|
|
30
|
-
.replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"')
|
|
30
|
+
.replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"')
|
|
31
|
+
// onBeforeCache lives in its own client-only module so cn() stays pure (#819).
|
|
32
|
+
// Without this rewrite dialog.ts keeps the registry-relative `../lib/dom.ts`
|
|
33
|
+
// (which resolves to a nonexistent components/lib/dom.ts) and fails typecheck.
|
|
34
|
+
.replaceAll("'../lib/dom.ts'", "'#lib/utils/dom.ts'")
|
|
35
|
+
.replaceAll('"../lib/dom.ts"', '"#lib/utils/dom.ts"');
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
/** Copy named registry components into `<appDir>/components/ui/`. */
|
|
@@ -87,6 +92,15 @@ export async function writeSaasFiles(appDir, opts = {}) {
|
|
|
87
92
|
"import { db } from '#db/connection.server.ts';",
|
|
88
93
|
"import { compare } from './password.server.ts';",
|
|
89
94
|
"",
|
|
95
|
+
"// AUTH_SECRET signs session tokens. Set a strong value in .env for any real",
|
|
96
|
+
"// deployment. The dev fallback keeps a fresh scaffold booting, but is NOT",
|
|
97
|
+
"// safe for production, so we fail fast if it is missing or blank there.",
|
|
98
|
+
"const trimmedSecret = process.env.AUTH_SECRET?.trim();",
|
|
99
|
+
"if (process.env.NODE_ENV === 'production' && !trimmedSecret) {",
|
|
100
|
+
" throw new Error('AUTH_SECRET must be set in production');",
|
|
101
|
+
"}",
|
|
102
|
+
"const authSecret = trimmedSecret || 'dev-insecure-secret-change-me';",
|
|
103
|
+
"",
|
|
90
104
|
"export const { auth, signIn, signOut, handlers } = createAuth({",
|
|
91
105
|
" providers: [",
|
|
92
106
|
" Credentials({",
|
|
@@ -97,7 +111,7 @@ export async function writeSaasFiles(appDir, opts = {}) {
|
|
|
97
111
|
" },",
|
|
98
112
|
" }),",
|
|
99
113
|
" ],",
|
|
100
|
-
" secret:
|
|
114
|
+
" secret: authSecret,",
|
|
101
115
|
"});",
|
|
102
116
|
"",
|
|
103
117
|
].join('\n'));
|
|
@@ -324,7 +338,7 @@ export async function writeSaasFiles(appDir, opts = {}) {
|
|
|
324
338
|
" <div class=\"max-w-sm mx-auto mt-12\">",
|
|
325
339
|
" <div class=${cardClass()}>",
|
|
326
340
|
" <div class=${cardHeaderClass()}>",
|
|
327
|
-
" <
|
|
341
|
+
" <h1 class=${cardTitleClass()}>Sign in</h1>",
|
|
328
342
|
" <p class=${cardDescriptionClass()}>Welcome back: log in to continue.</p>",
|
|
329
343
|
" </div>",
|
|
330
344
|
" <div class=${cardContentClass()}>",
|
|
@@ -393,7 +407,7 @@ export async function writeSaasFiles(appDir, opts = {}) {
|
|
|
393
407
|
" <div class=\"max-w-sm mx-auto mt-12\">",
|
|
394
408
|
" <div class=${cardClass()}>",
|
|
395
409
|
" <div class=${cardHeaderClass()}>",
|
|
396
|
-
" <
|
|
410
|
+
" <h1 class=${cardTitleClass()}>Create an account</h1>",
|
|
397
411
|
" <p class=${cardDescriptionClass()}>Get started with your new workspace.</p>",
|
|
398
412
|
" </div>",
|
|
399
413
|
" <div class=${cardContentClass()}>",
|
|
@@ -460,7 +474,7 @@ export async function writeSaasFiles(appDir, opts = {}) {
|
|
|
460
474
|
" </div>",
|
|
461
475
|
" <div class=${cardClass()}>",
|
|
462
476
|
" <div class=${cardHeaderClass()}>",
|
|
463
|
-
" <
|
|
477
|
+
" <h2 class=${cardTitleClass()}>Welcome, ${user?.name || user?.email}!</h2>",
|
|
464
478
|
" <p class=${cardDescriptionClass()}>You're authenticated. Replace this scaffold with your real app.</p>",
|
|
465
479
|
" </div>",
|
|
466
480
|
" <div class=${cardContentClass()}>",
|
|
@@ -486,7 +500,7 @@ export async function writeSaasFiles(appDir, opts = {}) {
|
|
|
486
500
|
" <h1 class=\"text-2xl font-semibold mb-6\">Settings</h1>",
|
|
487
501
|
" <div class=${cardClass()}>",
|
|
488
502
|
" <div class=${cardHeaderClass()}>",
|
|
489
|
-
" <
|
|
503
|
+
" <h2 class=${cardTitleClass()}>Account</h2>",
|
|
490
504
|
" <p class=${cardDescriptionClass()}>Your basic profile information.</p>",
|
|
491
505
|
" </div>",
|
|
492
506
|
" <div class=${cardContentClass()}>",
|
package/package.json
CHANGED
|
@@ -157,7 +157,7 @@ self-review loop.
|
|
|
157
157
|
explicit fields plus constructor body assignments instead of parameter
|
|
158
158
|
properties. If `erasableSyntaxOnly` is disabled and non-erasable syntax is
|
|
159
159
|
used, the dev server fails at strip time and returns a 500 pointing at the
|
|
160
|
-
`no-non-erasable-typescript` lint rule.
|
|
160
|
+
`no-non-erasable-typescript` lint rule. WebJs is buildless end-to-end and
|
|
161
161
|
has no bundler fallback.
|
|
162
162
|
- Web components render into light DOM by default (so Tailwind / global CSS
|
|
163
163
|
apply directly). Opt in to shadow DOM per component with
|
|
@@ -149,7 +149,7 @@ each change must include.
|
|
|
149
149
|
|
|
150
150
|
- No build step: source files are served as ES modules. Don't introduce
|
|
151
151
|
build tools or bundlers in the critical path.
|
|
152
|
-
- **Erasable TypeScript only.** The runtime strips types via `module.stripTypeScriptTypes` (Node's built-in, or `amaro` on Bun) (whitespace replacement, byte-exact position preservation, no sourcemap). The scaffold's tsconfig.json sets `erasableSyntaxOnly: true`, so the TS compiler rejects `enum`, `namespace` with values, constructor parameter properties, legacy decorators with `emitDecoratorMetadata`, and `import = require`. Use erasable equivalents: `const X = { ... } as const` plus a derived union type instead of `enum`; explicit fields plus constructor body assignments instead of parameter properties. If `erasableSyntaxOnly` is disabled and non-erasable syntax is used, the dev server fails at strip time and returns a 500 pointing at the `no-non-erasable-typescript` lint rule.
|
|
152
|
+
- **Erasable TypeScript only.** The runtime strips types via `module.stripTypeScriptTypes` (Node's built-in, or `amaro` on Bun) (whitespace replacement, byte-exact position preservation, no sourcemap). The scaffold's tsconfig.json sets `erasableSyntaxOnly: true`, so the TS compiler rejects `enum`, `namespace` with values, constructor parameter properties, legacy decorators with `emitDecoratorMetadata`, and `import = require`. Use erasable equivalents: `const X = { ... } as const` plus a derived union type instead of `enum`; explicit fields plus constructor body assignments instead of parameter properties. If `erasableSyntaxOnly` is disabled and non-erasable syntax is used, the dev server fails at strip time and returns a 500 pointing at the `no-non-erasable-typescript` lint rule. WebJs is buildless end-to-end and has no bundler fallback.
|
|
153
153
|
- Tagged template: html`<div>${value}</div>` with css`...` for styles.
|
|
154
154
|
- **Tailwind-first styling.** Tailwind utilities are the strong default for pages AND light-DOM components (the default DOM mode): layout, spacing, color (via `@theme` tokens), typography, borders, radius, shadows, interaction states. Light DOM does not scope, so utilities apply directly. The lit reflex to scope CSS (`static styles = css\`...\``) or write an inline `<style>` with semantic class names (`.hero`, `.card`) in a light-DOM component is wrong: the scoped block needs `static shadow = true`, and inline class names leak globally. When a utility bundle repeats, extract a `lib/utils/ui.ts` helper returning an `html` fragment, not a CSS class. Reserve raw CSS for the allowlist (design tokens / `@theme`, `@property` + `@keyframes`, `::-webkit-scrollbar`, `prefers-reduced-motion`, complex `color-mix()` / gradients); when unavoidable in a light-DOM component, prefix every class selector with the component tag. Shadow-DOM components (`static shadow = true`) legitimately author `static styles = css\`...\`` for scoped CSS; don't use inline `style="..."` there.
|
|
155
155
|
- **One theme, canonical tokens.** The app has a SINGLE theme, defined once in `app/layout.ts` using the standard `@webjsdev/ui` (shadcn-compatible) semantic tokens set to the brand palette. Use the canonical utility names everywhere, in the page chrome AND inside components: `bg-background`, `text-foreground`, `bg-card`, `bg-muted`, `text-muted-foreground`, `bg-primary`, `text-primary-foreground`, `bg-accent`, `text-accent-foreground`, `border-border`, `ring-ring`. These are exactly the tokens a component copied in by `webjs ui add <name>` reads, so a scaffolded page and a later-added ui component share one theme with no wiring. NEVER invent a parallel token vocabulary (`--fg`, `--bg`, `text-fg`, `bg-elev`, a separate `--brand`): it collides with the ui tokens (the accent once flipped to neutral on navigation for exactly this reason) and diverges from the shadcn conventions the kit and AI agents expect. Reach for opacity modifiers (`bg-primary/10`, `hover:bg-primary/90`) before adding a token; add one the canonical way (a `--x` var plus a `--color-x: var(--x)` line in `@theme inline`).
|
package/templates/AGENTS.md
CHANGED
|
@@ -342,7 +342,7 @@ app/ ROUTING ONLY: thin route adapters (import from modules/
|
|
|
342
342
|
styles/, lib/utils/, and modules/. globals.css is at
|
|
343
343
|
styles/, NOT app/.
|
|
344
344
|
page.ts → / (the scaffold home links to the gallery)
|
|
345
|
-
features/<name>/ single-feature demos (routing, components,
|
|
345
|
+
features/<name>/ single-feature demos (routing, boundaries, components,
|
|
346
346
|
server-actions, optimistic-ui, async-render,
|
|
347
347
|
directives, route-handler, forms, metadata, caching,
|
|
348
348
|
env, client-router, service-worker); prune what you skip
|
|
@@ -405,9 +405,10 @@ instrumentation-client.ts optional client boot hook, runs first before app modu
|
|
|
405
405
|
The scaffold ships a gallery organized by KIND, so features and whole apps are
|
|
406
406
|
not mixed:
|
|
407
407
|
- `app/features/<name>/` are single-feature demos, one webjs concept each
|
|
408
|
-
(routing, components, server-actions, optimistic-ui, async-render,
|
|
409
|
-
route-handler, forms, metadata, caching, env, client-router,
|
|
410
|
-
plus the infra demos websockets, file-storage, rate-limit,
|
|
408
|
+
(routing, boundaries, components, server-actions, optimistic-ui, async-render,
|
|
409
|
+
directives, route-handler, forms, metadata, caching, env, client-router,
|
|
410
|
+
service-worker, plus the infra demos websockets, file-storage, rate-limit,
|
|
411
|
+
broadcast).
|
|
411
412
|
- `app/examples/<name>/` are whole example apps that compose several features
|
|
412
413
|
(todo: optimistic UI + progressive enhancement + a11y + db + modules).
|
|
413
414
|
|
|
@@ -501,7 +502,7 @@ an npm `prestart` hook.
|
|
|
501
502
|
|
|
502
503
|
### Running on Bun instead of Node
|
|
503
504
|
|
|
504
|
-
|
|
505
|
+
WebJs runs on **Node 24+ or Bun**. The same `package.json` scripts work on
|
|
505
506
|
either; to run under Bun, force it with `--bun` so the server executes on Bun
|
|
506
507
|
rather than the `webjs` bin's Node shebang:
|
|
507
508
|
|
|
@@ -1384,7 +1385,7 @@ composition, so a nested shell ends up dropped by the HTML parser.
|
|
|
1384
1385
|
If you turn `erasableSyntaxOnly` off and use non-erasable syntax,
|
|
1385
1386
|
the dev server fails at strip time and returns a 500 naming the
|
|
1386
1387
|
file and pointing at the `no-non-erasable-typescript` lint rule.
|
|
1387
|
-
|
|
1388
|
+
WebJs is buildless end-to-end and has no bundler fallback. The
|
|
1388
1389
|
`erasable-typescript-only` convention check warns when the flag
|
|
1389
1390
|
is missing or set to false.
|
|
1390
1391
|
9. **No em-dashes (U+2014) anywhere, and no hyphen or semicolon used
|
package/templates/CONVENTIONS.md
CHANGED
|
@@ -317,7 +317,7 @@ api / saas templates) is a **starting point**.
|
|
|
317
317
|
|
|
318
318
|
The full-stack and saas scaffolds ship a **gallery** organized by kind so features
|
|
319
319
|
and whole apps are not mixed. `app/features/<name>/` are single-feature
|
|
320
|
-
demos, one webjs concept each (routing, components, server-actions,
|
|
320
|
+
demos, one webjs concept each (routing, boundaries, components, server-actions,
|
|
321
321
|
optimistic-ui, async-render, directives, route-handler). `app/examples/<name>/`
|
|
322
322
|
are whole example apps that compose several features (todo: optimistic UI
|
|
323
323
|
+ progressive enhancement + a11y + db + modules). Both keep their logic in
|
|
@@ -430,7 +430,7 @@ features and examples as reference, never blindly up front. This is a no-op for
|
|
|
430
430
|
## Sensible defaults
|
|
431
431
|
|
|
432
432
|
<!-- OVERRIDE -->
|
|
433
|
-
|
|
433
|
+
WebJs uses sensible defaults. Environment
|
|
434
434
|
variables control infrastructure (no config files needed):
|
|
435
435
|
|
|
436
436
|
| Environment variable | Effect |
|
|
@@ -1066,7 +1066,7 @@ auth middleware.
|
|
|
1066
1066
|
|
|
1067
1067
|
<!-- OVERRIDE -->
|
|
1068
1068
|
|
|
1069
|
-
|
|
1069
|
+
WebJs pages work without JavaScript by design. Read-paths render to
|
|
1070
1070
|
real HTML on the server. Write-paths run through plain `<form>` plus
|
|
1071
1071
|
server actions, and navigation is a real `<a href>`. Every web component
|
|
1072
1072
|
is SSR'd too. Its `render()` runs on the server, so the component's
|
|
@@ -1248,7 +1248,7 @@ is better). See `agent-docs/advanced.md` for the full API.
|
|
|
1248
1248
|
constructor(x: number) { this.x = x; }
|
|
1249
1249
|
}
|
|
1250
1250
|
```
|
|
1251
|
-
If you turn `erasableSyntaxOnly` off and use non-erasable syntax, the dev server fails at strip time and returns a 500 pointing at the `no-non-erasable-typescript` lint rule.
|
|
1251
|
+
If you turn `erasableSyntaxOnly` off and use non-erasable syntax, the dev server fails at strip time and returns a 500 pointing at the `no-non-erasable-typescript` lint rule. WebJs is buildless end-to-end and has no bundler fallback. The `erasable-typescript-only` convention check warns when the flag is off.
|
|
1252
1252
|
- No semicolons (or with semicolons, pick one and stay consistent)
|
|
1253
1253
|
- `const` by default, `let` when needed, never `var`
|
|
1254
1254
|
- Prefer `async/await` over `.then()` chains
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// A page that throws a real render error, so the nearest error.ts boundary
|
|
2
|
+
// (../error.ts) catches it. In a real app an unexpected throw (a failed query,
|
|
3
|
+
// a bug) lands here; expected failures should return an ActionResult or throw
|
|
4
|
+
// notFound() / forbidden() instead.
|
|
5
|
+
export default function Crash() {
|
|
6
|
+
throw new Error('demo: this page threw during render');
|
|
7
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// error.ts is the error boundary for this segment's subtree. A render-time
|
|
2
|
+
// exception in a sibling or deeper page (see crash/page.ts) is caught here and
|
|
3
|
+
// rendered scoped to this boundary, so outer layouts stay alive. The default
|
|
4
|
+
// export receives { error, ...ctx }; in production only error.message is sent.
|
|
5
|
+
import { html } from '@webjsdev/core';
|
|
6
|
+
|
|
7
|
+
export default function BoundariesError({ error }: { error: Error }) {
|
|
8
|
+
return html`
|
|
9
|
+
<h1 class="text-h2 font-bold mb-4">Something went wrong</h1>
|
|
10
|
+
<p class="text-muted-foreground mb-4">
|
|
11
|
+
This segment's <code class="font-mono">error.ts</code> boundary caught a
|
|
12
|
+
render error: <code class="font-mono">${error?.message ?? 'unknown'}</code>.
|
|
13
|
+
</p>
|
|
14
|
+
<p><a class="text-primary" href="/features/boundaries">Back to boundaries</a></p>
|
|
15
|
+
`;
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The nearest 403 boundary for a thrown forbidden(). A forbidden.ts default-
|
|
2
|
+
// exports a function returning a TemplateResult, rendered at status 403 in place
|
|
3
|
+
// of the page that threw. Nearest wins: this one (inside gated/) beats any
|
|
4
|
+
// forbidden.ts higher up the tree. Keep the message actionable for an
|
|
5
|
+
// authenticated user who lacks permission.
|
|
6
|
+
import { html } from '@webjsdev/core';
|
|
7
|
+
|
|
8
|
+
export default function Forbidden() {
|
|
9
|
+
return html`
|
|
10
|
+
<h1 class="text-h2 font-bold mb-4">403 Forbidden</h1>
|
|
11
|
+
<p class="text-muted-foreground mb-4">
|
|
12
|
+
You are signed in but do not have permission to view this page. This is the
|
|
13
|
+
nearest <code class="font-mono">forbidden.ts</code> boundary, rendered
|
|
14
|
+
because the page threw <code class="font-mono">forbidden()</code>.
|
|
15
|
+
</p>
|
|
16
|
+
<p><a class="text-primary" href="/features/boundaries">Back to boundaries</a></p>
|
|
17
|
+
`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// A page that throws forbidden(). In a real app you would call this only when
|
|
2
|
+
// an authenticated user lacks permission (checked against the session). Here it
|
|
3
|
+
// throws unconditionally so the demo always shows the 403 boundary. The nearest
|
|
4
|
+
// forbidden.ts (this folder's sibling) renders in place of this page at status
|
|
5
|
+
// 403; without a nearest one the framework renders a default 403 page.
|
|
6
|
+
import { forbidden } from '@webjsdev/core';
|
|
7
|
+
|
|
8
|
+
export default function Gated() {
|
|
9
|
+
forbidden();
|
|
10
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// loading.ts auto-wraps the sibling page in a Suspense boundary: its default
|
|
2
|
+
// export is the fallback shown while an async page (or its streamed regions)
|
|
3
|
+
// resolves. The boundaries index is fast, so you rarely see this, but any async
|
|
4
|
+
// page in this subtree gets this skeleton for free during navigation.
|
|
5
|
+
import { html } from '@webjsdev/core';
|
|
6
|
+
|
|
7
|
+
export default function BoundariesLoading() {
|
|
8
|
+
return html`
|
|
9
|
+
<div class="animate-pulse">
|
|
10
|
+
<div class="h-7 w-40 rounded bg-muted mb-4"></div>
|
|
11
|
+
<div class="h-4 w-full rounded bg-muted mb-2"></div>
|
|
12
|
+
<div class="h-4 w-3/4 rounded bg-muted"></div>
|
|
13
|
+
</div>
|
|
14
|
+
`;
|
|
15
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// not-found.ts is the nearest 404 boundary for this subtree. It renders both
|
|
2
|
+
// for a thrown notFound() in a page below here AND for an unmatched URL under
|
|
3
|
+
// /features/boundaries/ (try /features/boundaries/does-not-exist). Nearest wins,
|
|
4
|
+
// so this beats the root not-found for anything in this segment.
|
|
5
|
+
import { html } from '@webjsdev/core';
|
|
6
|
+
|
|
7
|
+
export default function BoundariesNotFound() {
|
|
8
|
+
return html`
|
|
9
|
+
<h1 class="text-h2 font-bold mb-4">404: Not here</h1>
|
|
10
|
+
<p class="text-muted-foreground mb-4">
|
|
11
|
+
This segment's <code class="font-mono">not-found.ts</code> boundary rendered,
|
|
12
|
+
because a page threw <code class="font-mono">notFound()</code> or the URL
|
|
13
|
+
matched nothing under this segment.
|
|
14
|
+
</p>
|
|
15
|
+
<p><a class="text-primary" href="/features/boundaries">Back to boundaries</a></p>
|
|
16
|
+
`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// webjs-scaffold-placeholder. Feature gallery route. Keep and adapt it, or prune it (delete this app/features/boundaries route), then delete this marker line. webjs check fails while the marker remains.
|
|
2
|
+
// Boundaries: the four control-flow throws and their boundary files. A page (or
|
|
3
|
+
// a page `action`) can THROW to short-circuit rendering, and the framework
|
|
4
|
+
// renders the NEAREST matching boundary file in the route's ancestor chain
|
|
5
|
+
// (innermost wins), exactly like an error boundary:
|
|
6
|
+
// notFound() -> the nearest not-found.ts (404)
|
|
7
|
+
// forbidden() -> the nearest forbidden.ts (403, authenticated but not allowed)
|
|
8
|
+
// unauthorized() -> the nearest unauthorized.ts (401, not authenticated)
|
|
9
|
+
// redirect(url) -> an HTTP 3xx (no boundary file; it sends a Location)
|
|
10
|
+
// Two boundaries are ROOT-ONLY, so they live at the app root, not here under a
|
|
11
|
+
// feature folder: global-error.ts (the app-wide catch-all, owns its own <html>)
|
|
12
|
+
// and global-not-found.ts (a 404 for a URL that matches nothing anywhere).
|
|
13
|
+
//
|
|
14
|
+
// This demo ships two live sub-routes. Visit each and the page throws, so you
|
|
15
|
+
// see the nearest boundary render in place of the page:
|
|
16
|
+
// /features/boundaries/gated throws forbidden() -> gated/forbidden.ts
|
|
17
|
+
// /features/boundaries/private throws unauthorized() -> private/unauthorized.ts
|
|
18
|
+
import { html } from '@webjsdev/core';
|
|
19
|
+
import type { Metadata } from '@webjsdev/core';
|
|
20
|
+
|
|
21
|
+
export const metadata: Metadata = { title: 'Boundaries (throws + boundary files) | features' };
|
|
22
|
+
|
|
23
|
+
export default function BoundariesExample() {
|
|
24
|
+
return html`
|
|
25
|
+
<h1 class="text-h2 font-bold mb-4">Boundaries</h1>
|
|
26
|
+
<p class="text-muted-foreground mb-4">
|
|
27
|
+
Throw a control-flow function from a page (or a page
|
|
28
|
+
<code class="font-mono">action</code>) to short-circuit the render. The
|
|
29
|
+
framework catches it and renders the nearest matching boundary file in the
|
|
30
|
+
route's chain, innermost wins.
|
|
31
|
+
</p>
|
|
32
|
+
<ul class="list-disc pl-5 mb-4">
|
|
33
|
+
<li>
|
|
34
|
+
<a class="text-primary" href="/features/boundaries/gated">/features/boundaries/gated</a>
|
|
35
|
+
throws <code class="font-mono">forbidden()</code>, caught by
|
|
36
|
+
<code class="font-mono">gated/forbidden.ts</code> (403).
|
|
37
|
+
</li>
|
|
38
|
+
<li>
|
|
39
|
+
<a class="text-primary" href="/features/boundaries/private">/features/boundaries/private</a>
|
|
40
|
+
throws <code class="font-mono">unauthorized()</code>, caught by
|
|
41
|
+
<code class="font-mono">private/unauthorized.ts</code> (401).
|
|
42
|
+
</li>
|
|
43
|
+
<li>
|
|
44
|
+
<a class="text-primary" href="/features/boundaries/crash">/features/boundaries/crash</a>
|
|
45
|
+
throws a render error, caught by this segment's
|
|
46
|
+
<code class="font-mono">error.ts</code> (500).
|
|
47
|
+
</li>
|
|
48
|
+
<li>
|
|
49
|
+
<a class="text-primary" href="/features/boundaries/does-not-exist">/features/boundaries/does-not-exist</a>
|
|
50
|
+
matches nothing, caught by the nearest
|
|
51
|
+
<code class="font-mono">not-found.ts</code> (404).
|
|
52
|
+
</li>
|
|
53
|
+
</ul>
|
|
54
|
+
<p class="text-muted-foreground text-sm mb-2">
|
|
55
|
+
<code class="font-mono">forbidden()</code> is for an authenticated user who
|
|
56
|
+
lacks permission (403); <code class="font-mono">unauthorized()</code> is for
|
|
57
|
+
a request that is not authenticated at all (401). Both import from
|
|
58
|
+
<code class="font-mono">@webjsdev/core</code> and are thrown, never returned.
|
|
59
|
+
</p>
|
|
60
|
+
<p class="text-muted-foreground text-sm mb-2">
|
|
61
|
+
Same throw model as <code class="font-mono">notFound()</code> (renders the
|
|
62
|
+
nearest <code class="font-mono">not-found.ts</code>) and
|
|
63
|
+
<code class="font-mono">redirect(url)</code> (sends an HTTP 3xx). Inside a
|
|
64
|
+
<code class="font-mono">'use server'</code> RPC action, return an
|
|
65
|
+
<code class="font-mono">ActionResult</code> for an auth failure instead of
|
|
66
|
+
throwing, since a raw throw there is a generic 500.
|
|
67
|
+
</p>
|
|
68
|
+
<p class="text-muted-foreground text-sm">
|
|
69
|
+
Two boundaries are root-only and live at the app root:
|
|
70
|
+
<code class="font-mono">app/global-error.ts</code> (the app-wide catch-all,
|
|
71
|
+
which renders its own <code class="font-mono"><html></code> document)
|
|
72
|
+
and <code class="font-mono">app/global-not-found.ts</code> (a 404 for a URL
|
|
73
|
+
that matches nothing anywhere).
|
|
74
|
+
</p>
|
|
75
|
+
<p class="mt-3"><a class="text-primary" href="/">Back to the gallery</a></p>
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// A page that throws unauthorized(). In a real app you would call this when the
|
|
2
|
+
// request is not authenticated (no valid session), typically to prompt a sign-in.
|
|
3
|
+
// Here it throws unconditionally so the demo always shows the 401 boundary. The
|
|
4
|
+
// nearest unauthorized.ts (this folder's sibling) renders in place of this page
|
|
5
|
+
// at status 401; without a nearest one the framework renders a default 401 page.
|
|
6
|
+
import { unauthorized } from '@webjsdev/core';
|
|
7
|
+
|
|
8
|
+
export default function Private() {
|
|
9
|
+
unauthorized();
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The nearest 401 boundary for a thrown unauthorized(). An unauthorized.ts
|
|
2
|
+
// default-exports a function returning a TemplateResult, rendered at status 401
|
|
3
|
+
// in place of the page that threw. Nearest wins: this one (inside private/) beats
|
|
4
|
+
// any unauthorized.ts higher up the tree. A real one usually links to sign-in.
|
|
5
|
+
import { html } from '@webjsdev/core';
|
|
6
|
+
|
|
7
|
+
export default function Unauthorized() {
|
|
8
|
+
return html`
|
|
9
|
+
<h1 class="text-h2 font-bold mb-4">401 Unauthorized</h1>
|
|
10
|
+
<p class="text-muted-foreground mb-4">
|
|
11
|
+
You need to sign in to view this page. This is the nearest
|
|
12
|
+
<code class="font-mono">unauthorized.ts</code> boundary, rendered because the
|
|
13
|
+
page threw <code class="font-mono">unauthorized()</code>.
|
|
14
|
+
</p>
|
|
15
|
+
<p><a class="text-primary" href="/features/boundaries">Back to boundaries</a></p>
|
|
16
|
+
`;
|
|
17
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// JS off, every link is a normal full-page navigation.
|
|
9
9
|
import { html } from '@webjsdev/core';
|
|
10
10
|
import type { Metadata } from '@webjsdev/core';
|
|
11
|
+
import '#modules/client-router/components/router-controls.ts';
|
|
11
12
|
|
|
12
13
|
export const metadata: Metadata = { title: 'Client router (soft nav) | features' };
|
|
13
14
|
|
|
@@ -24,6 +25,8 @@ export default function ClientRouterExample() {
|
|
|
24
25
|
<a href="/features/client-router/second" 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]">Go to page two</a>
|
|
25
26
|
<a href="/" class="text-muted-foreground no-underline font-medium text-sm hover:text-foreground transition-colors">Home</a>
|
|
26
27
|
</div>
|
|
28
|
+
<p class="text-muted-foreground text-sm mt-6 mb-2">Or drive it from JS with <code class="font-mono">navigate()</code> / <code class="font-mono">revalidate()</code>:</p>
|
|
29
|
+
<router-controls></router-controls>
|
|
27
30
|
<p class="text-muted-foreground text-sm mt-6">
|
|
28
31
|
Opt out app-wide with <code class="font-mono">{ "webjs": { "clientRouter": false } }</code>,
|
|
29
32
|
or per-link with <code class="font-mono">data-no-router</code> (use it for
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { html } from '@webjsdev/core';
|
|
3
3
|
import type { Metadata } from '@webjsdev/core';
|
|
4
4
|
import '#modules/components/components/counter-card.ts';
|
|
5
|
+
import '#modules/components/components/reactive-meter.ts';
|
|
5
6
|
|
|
6
7
|
export const metadata: Metadata = { title: 'Components (signals + slots) | features' };
|
|
7
8
|
|
|
@@ -10,5 +11,7 @@ export default function ComponentsExample() {
|
|
|
10
11
|
<h1 class="text-h2 font-bold mb-4">Components</h1>
|
|
11
12
|
<p class="text-muted-foreground mb-4">The WebComponent factory, a reactive prop, an instance signal, and a slot.</p>
|
|
12
13
|
<counter-card label="Taps"><strong>A slotted title</strong></counter-card>
|
|
14
|
+
<p class="text-muted-foreground mt-6 mb-2">Shadow DOM (scoped <code class="font-mono">css</code>) plus the rest of the signals API (<code class="font-mono">computed</code>, <code class="font-mono">effect</code>, <code class="font-mono">batch</code>):</p>
|
|
15
|
+
<reactive-meter></reactive-meter>
|
|
13
16
|
`;
|
|
14
17
|
}
|
|
@@ -42,7 +42,7 @@ export default function FileStorageExample({
|
|
|
42
42
|
<code class="font-mono">setFileStore()</code> call, no call-site change.
|
|
43
43
|
</p>
|
|
44
44
|
<form method="post" enctype="multipart/form-data" class="flex flex-wrap gap-3 items-center mb-4">
|
|
45
|
-
<input type="file" name="file" required
|
|
45
|
+
<input type="file" name="file" required aria-label="Choose a file to upload"
|
|
46
46
|
class="text-sm text-muted-foreground file:mr-3 file:px-3.5 file:py-2 file:rounded-xl file:border-0 file:bg-card file:border file:border-border file:text-foreground file:text-sm file:cursor-pointer" />
|
|
47
47
|
<button type="submit"
|
|
48
48
|
class="px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Upload</button>
|
|
@@ -54,7 +54,7 @@ export default function FileStorageExample({
|
|
|
54
54
|
? html`
|
|
55
55
|
<div class="px-4 py-3 rounded-xl bg-card border border-border text-sm">
|
|
56
56
|
Stored <span class="text-foreground font-medium">${name}</span>
|
|
57
|
-
<span class="text-muted-foreground
|
|
57
|
+
<span class="text-muted-foreground">(${size} bytes)</span>
|
|
58
58
|
<a class="text-primary no-underline ml-2" href="/features/file-storage/file/${key}">download</a>
|
|
59
59
|
</div>`
|
|
60
60
|
: ''}
|
|
@@ -26,7 +26,7 @@ const field = (label: string, name: string, input: unknown, error?: string) => h
|
|
|
26
26
|
</div>
|
|
27
27
|
`;
|
|
28
28
|
|
|
29
|
-
const inputCls = 'w-full bg-background border border-border rounded-xl px-3 py-2 text-[15px] text-foreground outline-none transition-colors focus:border-primary placeholder:text-muted-foreground
|
|
29
|
+
const inputCls = 'w-full bg-background border border-border rounded-xl px-3 py-2 text-[15px] text-foreground outline-none transition-colors focus:border-primary placeholder:text-muted-foreground';
|
|
30
30
|
|
|
31
31
|
export default function FormsFeature({ searchParams, actionData }: { searchParams: Record<string, string | undefined>; actionData?: Result }) {
|
|
32
32
|
if (searchParams.sent) {
|
|
@@ -12,12 +12,16 @@
|
|
|
12
12
|
// params, so PageProps<'/features/routing/[id]'>['params'] narrows to
|
|
13
13
|
// { id: string } automatically. Rename the folder and the type follows; pass a
|
|
14
14
|
// route literal that does not exist and it is a compile error.
|
|
15
|
-
import { html } from '@webjsdev/core';
|
|
15
|
+
import { html, notFound } from '@webjsdev/core';
|
|
16
16
|
import type { PageProps } from '@webjsdev/core';
|
|
17
17
|
|
|
18
18
|
export default async function RoutingParam({ params }: PageProps<'/features/routing/[id]'>) {
|
|
19
19
|
// The Next-style await also works; `params.id` sync would be identical.
|
|
20
20
|
const { id } = await params;
|
|
21
|
+
// Throw notFound() to short-circuit into the nearest not-found boundary (404).
|
|
22
|
+
// Here the reserved id "missing" stands in for "no such record"; in a real app
|
|
23
|
+
// you throw this after a DB lookup returns nothing. Try /features/routing/missing.
|
|
24
|
+
if (id === 'missing') notFound();
|
|
21
25
|
return html`
|
|
22
26
|
<h1 class="text-h2 font-bold mb-4">Route param</h1>
|
|
23
27
|
<p>The <code>[id]</code> segment is: <strong>${id}</strong></p>
|
|
@@ -28,6 +32,14 @@ export default async function RoutingParam({ params }: PageProps<'/features/rout
|
|
|
28
32
|
<code class="font-mono">params</code> is awaitable too:
|
|
29
33
|
<code class="font-mono">const { id } = await params</code> works, same value.
|
|
30
34
|
</p>
|
|
35
|
+
<p class="text-muted-foreground text-sm mt-3">
|
|
36
|
+
Throwing wins over rendering: <a class="text-primary" href="/features/routing/missing">/features/routing/missing</a>
|
|
37
|
+
throws <code class="font-mono">notFound()</code> and renders the nearest
|
|
38
|
+
not-found boundary at 404. See the
|
|
39
|
+
<a class="text-primary" href="/features/boundaries">Boundaries</a> demo for
|
|
40
|
+
<code class="font-mono">forbidden()</code> and
|
|
41
|
+
<code class="font-mono">unauthorized()</code>.
|
|
42
|
+
</p>
|
|
31
43
|
<p class="mt-3"><a class="text-primary" href="/features/routing">Back</a></p>
|
|
32
44
|
`;
|
|
33
45
|
}
|
|
@@ -43,5 +43,13 @@ export default function RoutingExample() {
|
|
|
43
43
|
<code class="font-mono">redirect()</code> on the server, call
|
|
44
44
|
<code class="font-mono">navigate()</code> on the client.
|
|
45
45
|
</p>
|
|
46
|
+
<p class="text-muted-foreground text-sm mt-3">
|
|
47
|
+
A page can also THROW to short-circuit rendering:
|
|
48
|
+
<code class="font-mono">notFound()</code> (404),
|
|
49
|
+
<code class="font-mono">forbidden()</code> (403), and
|
|
50
|
+
<code class="font-mono">unauthorized()</code> (401), each rendering the
|
|
51
|
+
nearest matching boundary file. See the
|
|
52
|
+
<a class="text-primary" href="/features/boundaries">Boundaries</a> demo.
|
|
53
|
+
</p>
|
|
46
54
|
`;
|
|
47
55
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// webjs-scaffold-placeholder. Metadata route. Keep and adapt it, or prune it
|
|
2
|
+
// (delete this file), then delete this marker line. webjs check fails while the
|
|
3
|
+
// marker remains.
|
|
4
|
+
//
|
|
5
|
+
// app/manifest.ts serves /manifest.json (the web app manifest). The default
|
|
6
|
+
// export returns an object, serialized to JSON. Adapt the name, colors, and
|
|
7
|
+
// icons to your app; pair it with the opt-in service worker for an installable
|
|
8
|
+
// PWA. See agent-docs/service-worker.md. (Gallery files are copied verbatim, so
|
|
9
|
+
// set the real app name here by hand rather than expecting substitution.)
|
|
10
|
+
export default function Manifest() {
|
|
11
|
+
return {
|
|
12
|
+
name: 'webjs app',
|
|
13
|
+
short_name: 'webjs app',
|
|
14
|
+
start_url: '/',
|
|
15
|
+
display: 'standalone',
|
|
16
|
+
background_color: '#ffffff',
|
|
17
|
+
theme_color: '#1c1613',
|
|
18
|
+
icons: [
|
|
19
|
+
{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// webjs-scaffold-placeholder. Metadata route. Keep and adapt it, or prune it
|
|
2
|
+
// (delete this file), then delete this marker line. webjs check fails while the
|
|
3
|
+
// marker remains.
|
|
4
|
+
//
|
|
5
|
+
// app/robots.ts serves /robots.txt. The default export returns a string (served
|
|
6
|
+
// as text/plain) or an object. This example allows all crawlers and points them
|
|
7
|
+
// at the sitemap. Tighten the rules (Disallow paths) for a real app.
|
|
8
|
+
const SITE_URL = (process.env.SITE_URL || 'http://localhost:8080').replace(/\/$/, '');
|
|
9
|
+
|
|
10
|
+
export default function Robots() {
|
|
11
|
+
return [
|
|
12
|
+
'User-agent: *',
|
|
13
|
+
'Allow: /',
|
|
14
|
+
`Sitemap: ${SITE_URL}/sitemap.xml`,
|
|
15
|
+
].join('\n');
|
|
16
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// webjs-scaffold-placeholder. Metadata route. Keep and adapt it to your real
|
|
2
|
+
// routes, or prune it (delete this file), then delete this marker line. webjs
|
|
3
|
+
// check fails while the marker remains.
|
|
4
|
+
//
|
|
5
|
+
// app/sitemap.ts serves /sitemap.xml. The default export is a (possibly async)
|
|
6
|
+
// server function; `sitemap(entries)` from @webjsdev/server serializes a
|
|
7
|
+
// spec-valid XML sitemap. In a real app, build the entries from your content
|
|
8
|
+
// (a query over posts/products), so new content is discoverable without editing
|
|
9
|
+
// this file. Here it lists the gallery's static routes as an example.
|
|
10
|
+
import { sitemap } from '@webjsdev/server';
|
|
11
|
+
|
|
12
|
+
const SITE_URL = (process.env.SITE_URL || 'http://localhost:8080').replace(/\/$/, '');
|
|
13
|
+
|
|
14
|
+
export default function Sitemap() {
|
|
15
|
+
const routes = ['/', '/features/routing', '/features/boundaries'];
|
|
16
|
+
return sitemap(
|
|
17
|
+
routes.map((path) => ({
|
|
18
|
+
url: `${SITE_URL}${path}`,
|
|
19
|
+
changeFrequency: 'weekly' as const,
|
|
20
|
+
priority: path === '/' ? 1.0 : 0.7,
|
|
21
|
+
})),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Programmatic client navigation. `navigate(url)` does the same soft, in-place
|
|
2
|
+
// swap an <a> click does, but from an event handler (after a save, a wizard
|
|
3
|
+
// step, etc.). `revalidate(url?)` evicts the browser snapshot cache so the next
|
|
4
|
+
// visit refetches fresh HTML instead of the cached page. Both are client-only
|
|
5
|
+
// (they run in the browser), so a component is the right home; a page/layout
|
|
6
|
+
// never hydrates. With JS off this component is inert, so keep real navigation
|
|
7
|
+
// on plain <a href> and use navigate() only for JS-driven flows.
|
|
8
|
+
import { WebComponent, html, navigate, revalidate } from '@webjsdev/core';
|
|
9
|
+
|
|
10
|
+
export class RouterControls extends WebComponent {
|
|
11
|
+
render() {
|
|
12
|
+
return html`
|
|
13
|
+
<div class="flex gap-3 items-center">
|
|
14
|
+
<button
|
|
15
|
+
@click=${() => navigate('/features/client-router/second')}
|
|
16
|
+
class="inline-flex items-center px-4 py-2 rounded-xl bg-card border border-border text-foreground font-medium text-sm cursor-pointer transition-colors hover:border-border-strong">navigate() to page two</button>
|
|
17
|
+
<button
|
|
18
|
+
@click=${() => revalidate()}
|
|
19
|
+
class="text-muted-foreground font-medium text-sm cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">revalidate() the snapshot cache</button>
|
|
20
|
+
</div>
|
|
21
|
+
`;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
RouterControls.register('router-controls');
|
|
@@ -24,7 +24,7 @@ export class CounterCard extends WebComponent({
|
|
|
24
24
|
<slot></slot>
|
|
25
25
|
<div class="flex items-baseline gap-2">
|
|
26
26
|
<span class="text-[2.5rem] font-bold tabular-nums leading-none text-foreground">${this.count.get()}</span>
|
|
27
|
-
<span class="text-sm text-muted-foreground
|
|
27
|
+
<span class="text-sm text-muted-foreground">${this.label}</span>
|
|
28
28
|
</div>
|
|
29
29
|
<button @click=${() => this.count.set(this.count.get() + 1)}
|
|
30
30
|
class="w-fit px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Increment</button>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// A shadow-DOM component: `static shadow = true` scopes `static styles = css\`\``
|
|
2
|
+
// to this element (bare selectors do not leak out), the one place the lit reflex
|
|
3
|
+
// to scope CSS is right in webjs. It also shows the rest of the signals API:
|
|
4
|
+
// `computed` (a derived signal), `effect` (a browser-side reaction that runs on
|
|
5
|
+
// change), and `batch` (coalesce several writes into ONE re-render).
|
|
6
|
+
import { WebComponent, html, css, signal, computed, effect, batch } from '@webjsdev/core';
|
|
7
|
+
|
|
8
|
+
export class ReactiveMeter extends WebComponent {
|
|
9
|
+
static shadow = true;
|
|
10
|
+
static styles = css`
|
|
11
|
+
.row { display: flex; align-items: center; gap: 8px; }
|
|
12
|
+
button {
|
|
13
|
+
padding: 6px 12px; border-radius: 10px; border: 1px solid #8883;
|
|
14
|
+
background: transparent; color: inherit; cursor: pointer; font: inherit;
|
|
15
|
+
}
|
|
16
|
+
.val { font-variant-numeric: tabular-nums; font-weight: 600; }
|
|
17
|
+
.muted { opacity: 0.6; font-size: 13px; }
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
private count = signal(0);
|
|
21
|
+
// computed: recomputed lazily when count changes, cached otherwise.
|
|
22
|
+
private doubled = computed(() => this.count.get() * 2);
|
|
23
|
+
private lastLogged = signal('');
|
|
24
|
+
private disposeEffect?: () => void;
|
|
25
|
+
|
|
26
|
+
connectedCallback() {
|
|
27
|
+
super.connectedCallback();
|
|
28
|
+
// effect: runs now and again whenever a signal it reads changes. Browser-only
|
|
29
|
+
// (SSR never calls connectedCallback), so it is the place for reactive
|
|
30
|
+
// side effects.
|
|
31
|
+
this.disposeEffect = effect(() => {
|
|
32
|
+
this.lastLogged.set('count is ' + this.count.get());
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
disconnectedCallback() {
|
|
36
|
+
super.disconnectedCallback?.();
|
|
37
|
+
this.disposeEffect?.();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private bump(n: number) {
|
|
41
|
+
this.count.set(this.count.get() + n);
|
|
42
|
+
}
|
|
43
|
+
// batch: both writes commit together, so the component re-renders ONCE.
|
|
44
|
+
private reset() {
|
|
45
|
+
batch(() => {
|
|
46
|
+
this.count.set(0);
|
|
47
|
+
this.lastLogged.set('reset');
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
render() {
|
|
52
|
+
return html`
|
|
53
|
+
<div class="row">
|
|
54
|
+
<button @click=${() => this.bump(-1)}>-1</button>
|
|
55
|
+
<span class="val">${this.count.get()}</span>
|
|
56
|
+
<button @click=${() => this.bump(1)}>+1</button>
|
|
57
|
+
<span class="muted">doubled: ${this.doubled.get()}</span>
|
|
58
|
+
<button @click=${() => this.reset()}>reset</button>
|
|
59
|
+
</div>
|
|
60
|
+
<p class="muted">${this.lastLogged.get()}</p>
|
|
61
|
+
`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
ReactiveMeter.register('reactive-meter');
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
// `repeat` keys a list so DOM nodes are REUSED across reorders instead of being
|
|
3
3
|
// recreated (use it for keyed lists that reorder; plain `.map()` is fine for
|
|
4
4
|
// static lists). `watch(signal)` does a fine-grained DOM swap of ONE node when
|
|
5
|
-
// the signal changes, without re-running the whole template.
|
|
5
|
+
// the signal changes, without re-running the whole template. The second card
|
|
6
|
+
// shows more of the set: `live` (a controlled input), `ref` + `createRef` (a
|
|
7
|
+
// handle to a DOM node), `until` (a pending fallback for a promise),
|
|
8
|
+
// `unsafeHTML` (trusted raw HTML, NEVER user input), and `keyed` (force a fresh
|
|
9
|
+
// subtree when a key changes).
|
|
6
10
|
import { WebComponent, signal, html } from '@webjsdev/core';
|
|
7
|
-
import { repeat, watch } from '@webjsdev/core/directives';
|
|
11
|
+
import { repeat, watch, live, until, keyed, unsafeHTML, ref, createRef } from '@webjsdev/core/directives';
|
|
8
12
|
|
|
9
13
|
interface Item { id: number; label: string }
|
|
10
14
|
|
|
@@ -17,6 +21,14 @@ export class DirectiveDemo extends WebComponent {
|
|
|
17
21
|
{ id: 3, label: 'Charlie' },
|
|
18
22
|
]);
|
|
19
23
|
private ticks = signal(0);
|
|
24
|
+
// A controlled value (for `live`) and a key (for `keyed`).
|
|
25
|
+
private text = signal('type here');
|
|
26
|
+
private variant = signal(0);
|
|
27
|
+
// A handle to the input node, attached by `ref` in the browser.
|
|
28
|
+
private inputRef = createRef<HTMLInputElement>();
|
|
29
|
+
// Created ONCE (not per render), so `until` keeps the resolved value across
|
|
30
|
+
// re-renders instead of flashing back to the fallback each time.
|
|
31
|
+
private asyncValue: Promise<string> = this.later();
|
|
20
32
|
|
|
21
33
|
private reverse() {
|
|
22
34
|
this.items.set(this.items.get().slice().reverse());
|
|
@@ -25,27 +37,60 @@ export class DirectiveDemo extends WebComponent {
|
|
|
25
37
|
const id = nextId++;
|
|
26
38
|
this.items.set([...this.items.get(), { id, label: 'Item ' + id }]);
|
|
27
39
|
}
|
|
40
|
+
private focusInput() {
|
|
41
|
+
this.inputRef.value?.focus();
|
|
42
|
+
}
|
|
43
|
+
// A promise that resolves after a tick, so `until` shows its fallback first.
|
|
44
|
+
private later(): Promise<string> {
|
|
45
|
+
return new Promise((resolve) => setTimeout(() => resolve('resolved!'), 400));
|
|
46
|
+
}
|
|
28
47
|
|
|
29
48
|
render() {
|
|
49
|
+
const variant = this.variant.get();
|
|
30
50
|
return html`
|
|
31
|
-
<div class="grid gap-
|
|
32
|
-
<div class="
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
51
|
+
<div class="grid gap-6 max-w-[420px]">
|
|
52
|
+
<div class="grid gap-4">
|
|
53
|
+
<div class="flex gap-2">
|
|
54
|
+
<button @click=${() => this.reverse()}
|
|
55
|
+
class="px-3.5 py-1.5 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Reverse</button>
|
|
56
|
+
<button @click=${() => this.add()}
|
|
57
|
+
class="px-3.5 py-1.5 rounded-xl bg-card border border-border text-foreground font-medium text-sm cursor-pointer transition-colors hover:border-border-strong">Add</button>
|
|
58
|
+
</div>
|
|
59
|
+
<!-- repeat keyed by item.id: reversing reuses the existing <li> nodes. -->
|
|
60
|
+
<ul class="grid gap-2 list-none m-0 p-0">
|
|
61
|
+
${repeat(this.items.get(), (it: Item) => it.id, (it: Item) => html`
|
|
62
|
+
<li class="flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground">
|
|
63
|
+
<span class="text-muted-foreground tabular-nums text-[13px]">#${it.id}</span>${it.label}
|
|
64
|
+
</li>
|
|
65
|
+
`)}
|
|
66
|
+
</ul>
|
|
67
|
+
<!-- watch(signal) swaps only this node when ticks changes. -->
|
|
68
|
+
<button @click=${() => this.ticks.set(this.ticks.get() + 1)}
|
|
69
|
+
class="w-fit text-sm text-muted-foreground cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">ticks: ${watch(this.ticks)}</button>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div class="grid gap-3 border-t border-border pt-4">
|
|
73
|
+
<!-- live keeps the input's DOM value tracking the signal even after the
|
|
74
|
+
user edits it, so it stays a controlled input. ref hands you the
|
|
75
|
+
node via inputRef.value once it is attached in the browser. -->
|
|
76
|
+
<div class="flex gap-2">
|
|
77
|
+
<input
|
|
78
|
+
${ref(this.inputRef)}
|
|
79
|
+
aria-label="Editable text for the ref focus demo"
|
|
80
|
+
class="flex-1 px-3 py-1.5 rounded-xl bg-card border border-border text-[15px] text-foreground"
|
|
81
|
+
.value=${live(this.text.get())}
|
|
82
|
+
@input=${(e: Event) => this.text.set((e.target as HTMLInputElement).value)}>
|
|
83
|
+
<button @click=${() => this.focusInput()}
|
|
84
|
+
class="px-3.5 py-1.5 rounded-xl bg-card border border-border text-foreground text-sm cursor-pointer transition-colors hover:border-border-strong">Focus</button>
|
|
85
|
+
</div>
|
|
86
|
+
<!-- until shows the fallback until the promise resolves. -->
|
|
87
|
+
<p class="text-sm text-muted-foreground">async: ${until(this.asyncValue, 'loading...')}</p>
|
|
88
|
+
<!-- keyed discards and rebuilds this subtree when the key changes;
|
|
89
|
+
unsafeHTML injects trusted, author-written HTML (NEVER user input). -->
|
|
90
|
+
<button @click=${() => this.variant.set(variant + 1)}
|
|
91
|
+
class="w-fit text-sm text-muted-foreground cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">rekey</button>
|
|
92
|
+
${keyed(variant, html`<div class="text-[15px] text-foreground">${unsafeHTML('<em>fresh subtree</em>')} #${variant}</div>`)}
|
|
37
93
|
</div>
|
|
38
|
-
<!-- repeat keyed by item.id: reversing reuses the existing <li> nodes. -->
|
|
39
|
-
<ul class="grid gap-2 list-none m-0 p-0">
|
|
40
|
-
${repeat(this.items.get(), (it: Item) => it.id, (it: Item) => html`
|
|
41
|
-
<li class="flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground">
|
|
42
|
-
<span class="text-muted-foreground/70 tabular-nums text-[13px]">#${it.id}</span>${it.label}
|
|
43
|
-
</li>
|
|
44
|
-
`)}
|
|
45
|
-
</ul>
|
|
46
|
-
<!-- watch(signal) swaps only this node when ticks changes. -->
|
|
47
|
-
<button @click=${() => this.ticks.set(this.ticks.get() + 1)}
|
|
48
|
-
class="w-fit text-sm text-muted-foreground cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">ticks: ${watch(this.ticks)}</button>
|
|
49
94
|
</div>
|
|
50
95
|
`;
|
|
51
96
|
}
|
|
@@ -38,7 +38,7 @@ export class RateProbe extends WebComponent {
|
|
|
38
38
|
<li class="flex items-center justify-between px-3 py-2 rounded-xl bg-card border border-border text-sm">
|
|
39
39
|
<span class="text-muted-foreground">#${p.n}</span>
|
|
40
40
|
<span class="font-mono ${p.status === 429 ? 'text-destructive' : 'text-primary'}">${p.status === 429 ? '429 limited' : '200 ok'}</span>
|
|
41
|
-
<span class="text-muted-foreground
|
|
41
|
+
<span class="text-muted-foreground text-[13px]">remaining: ${p.remaining}</span>
|
|
42
42
|
</li>
|
|
43
43
|
`)}
|
|
44
44
|
</ul>
|
|
@@ -18,7 +18,7 @@ export class Greeter extends WebComponent {
|
|
|
18
18
|
<form @submit=${(e: SubmitEvent) => this.run(e)}
|
|
19
19
|
class="flex items-center gap-2 p-2 pl-4 rounded-2xl bg-card border border-border">
|
|
20
20
|
<input name="name" placeholder="your name" autocomplete="off"
|
|
21
|
-
class="flex-1 min-w-0 bg-transparent border-0 outline-none text-foreground text-[15px] placeholder:text-muted-foreground
|
|
21
|
+
class="flex-1 min-w-0 bg-transparent border-0 outline-none text-foreground text-[15px] placeholder:text-muted-foreground py-1.5" />
|
|
22
22
|
<button type="submit"
|
|
23
23
|
class="shrink-0 px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Greet</button>
|
|
24
24
|
</form>
|
|
@@ -82,7 +82,7 @@ export class TodoApp extends WebComponent({
|
|
|
82
82
|
</span>
|
|
83
83
|
<div class="grid gap-0.5">
|
|
84
84
|
<h2 class="m-0 text-[1.4rem] font-bold tracking-[-0.02em] leading-none text-foreground">Tasks</h2>
|
|
85
|
-
<p class="m-0 text-[13px] text-muted-foreground
|
|
85
|
+
<p class="m-0 text-[13px] text-muted-foreground leading-none">${done} of ${list.length} done</p>
|
|
86
86
|
</div>
|
|
87
87
|
<span class="ml-auto text-[13px] font-semibold tabular-nums text-muted-foreground">${pct}%</span>
|
|
88
88
|
</div>
|
|
@@ -97,7 +97,7 @@ export class TodoApp extends WebComponent({
|
|
|
97
97
|
class="flex items-center gap-2 p-2 pl-4 rounded-2xl bg-card border border-border shadow-[0_1px_0_0_color-mix(in_oklch,var(--foreground)_5%,transparent)]">
|
|
98
98
|
<input type="hidden" name="intent" value="create" />
|
|
99
99
|
<input name="title" required maxlength="280" autocomplete="off" placeholder="What needs doing?"
|
|
100
|
-
class="flex-1 min-w-0 bg-transparent border-0 outline-none text-foreground text-[15px] placeholder:text-muted-foreground
|
|
100
|
+
class="flex-1 min-w-0 bg-transparent border-0 outline-none text-foreground text-[15px] placeholder:text-muted-foreground py-1.5" />
|
|
101
101
|
<button type="submit"
|
|
102
102
|
class="shrink-0 px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Add</button>
|
|
103
103
|
</form>
|
|
@@ -120,17 +120,17 @@ export class TodoApp extends WebComponent({
|
|
|
120
120
|
</button>
|
|
121
121
|
<!-- The title is a <label for> the toggle: clicking the text toggles
|
|
122
122
|
the task (works on JS and no-JS paths, and screen readers). -->
|
|
123
|
-
<label for="t-${todo.id}" class="flex-1 min-w-0 text-[15px] leading-snug break-words cursor-pointer select-none ${todo.completed ? 'line-through text-muted-foreground
|
|
123
|
+
<label for="t-${todo.id}" class="flex-1 min-w-0 text-[15px] leading-snug break-words cursor-pointer select-none ${todo.completed ? 'line-through text-muted-foreground' : 'text-foreground'}">${todo.title}</label>
|
|
124
124
|
<!-- Delete: a proper icon button, revealed on row hover / focus. -->
|
|
125
125
|
<button type="submit" name="intent" value="delete" aria-label="Delete task"
|
|
126
126
|
@click=${(e: Event) => this.removeTodo(e, todo)}
|
|
127
|
-
class="shrink-0 grid place-items-center w-7 h-7 rounded-lg border-0 bg-transparent text-muted-foreground
|
|
127
|
+
class="shrink-0 grid place-items-center w-7 h-7 rounded-lg border-0 bg-transparent text-muted-foreground cursor-pointer opacity-0 group-hover:opacity-100 focus:opacity-100 transition-all hover:text-destructive hover:bg-[color-mix(in_oklch,var(--color-destructive)_12%,transparent)]">
|
|
128
128
|
<svg viewBox="0 0 24 24" class="w-4 h-4 stroke-current fill-none" style="stroke-width:2;stroke-linecap:round;stroke-linejoin:round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
|
129
129
|
</button>
|
|
130
130
|
</form>
|
|
131
131
|
</li>
|
|
132
132
|
`) : html`
|
|
133
|
-
<li class="text-center text-muted-foreground
|
|
133
|
+
<li class="text-center text-muted-foreground text-sm py-14 border border-dashed border-border rounded-2xl">No tasks yet. Add your first one above.</li>
|
|
134
134
|
`}
|
|
135
135
|
</ul>
|
|
136
136
|
</section>
|