@webjsdev/cli 0.10.44 → 0.10.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -3
- package/bin/webjs.js +18 -18
- package/lib/api-gallery.js +1 -1
- package/lib/create.js +154 -243
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +7 -3
- package/templates/.agents/skills/webjs/SKILL.md +4 -2
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +78 -16
- package/templates/.agents/skills/webjs/references/built-ins.md +16 -2
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +38 -6
- package/templates/.agents/skills/webjs/references/components.md +101 -6
- package/templates/.agents/skills/webjs/references/data-and-actions.md +19 -2
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +5 -1
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +18 -0
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +25 -2
- package/templates/.agents/skills/webjs/references/runtime.md +1 -1
- package/templates/.agents/skills/webjs/references/service-worker.md +1 -1
- package/templates/.agents/skills/webjs/references/styling.md +82 -2
- package/templates/AGENTS.md +12 -5
- package/templates/gallery/app/api/auth/[...path]/route.ts +7 -0
- package/templates/gallery/app/apple-icon.ts +2 -5
- package/templates/gallery/app/examples/layout.ts +16 -0
- package/templates/gallery/app/examples/todo/page.ts +2 -1
- package/templates/gallery/app/features/async-render/page.ts +3 -2
- package/templates/gallery/app/features/auth/dashboard/layout.ts +21 -0
- package/templates/gallery/app/features/auth/dashboard/middleware.ts +14 -0
- package/templates/gallery/app/features/auth/dashboard/page.ts +20 -0
- package/templates/gallery/app/features/auth/dashboard/settings/page.ts +22 -0
- package/templates/gallery/app/features/auth/login/middleware.ts +15 -0
- package/templates/gallery/app/features/auth/login/page.ts +43 -0
- package/templates/gallery/app/features/auth/page.ts +34 -0
- package/templates/gallery/app/features/auth/signup/middleware.ts +11 -0
- package/templates/gallery/app/features/auth/signup/page.ts +61 -0
- package/templates/gallery/app/features/boundaries/error.ts +5 -4
- package/templates/gallery/app/features/boundaries/gated/forbidden.ts +5 -4
- package/templates/gallery/app/features/boundaries/not-found.ts +5 -4
- package/templates/gallery/app/features/boundaries/page.ts +11 -10
- package/templates/gallery/app/features/boundaries/private/unauthorized.ts +5 -4
- package/templates/gallery/app/features/broadcast/page.ts +4 -3
- package/templates/gallery/app/features/caching/page.ts +5 -4
- package/templates/gallery/app/features/client-router/page.ts +7 -5
- package/templates/gallery/app/features/client-router/second/page.ts +4 -3
- package/templates/gallery/app/features/components/page.ts +3 -2
- package/templates/gallery/app/features/directives/page.ts +3 -2
- package/templates/gallery/app/features/env/page.ts +4 -3
- package/templates/gallery/app/features/file-storage/page.ts +8 -5
- package/templates/gallery/app/features/forms/page.ts +10 -6
- package/templates/gallery/app/features/frames/page.ts +26 -10
- package/templates/gallery/app/features/layout.ts +67 -0
- package/templates/gallery/app/features/metadata/page.ts +7 -6
- package/templates/gallery/app/features/optimistic-ui/page.ts +3 -2
- package/templates/gallery/app/features/rate-limit/page.ts +6 -5
- package/templates/gallery/app/features/route-handler/page.ts +4 -3
- package/templates/gallery/app/features/routing/[id]/page.ts +7 -6
- package/templates/gallery/app/features/routing/page.ts +10 -9
- package/templates/gallery/app/features/server-actions/page.ts +7 -4
- package/templates/gallery/app/features/service-worker/page.ts +4 -3
- package/templates/gallery/app/features/sessions/page.ts +5 -4
- package/templates/gallery/app/features/stream/page.ts +46 -0
- package/templates/gallery/app/features/streaming/page.ts +32 -0
- package/templates/gallery/app/features/suspense/page.ts +35 -0
- package/templates/gallery/app/features/view-transitions/page.ts +44 -0
- package/templates/gallery/app/features/view-transitions/second/page.ts +30 -0
- package/templates/gallery/app/features/websockets/page.ts +4 -3
- package/templates/gallery/app/global-error.ts +2 -5
- package/templates/gallery/app/global-not-found.ts +4 -6
- package/templates/gallery/app/icon.ts +2 -5
- package/templates/gallery/app/manifest.ts +1 -4
- package/templates/gallery/app/opengraph-image.ts +3 -6
- package/templates/gallery/app/robots.ts +0 -3
- package/templates/gallery/app/sitemap.ts +0 -3
- package/templates/gallery/app/twitter-image.ts +3 -6
- package/templates/gallery/components/ui/badge.ts +41 -0
- package/templates/gallery/components/ui/button.ts +86 -0
- package/templates/gallery/components/ui/card.ts +36 -0
- package/templates/gallery/components/ui/input.ts +50 -0
- package/templates/gallery/lib/utils/ui.ts +31 -0
- package/templates/gallery/modules/auth/actions/signup.server.ts +19 -0
- package/templates/gallery/modules/auth/auth.server.ts +53 -0
- package/templates/gallery/modules/auth/password.server.ts +20 -0
- package/templates/gallery/modules/auth/queries/current-user.server.ts +12 -0
- package/templates/gallery/modules/auth/types.ts +9 -0
- package/templates/gallery/modules/broadcast/components/broadcast-feed.ts +4 -2
- package/templates/gallery/modules/caching/components/cache-buster.ts +2 -1
- package/templates/gallery/modules/client-router/components/router-controls.ts +4 -3
- package/templates/gallery/modules/components/components/counter-card.ts +4 -2
- package/templates/gallery/modules/components/components/reactive-meter.ts +9 -1
- package/templates/gallery/modules/components/components/task-loader.ts +3 -2
- package/templates/gallery/modules/components/components/theme-context.ts +5 -3
- package/templates/gallery/modules/directives/components/directive-demo.ts +17 -10
- package/templates/gallery/modules/gallery/components/gallery-nav.ts +54 -0
- package/templates/gallery/modules/gallery/nav.ts +79 -0
- package/templates/gallery/modules/optimistic-ui/components/like-button.ts +19 -1
- package/templates/gallery/modules/rate-limit/components/rate-probe.ts +2 -1
- package/templates/gallery/modules/route-handler/components/rich-data.ts +2 -1
- package/templates/gallery/modules/server-actions/actions/greet.server.ts +2 -2
- package/templates/gallery/modules/server-actions/actions/greet.test.ts +39 -36
- package/templates/gallery/modules/server-actions/components/greeter.ts +11 -11
- package/templates/gallery/modules/server-actions/middleware/require-auth.server.ts +14 -9
- package/templates/gallery/modules/stream/components/stream-demo.ts +81 -0
- package/templates/gallery/modules/streaming/actions/stream-tokens.server.ts +17 -0
- package/templates/gallery/modules/streaming/components/token-stream.ts +53 -0
- package/templates/gallery/modules/suspense/components/slow-fact.ts +20 -0
- package/templates/gallery/modules/todo/components/todo-app.ts +8 -4
- package/templates/gallery/modules/websockets/components/ws-echo.ts +5 -3
- package/templates/gallery/test/auth/auth.test.ts +81 -0
- package/templates/public/favicon.svg +10 -3
- package/templates/scripts/clear-api-gallery.mjs +55 -0
- package/templates/scripts/clear-gallery.mjs +143 -24
- package/lib/lean-copy.js +0 -43
- package/lib/saas-template.js +0 -568
|
@@ -16,47 +16,48 @@
|
|
|
16
16
|
// /features/boundaries/private throws unauthorized() -> private/unauthorized.ts
|
|
17
17
|
import { html } from '@webjsdev/core';
|
|
18
18
|
import type { Metadata } from '@webjsdev/core';
|
|
19
|
+
import { pageHeading, lede } from '#lib/utils/ui.ts';
|
|
19
20
|
|
|
20
21
|
export const metadata: Metadata = { title: 'Boundaries (throws + boundary files) | features' };
|
|
21
22
|
|
|
22
23
|
export default function BoundariesExample() {
|
|
23
24
|
return html`
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
${pageHeading('Boundaries')}
|
|
26
|
+
${lede(html`
|
|
26
27
|
Throw a control-flow function from a page (or a page
|
|
27
28
|
<code class="font-mono">action</code>) to short-circuit the render. The
|
|
28
29
|
framework catches it and renders the nearest matching boundary file in the
|
|
29
30
|
route's chain, innermost wins.
|
|
30
|
-
|
|
31
|
+
`)}
|
|
31
32
|
<ul class="list-disc pl-5 mb-4">
|
|
32
33
|
<li>
|
|
33
|
-
<a class="text-primary" href="/features/boundaries/gated">/features/boundaries/gated</a>
|
|
34
|
+
<a class="text-primary underline underline-offset-2" href="/features/boundaries/gated">/features/boundaries/gated</a>
|
|
34
35
|
throws <code class="font-mono">forbidden()</code>, caught by
|
|
35
36
|
<code class="font-mono">gated/forbidden.ts</code> (403).
|
|
36
37
|
</li>
|
|
37
38
|
<li>
|
|
38
|
-
<a class="text-primary" href="/features/boundaries/private">/features/boundaries/private</a>
|
|
39
|
+
<a class="text-primary underline underline-offset-2" href="/features/boundaries/private">/features/boundaries/private</a>
|
|
39
40
|
throws <code class="font-mono">unauthorized()</code>, caught by
|
|
40
41
|
<code class="font-mono">private/unauthorized.ts</code> (401).
|
|
41
42
|
</li>
|
|
42
43
|
<li>
|
|
43
|
-
<a class="text-primary" href="/features/boundaries/crash">/features/boundaries/crash</a>
|
|
44
|
+
<a class="text-primary underline underline-offset-2" href="/features/boundaries/crash">/features/boundaries/crash</a>
|
|
44
45
|
throws a render error, caught by this segment's
|
|
45
46
|
<code class="font-mono">error.ts</code> (500).
|
|
46
47
|
</li>
|
|
47
48
|
<li>
|
|
48
|
-
<a class="text-primary" href="/features/boundaries/does-not-exist">/features/boundaries/does-not-exist</a>
|
|
49
|
+
<a class="text-primary underline underline-offset-2" href="/features/boundaries/does-not-exist">/features/boundaries/does-not-exist</a>
|
|
49
50
|
matches nothing, caught by the nearest
|
|
50
51
|
<code class="font-mono">not-found.ts</code> (404).
|
|
51
52
|
</li>
|
|
52
53
|
</ul>
|
|
53
|
-
<p class="text-muted-foreground text-sm
|
|
54
|
+
<p class="text-muted-foreground text-sm">
|
|
54
55
|
<code class="font-mono">forbidden()</code> is for an authenticated user who
|
|
55
56
|
lacks permission (403); <code class="font-mono">unauthorized()</code> is for
|
|
56
57
|
a request that is not authenticated at all (401). Both import from
|
|
57
58
|
<code class="font-mono">@webjsdev/core</code> and are thrown, never returned.
|
|
58
59
|
</p>
|
|
59
|
-
<p class="text-muted-foreground text-sm
|
|
60
|
+
<p class="text-muted-foreground text-sm">
|
|
60
61
|
Same throw model as <code class="font-mono">notFound()</code> (renders the
|
|
61
62
|
nearest <code class="font-mono">not-found.ts</code>) and
|
|
62
63
|
<code class="font-mono">redirect(url)</code> (sends an HTTP 3xx). Inside a
|
|
@@ -71,6 +72,6 @@ export default function BoundariesExample() {
|
|
|
71
72
|
and <code class="font-mono">app/global-not-found.ts</code> (a 404 for a URL
|
|
72
73
|
that matches nothing anywhere).
|
|
73
74
|
</p>
|
|
74
|
-
<p class="mt-3"><a class="text-primary" href="/">Back to the gallery</a></p>
|
|
75
|
+
<p class="mt-3"><a class="text-primary underline underline-offset-2" href="/">Back to the gallery</a></p>
|
|
75
76
|
`;
|
|
76
77
|
}
|
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
// in place of the page that threw. Nearest wins: this one (inside private/) beats
|
|
4
4
|
// any unauthorized.ts higher up the tree. A real one usually links to sign-in.
|
|
5
5
|
import { html } from '@webjsdev/core';
|
|
6
|
+
import { pageHeading, lede } from '#lib/utils/ui.ts';
|
|
6
7
|
|
|
7
8
|
export default function Unauthorized() {
|
|
8
9
|
return html`
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
${pageHeading('401 Unauthorized')}
|
|
11
|
+
${lede(html`
|
|
11
12
|
You need to sign in to view this page. This is the nearest
|
|
12
13
|
<code class="font-mono">unauthorized.ts</code> boundary, rendered because the
|
|
13
14
|
page threw <code class="font-mono">unauthorized()</code>.
|
|
14
|
-
|
|
15
|
-
<p><a class="text-primary" href="/features/boundaries">Back to boundaries</a></p>
|
|
15
|
+
`)}
|
|
16
|
+
<p><a class="text-primary underline underline-offset-2" href="/features/boundaries">Back to boundaries</a></p>
|
|
16
17
|
`;
|
|
17
18
|
}
|
|
@@ -5,19 +5,20 @@
|
|
|
5
5
|
// Open this page in two browser tabs and send: both see every message.
|
|
6
6
|
import { html } from '@webjsdev/core';
|
|
7
7
|
import type { Metadata } from '@webjsdev/core';
|
|
8
|
+
import { pageHeading, lede } from '#lib/utils/ui.ts';
|
|
8
9
|
import '#modules/broadcast/components/broadcast-feed.ts';
|
|
9
10
|
|
|
10
11
|
export const metadata: Metadata = { title: 'Broadcast (fan-out to all clients) | features' };
|
|
11
12
|
|
|
12
13
|
export default function BroadcastExample() {
|
|
13
14
|
return html`
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
${pageHeading('Broadcast')}
|
|
16
|
+
${lede(html`
|
|
16
17
|
Every message is fanned out to all connected clients via
|
|
17
18
|
<code class="font-mono">broadcast()</code>. Open this page in a second tab
|
|
18
19
|
and watch messages appear in both. Single-instance by default; wire Redis
|
|
19
20
|
to scale across processes.
|
|
20
|
-
|
|
21
|
+
`)}
|
|
21
22
|
<broadcast-feed></broadcast-feed>
|
|
22
23
|
`;
|
|
23
24
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// use HTTP Cache-Control + ETag (conditional GET).
|
|
8
8
|
import { html } from '@webjsdev/core';
|
|
9
9
|
import type { Metadata } from '@webjsdev/core';
|
|
10
|
+
import { pageHeading, lede } from '#lib/utils/ui.ts';
|
|
10
11
|
import '#modules/caching/components/cache-buster.ts';
|
|
11
12
|
|
|
12
13
|
export const metadata: Metadata = { title: 'Caching (revalidate) | features' };
|
|
@@ -19,11 +20,11 @@ export default function CachingExample() {
|
|
|
19
20
|
// seconds, so this value is frozen until the window elapses.
|
|
20
21
|
const renderedAt = new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
21
22
|
return html`
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
${pageHeading('Caching')}
|
|
24
|
+
${lede(html`
|
|
24
25
|
This page sets <code>export const revalidate = 10</code>, so its
|
|
25
26
|
server-rendered HTML is cached per URL for ten seconds.
|
|
26
|
-
|
|
27
|
+
`)}
|
|
27
28
|
<p class="mb-4">
|
|
28
29
|
Rendered at
|
|
29
30
|
<code class="font-mono text-primary">${renderedAt}</code>.
|
|
@@ -35,7 +36,7 @@ export default function CachingExample() {
|
|
|
35
36
|
<code>revalidateTag</code>, or a GET action's
|
|
36
37
|
<code>export const cache</code>.
|
|
37
38
|
</p>
|
|
38
|
-
<p class="text-muted-foreground text-sm
|
|
39
|
+
<p class="text-muted-foreground text-sm">
|
|
39
40
|
A mutation evicts the cache on demand. Click below (it calls
|
|
40
41
|
<code class="font-mono">revalidatePath('/features/caching')</code>), then refresh:
|
|
41
42
|
the timestamp updates immediately, even inside the 10s window, because the
|
|
@@ -7,24 +7,26 @@
|
|
|
7
7
|
// JS off, every link is a normal full-page navigation.
|
|
8
8
|
import { html } from '@webjsdev/core';
|
|
9
9
|
import type { Metadata } from '@webjsdev/core';
|
|
10
|
+
import { buttonClass } from '#components/ui/button.ts';
|
|
11
|
+
import { pageHeading, lede } from '#lib/utils/ui.ts';
|
|
10
12
|
import '#modules/client-router/components/router-controls.ts';
|
|
11
13
|
|
|
12
14
|
export const metadata: Metadata = { title: 'Client router (soft nav) | features' };
|
|
13
15
|
|
|
14
16
|
export default function ClientRouterExample() {
|
|
15
17
|
return html`
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
${pageHeading('Client router')}
|
|
19
|
+
${lede(html`
|
|
18
20
|
Navigate to the second page and back. With JS on it is a soft swap (no full
|
|
19
21
|
reload, scroll restored); open the network tab to see only a fragment
|
|
20
22
|
fetched, prefetched on hover. With JS off the same links do full-page
|
|
21
23
|
navigations. Nothing was imported to get this.
|
|
22
|
-
|
|
24
|
+
`)}
|
|
23
25
|
<div class="flex gap-3 items-center">
|
|
24
|
-
<a href="/features/client-router/second" class="
|
|
26
|
+
<a href="/features/client-router/second" class="${buttonClass()} no-underline">Go to page two</a>
|
|
25
27
|
<a href="/" class="text-muted-foreground no-underline font-medium text-sm hover:text-foreground transition-colors">Home</a>
|
|
26
28
|
</div>
|
|
27
|
-
<p class="text-muted-foreground text-sm mt-6
|
|
29
|
+
<p class="text-muted-foreground text-sm mt-6">Or drive it from JS with <code class="font-mono">navigate()</code> / <code class="font-mono">revalidate()</code>:</p>
|
|
28
30
|
<router-controls></router-controls>
|
|
29
31
|
<p class="text-muted-foreground text-sm mt-6">
|
|
30
32
|
Opt out app-wide with <code class="font-mono">{ "webjs": { "clientRouter": false } }</code>,
|
|
@@ -3,17 +3,18 @@
|
|
|
3
3
|
// its scroll position from the client-router snapshot cache.
|
|
4
4
|
import { html } from '@webjsdev/core';
|
|
5
5
|
import type { Metadata } from '@webjsdev/core';
|
|
6
|
+
import { pageHeading, lede } from '#lib/utils/ui.ts';
|
|
6
7
|
|
|
7
8
|
export const metadata: Metadata = { title: 'Client router: page two | features' };
|
|
8
9
|
|
|
9
10
|
export default function ClientRouterSecond() {
|
|
10
11
|
return html`
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
${pageHeading('Page two')}
|
|
13
|
+
${lede(html`
|
|
13
14
|
You arrived here without a full reload. Press the browser Back button (or
|
|
14
15
|
the link below): the previous page and its scroll position are restored
|
|
15
16
|
from the snapshot cache.
|
|
16
|
-
|
|
17
|
+
`)}
|
|
17
18
|
<a href="/features/client-router" class="text-primary no-underline font-medium">← Back to page one</a>
|
|
18
19
|
`;
|
|
19
20
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { html } from '@webjsdev/core';
|
|
2
2
|
import type { Metadata } from '@webjsdev/core';
|
|
3
|
+
import { pageHeading, lede } from '#lib/utils/ui.ts';
|
|
3
4
|
import '#modules/components/components/counter-card.ts';
|
|
4
5
|
import '#modules/components/components/reactive-meter.ts';
|
|
5
6
|
import '#modules/components/components/theme-context.ts';
|
|
@@ -9,8 +10,8 @@ export const metadata: Metadata = { title: 'Components (signals + slots) | featu
|
|
|
9
10
|
|
|
10
11
|
export default function ComponentsExample() {
|
|
11
12
|
return html`
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
${pageHeading('Components')}
|
|
14
|
+
${lede(html`The WebComponent factory, a reactive prop, an instance signal, and a slot.`)}
|
|
14
15
|
<counter-card label="Taps"><strong>A slotted title</strong></counter-card>
|
|
15
16
|
<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>
|
|
16
17
|
<reactive-meter></reactive-meter>
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { html } from '@webjsdev/core';
|
|
2
2
|
import type { Metadata } from '@webjsdev/core';
|
|
3
|
+
import { pageHeading, lede } from '#lib/utils/ui.ts';
|
|
3
4
|
import '#modules/directives/components/directive-demo.ts';
|
|
4
5
|
|
|
5
6
|
export const metadata: Metadata = { title: 'Directives (repeat + watch) | features' };
|
|
6
7
|
|
|
7
8
|
export default function DirectivesExample() {
|
|
8
9
|
return html`
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
${pageHeading('Directives')}
|
|
11
|
+
${lede(html`The lit-html directive set: <code>repeat</code> keys a reordering list so nodes are reused, and <code>watch(signal)</code> swaps one node without a full re-render.`)}
|
|
11
12
|
<directive-demo></directive-demo>
|
|
12
13
|
`;
|
|
13
14
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// an app-root env.ts (a schema or a validator fn) that fails fast.
|
|
7
7
|
import { html } from '@webjsdev/core';
|
|
8
8
|
import type { Metadata } from '@webjsdev/core';
|
|
9
|
+
import { pageHeading, lede } from '#lib/utils/ui.ts';
|
|
9
10
|
|
|
10
11
|
export const metadata: Metadata = { title: 'Env vars (public vs server) | features' };
|
|
11
12
|
|
|
@@ -16,11 +17,11 @@ export default function EnvExample() {
|
|
|
16
17
|
// add WEBJS_PUBLIC_APP_NAME=... to .env, which demonstrates the default.
|
|
17
18
|
const publicName = process.env.WEBJS_PUBLIC_APP_NAME || '(unset, add WEBJS_PUBLIC_APP_NAME to .env)';
|
|
18
19
|
return html`
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
${pageHeading('Environment variables')}
|
|
21
|
+
${lede(html`
|
|
21
22
|
Read on the server during SSR. Only <code>WEBJS_PUBLIC_</code>-prefixed
|
|
22
23
|
names are exposed to the browser; the rest stay server-side.
|
|
23
|
-
|
|
24
|
+
`)}
|
|
24
25
|
<ul class="list-disc pl-5 mb-4 space-y-1">
|
|
25
26
|
<li><code class="font-mono text-sm">NODE_ENV</code> = <span class="text-primary">${nodeEnv}</span> <span class="text-muted-foreground text-sm">(defined both sides)</span></li>
|
|
26
27
|
<li><code class="font-mono text-sm">WEBJS_PUBLIC_APP_NAME</code> = <span class="text-primary">${publicName}</span></li>
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
// streams the file back through file/[key]/route.ts. Works with JS off; the
|
|
6
6
|
// client router applies the same flow in place with JS on.
|
|
7
7
|
import { html } from '@webjsdev/core';
|
|
8
|
+
import { buttonClass } from '#components/ui/button.ts';
|
|
9
|
+
import { cardClass } from '#components/ui/card.ts';
|
|
10
|
+
import { pageHeading, lede } from '#lib/utils/ui.ts';
|
|
8
11
|
import type { Metadata } from '@webjsdev/core';
|
|
9
12
|
import { storeUpload } from '#modules/file-storage/actions/store-upload.server.ts';
|
|
10
13
|
|
|
@@ -33,25 +36,25 @@ export default function FileStorageExample({
|
|
|
33
36
|
const name = (searchParams.name || '').trim();
|
|
34
37
|
const size = (searchParams.size || '').trim();
|
|
35
38
|
return html`
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
${pageHeading('File storage')}
|
|
40
|
+
${lede(html`
|
|
38
41
|
Upload a file: the bytes stream into the FileStore (a local
|
|
39
42
|
<code class="font-mono">.webjs/uploads</code> directory by default,
|
|
40
43
|
gitignored). Swap the backend for S3/R2 with one
|
|
41
44
|
<code class="font-mono">setFileStore()</code> call, no call-site change.
|
|
42
|
-
|
|
45
|
+
`)}
|
|
43
46
|
<form method="post" enctype="multipart/form-data" class="flex flex-wrap gap-3 items-center mb-4">
|
|
44
47
|
<input type="file" name="file" required aria-label="Choose a file to upload"
|
|
45
48
|
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" />
|
|
46
49
|
<button type="submit"
|
|
47
|
-
class
|
|
50
|
+
class=${buttonClass()}>Upload</button>
|
|
48
51
|
</form>
|
|
49
52
|
${actionData?.error
|
|
50
53
|
? html`<p class="text-destructive text-sm mb-4">${actionData.error}</p>`
|
|
51
54
|
: ''}
|
|
52
55
|
${key
|
|
53
56
|
? html`
|
|
54
|
-
<div class="px-4 py-3
|
|
57
|
+
<div class="${cardClass('rounded-xl')} px-4 py-3 text-sm">
|
|
55
58
|
Stored <span class="text-foreground font-medium">${name}</span>
|
|
56
59
|
<span class="text-muted-foreground">(${size} bytes)</span>
|
|
57
60
|
<a class="text-primary no-underline ml-2" href="/features/file-storage/file/${key}">download</a>
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
// On failure the framework re-renders at 422 with the result; on success it
|
|
7
7
|
// does a 303 Post-Redirect-Get, so we redirect to ?sent=1 to show a confirmation.
|
|
8
8
|
import { html } from '@webjsdev/core';
|
|
9
|
+
import { cardClass } from '#components/ui/card.ts';
|
|
10
|
+
import { inputClass } from '#components/ui/input.ts';
|
|
11
|
+
import { buttonClass } from '#components/ui/button.ts';
|
|
12
|
+
import { pageHeading } from '#lib/utils/ui.ts';
|
|
9
13
|
import type { Metadata } from '@webjsdev/core';
|
|
10
14
|
|
|
11
15
|
export const metadata: Metadata = { title: 'Forms (no-JS PE) | features' };
|
|
@@ -25,18 +29,18 @@ const field = (label: string, name: string, input: unknown, error?: string) => h
|
|
|
25
29
|
</div>
|
|
26
30
|
`;
|
|
27
31
|
|
|
28
|
-
const inputCls =
|
|
32
|
+
const inputCls = inputClass();
|
|
29
33
|
|
|
30
34
|
export default function FormsFeature({ searchParams, actionData }: { searchParams: Record<string, string | undefined>; actionData?: Result }) {
|
|
31
35
|
if (searchParams.sent) {
|
|
32
36
|
return html`
|
|
33
|
-
|
|
34
|
-
<div class="max-w-[460px] grid gap-3 p-6
|
|
37
|
+
${pageHeading('Forms')}
|
|
38
|
+
<div class="${cardClass()} max-w-[460px] grid gap-3 p-6 text-center">
|
|
35
39
|
<span class="mx-auto grid place-items-center w-12 h-12 rounded-2xl bg-primary/15 text-primary">
|
|
36
40
|
<svg viewBox="0 0 24 24" class="w-6 h-6 stroke-current fill-none" style="stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round"><path d="m5 13 4 4L19 7"/></svg>
|
|
37
41
|
</span>
|
|
38
42
|
<p class="m-0 text-lg font-semibold text-foreground">Message sent</p>
|
|
39
|
-
<p class="m-0 text-sm text-muted-foreground">Thanks, we got it. <a class="text-primary" href="/features/forms">Send another</a>.</p>
|
|
43
|
+
<p class="m-0 text-sm text-muted-foreground">Thanks, we got it. <a class="text-primary underline underline-offset-2" href="/features/forms">Send another</a>.</p>
|
|
40
44
|
</div>
|
|
41
45
|
`;
|
|
42
46
|
}
|
|
@@ -45,11 +49,11 @@ export default function FormsFeature({ searchParams, actionData }: { searchParam
|
|
|
45
49
|
return html`
|
|
46
50
|
<h1 class="text-h2 font-bold mb-2">Forms</h1>
|
|
47
51
|
<p class="text-muted-foreground mb-5 max-w-[460px]">A real <code><form></code> posting to this page's <code>action</code>. It works with JS off; validation errors come back on <code>actionData</code>.</p>
|
|
48
|
-
<form method="post" action="" class="max-w-[460px] grid gap-4 p-5
|
|
52
|
+
<form method="post" action="" class="${cardClass()} max-w-[460px] grid gap-4 p-5">
|
|
49
53
|
${field('Name', 'name', html`<input id="name" name="name" value=${v.name ?? ''} class=${inputCls} placeholder="Ada Lovelace" />`, errs.name)}
|
|
50
54
|
${field('Email', 'email', html`<input id="email" name="email" type="email" value=${v.email ?? ''} class=${inputCls} placeholder="ada@example.com" />`, errs.email)}
|
|
51
55
|
${field('Message', 'message', html`<textarea id="message" name="message" rows="3" class=${inputCls} placeholder="Say hello...">${v.message ?? ''}</textarea>`, errs.message)}
|
|
52
|
-
<button type="submit" class="justify-self-start
|
|
56
|
+
<button type="submit" class="${buttonClass()} justify-self-start">Send message</button>
|
|
53
57
|
</form>
|
|
54
58
|
`;
|
|
55
59
|
}
|
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
// link targeting its id, shipping zero component JS. It is WebJs's take on Turbo
|
|
3
3
|
// Frames. Unlike the client router (which swaps the whole page's children when
|
|
4
4
|
// you navigate to a DIFFERENT url), a frame refreshes just ONE sub-region in
|
|
5
|
-
// place.
|
|
5
|
+
// place. It is also NOT a layout: a layout (layout.ts) is server-rendered chrome
|
|
6
|
+
// that WRAPS a route subtree via ${children} and re-renders only as part of a
|
|
7
|
+
// navigation, so it answers "what structure wraps these routes". A frame answers
|
|
8
|
+
// "which region updates itself in place", with no navigation at all. Use a layout
|
|
9
|
+
// for shared chrome across routes; use a frame when one region (a filtered list,
|
|
10
|
+
// a paginated table, a tab panel) must refresh on its own without navigating.
|
|
11
|
+
// The filter links below live INSIDE the frame, so a click walks
|
|
6
12
|
// closest('webjs-frame'), refetches THIS same page with the new ?status, and the
|
|
7
13
|
// server returns ONLY the <webjs-frame id="tasks"> subtree (open the network tab
|
|
8
14
|
// to see it). The router swaps that subtree in; everything outside the frame,
|
|
@@ -14,6 +20,10 @@
|
|
|
14
20
|
// requirement. The frame element itself upgrades because the root layout ships a
|
|
15
21
|
// component (the theme toggle), so @webjsdev/core and the router load app-wide.
|
|
16
22
|
import { html } from '@webjsdev/core';
|
|
23
|
+
import { cardClass } from '#components/ui/card.ts';
|
|
24
|
+
import { buttonClass } from '#components/ui/button.ts';
|
|
25
|
+
import { cn } from '#lib/utils/cn.ts';
|
|
26
|
+
import { pageHeading, lede } from '#lib/utils/ui.ts';
|
|
17
27
|
import type { Metadata } from '@webjsdev/core';
|
|
18
28
|
import { filterTasks, normalizeStatus, type Status } from '#modules/frames/utils/tasks.ts';
|
|
19
29
|
|
|
@@ -24,10 +34,14 @@ export const metadata: Metadata = { title: 'Frames (webjs-frame partial swap) |
|
|
|
24
34
|
// link OUTSIDE the frame would drive it from anywhere via data-webjs-frame="tasks".
|
|
25
35
|
function filterTab(current: Status, status: Status, label: string) {
|
|
26
36
|
const active = current === status;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
// A segmented control: keep ONE variant (constant weight, border, padding) so
|
|
38
|
+
// toggling the active tab never reflows the row, overriding only the active
|
|
39
|
+
// colors with cn().
|
|
40
|
+
const cls = cn(
|
|
41
|
+
buttonClass({ variant: 'secondary', size: 'sm' }),
|
|
42
|
+
'no-underline',
|
|
43
|
+
active && 'bg-primary text-primary-foreground border-primary hover:border-primary',
|
|
44
|
+
);
|
|
31
45
|
return html`<a href="/features/frames?status=${status}" class=${cls}>${label}</a>`;
|
|
32
46
|
}
|
|
33
47
|
|
|
@@ -35,14 +49,16 @@ export default function FramesExample({ searchParams }: { searchParams: Record<s
|
|
|
35
49
|
const status = normalizeStatus(searchParams?.status);
|
|
36
50
|
const tasks = filterTasks(status);
|
|
37
51
|
return html`
|
|
38
|
-
|
|
39
|
-
|
|
52
|
+
${pageHeading('Frames')}
|
|
53
|
+
${lede(html`
|
|
40
54
|
Filter the list. With JS on, only the framed region swaps (the response is
|
|
41
55
|
just the frame's subtree, not the whole page) and the heading above never
|
|
42
56
|
re-renders. With JS off, the same links do full-page navigations. It is one
|
|
43
|
-
region refreshing independently of a navigation, which a page
|
|
44
|
-
|
|
45
|
-
|
|
57
|
+
region refreshing independently of a navigation, which neither a page nor a
|
|
58
|
+
layout can express: a layout wraps routes with shared chrome and only
|
|
59
|
+
re-renders when you navigate, while a frame updates one region in place.
|
|
60
|
+
`)}
|
|
61
|
+
<webjs-frame id="tasks" class="${cardClass()} block p-4">
|
|
46
62
|
<div class="flex gap-2 mb-4">
|
|
47
63
|
${filterTab(status, 'all', 'All')}
|
|
48
64
|
${filterTab(status, 'active', 'Active')}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { html } from '@webjsdev/core';
|
|
2
|
+
import { backLink } from '#lib/utils/ui.ts';
|
|
3
|
+
import '#modules/gallery/components/gallery-nav.ts';
|
|
4
|
+
|
|
5
|
+
// A subtle, hover-revealed scrollbar for the sidebar (macOS overlay feel): the
|
|
6
|
+
// track + thumb are transparent while idle (no bar), and fade in on hover /
|
|
7
|
+
// keyboard focus, then fade out again. A layout may interpolate CSS into a
|
|
8
|
+
// <style> (it never hydrates, unlike a component), so this is legitimate here.
|
|
9
|
+
const SIDENAV_CSS = `
|
|
10
|
+
.gallery-sidenav { scrollbar-width: thin; scrollbar-color: transparent transparent; transition: scrollbar-color .2s ease; }
|
|
11
|
+
.gallery-sidenav:hover, .gallery-sidenav:focus-within { scrollbar-color: color-mix(in oklch, var(--foreground) 22%, transparent) transparent; }
|
|
12
|
+
.gallery-sidenav::-webkit-scrollbar { width: 8px; }
|
|
13
|
+
.gallery-sidenav::-webkit-scrollbar-track { background: transparent; }
|
|
14
|
+
.gallery-sidenav::-webkit-scrollbar-thumb { background-color: transparent; border-radius: 999px; border: 2px solid transparent; background-clip: padding-box; transition: background-color .2s ease; }
|
|
15
|
+
.gallery-sidenav:hover::-webkit-scrollbar-thumb, .gallery-sidenav:focus-within::-webkit-scrollbar-thumb { background-color: color-mix(in oklch, var(--foreground) 22%, transparent); }
|
|
16
|
+
.gallery-sidenav::-webkit-scrollbar-thumb:hover { background-color: color-mix(in oklch, var(--foreground) 40%, transparent); }
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
// Section rhythm: ONE uniform gap between every top-level block of a demo
|
|
20
|
+
// (heading, lede, paragraph, component, list, anything), so spacing stays
|
|
21
|
+
// consistent with NO per-element margins. This is the single place that controls
|
|
22
|
+
// demo spacing. A flex column with `gap` does it WITHOUT touching each child's
|
|
23
|
+
// own display: a `grid`/`flex` child (like the forms card) keeps its layout, a
|
|
24
|
+
// shadow-DOM host (inline by default) is blockified as a flex item so it honours
|
|
25
|
+
// the gap, a display:contents <webjs-suspense> is replaced by its children (which
|
|
26
|
+
// become the flex items and get the gap), and a display:none streaming
|
|
27
|
+
// <script>/<template> is not an item at all (no phantom gap).
|
|
28
|
+
const STACK_CSS = `
|
|
29
|
+
.demo-stack {
|
|
30
|
+
--section-gap: 1.5rem;
|
|
31
|
+
display: flex;
|
|
32
|
+
flex-direction: column;
|
|
33
|
+
gap: var(--section-gap);
|
|
34
|
+
}
|
|
35
|
+
/* Zero any BLOCK-axis margins so ONLY the flex gap spaces sections. Block
|
|
36
|
+
axis only: this style is unlayered so it beats Tailwind's layered
|
|
37
|
+
utilities, and a full margin: 0 would also kill mx-auto (an auth card's
|
|
38
|
+
horizontal centering). */
|
|
39
|
+
.demo-stack > * { margin-block: 0; }
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
// Shared layout for every gallery feature demo under /features/*. A docs-style
|
|
43
|
+
// two-column shell: a grouped sidebar (the <gallery-nav> component, which tracks
|
|
44
|
+
// the active demo across soft navigation) plus the demo content. On mobile the
|
|
45
|
+
// sidebar is hidden, so a slim back link keeps a demo from being a dead end.
|
|
46
|
+
// Nested layouts (like the auth dashboard's sub-nav) render inside ${children}.
|
|
47
|
+
// A non-root layout, so it never writes the document shell (the framework does).
|
|
48
|
+
export default function FeaturesLayout({ children, url }: { children: unknown; url: URL | string }) {
|
|
49
|
+
const path = typeof url === 'string' ? new URL(url, 'http://x').pathname : url.pathname;
|
|
50
|
+
return html`
|
|
51
|
+
<style>${SIDENAV_CSS}${STACK_CSS}</style>
|
|
52
|
+
<div class="lg:hidden mb-6">${backLink('/', html`← Gallery`)}</div>
|
|
53
|
+
<div class="grid lg:grid-cols-[190px_1fr] gap-8 lg:gap-12">
|
|
54
|
+
<!-- The sidebar caps at the viewport minus the root chrome (the h-14 navbar
|
|
55
|
+
= 3.5rem plus the root <main>'s py-8 = 4rem, so 7.5rem) and scrolls
|
|
56
|
+
internally, so a short demo never forces a page scrollbar. -->
|
|
57
|
+
<aside class="hidden lg:flex lg:flex-col sticky top-6 self-start max-h-[calc(100dvh-7.5rem)] text-sm">
|
|
58
|
+
<!-- Pinned header: stays put while the demo list below scrolls. -->
|
|
59
|
+
<a href="/" class="shrink-0 block px-3 py-1.5 mb-4 rounded-lg no-underline text-muted-foreground hover:text-foreground transition-colors">← Gallery</a>
|
|
60
|
+
<div class="gallery-sidenav min-h-0 overflow-y-auto overflow-x-hidden -mr-2 pr-2">
|
|
61
|
+
<gallery-nav current=${path}></gallery-nav>
|
|
62
|
+
</div>
|
|
63
|
+
</aside>
|
|
64
|
+
<div class="min-w-0 demo-stack">${children}</div>
|
|
65
|
+
</div>
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// (sitemap.ts, robots.ts, opengraph-image.ts, ...) live at the app root.
|
|
7
7
|
import { html } from '@webjsdev/core';
|
|
8
8
|
import type { Metadata } from '@webjsdev/core';
|
|
9
|
+
import { pageHeading, lede } from '#lib/utils/ui.ts';
|
|
9
10
|
|
|
10
11
|
// generateMetadata wins over a static `metadata` export when both exist. Here
|
|
11
12
|
// it derives the title from a ?topic= query param to show the dynamic form;
|
|
@@ -31,20 +32,20 @@ export default function MetadataExample({
|
|
|
31
32
|
}) {
|
|
32
33
|
const topic = (searchParams.topic || '').trim();
|
|
33
34
|
return html`
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
${pageHeading('Metadata')}
|
|
36
|
+
${lede(html`
|
|
36
37
|
<code>generateMetadata(ctx)</code> runs on the server and can read the
|
|
37
38
|
request, so the <code><title></code> is computed per URL. View source
|
|
38
39
|
to see the tag this page produced.
|
|
39
|
-
|
|
40
|
+
`)}
|
|
40
41
|
<p class="mb-4">
|
|
41
42
|
Current title source:
|
|
42
43
|
<code class="font-mono text-sm">${topic ? '?topic=' + topic : '(default, no ?topic=)'}</code>
|
|
43
44
|
</p>
|
|
44
45
|
<ul class="list-disc pl-5 mb-4">
|
|
45
|
-
<li><a class="text-primary" href="/features/metadata?topic=webjs">?topic=webjs</a></li>
|
|
46
|
-
<li><a class="text-primary" href="/features/metadata?topic=Routing">?topic=Routing</a></li>
|
|
47
|
-
<li><a class="text-primary" href="/features/metadata">clear the param</a></li>
|
|
46
|
+
<li><a class="text-primary underline underline-offset-2" href="/features/metadata?topic=webjs">?topic=webjs</a></li>
|
|
47
|
+
<li><a class="text-primary underline underline-offset-2" href="/features/metadata?topic=Routing">?topic=Routing</a></li>
|
|
48
|
+
<li><a class="text-primary underline underline-offset-2" href="/features/metadata">clear the param</a></li>
|
|
48
49
|
</ul>
|
|
49
50
|
<p class="text-muted-foreground text-sm">
|
|
50
51
|
Site-wide metadata (sitemap, robots, Open Graph images) lives in metadata
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { html } from '@webjsdev/core';
|
|
2
2
|
import type { Metadata } from '@webjsdev/core';
|
|
3
|
+
import { pageHeading, lede } from '#lib/utils/ui.ts';
|
|
3
4
|
import '#modules/optimistic-ui/components/like-button.ts';
|
|
4
5
|
|
|
5
6
|
export const metadata: Metadata = { title: 'Optimistic UI (imperative flip) | features' };
|
|
6
7
|
|
|
7
8
|
export default function OptimisticUiFeature() {
|
|
8
9
|
return html`
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
${pageHeading('Optimistic UI')}
|
|
11
|
+
${lede(html`The imperative <code>optimistic(signal, value, action)</code> form: the UI flips instantly and rolls back if the action fails. For the declarative list form (add / remove with rollback) in a full app, see <a class="text-primary underline underline-offset-2" href="/examples/todo">/examples/todo</a>.`)}
|
|
11
12
|
<like-button></like-button>
|
|
12
13
|
`;
|
|
13
14
|
}
|
|
@@ -5,23 +5,24 @@
|
|
|
5
5
|
// default; point the store at Redis to share the window across instances.
|
|
6
6
|
import { html } from '@webjsdev/core';
|
|
7
7
|
import type { Metadata } from '@webjsdev/core';
|
|
8
|
+
import { pageHeading, lede } from '#lib/utils/ui.ts';
|
|
8
9
|
import '#modules/rate-limit/components/rate-probe.ts';
|
|
9
10
|
|
|
10
11
|
export const metadata: Metadata = { title: 'Rate limiting (rateLimit middleware) | features' };
|
|
11
12
|
|
|
12
13
|
export default function RateLimitExample() {
|
|
13
14
|
return html`
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
${pageHeading('Rate limiting')}
|
|
16
|
+
${lede(html`
|
|
16
17
|
The <code class="font-mono">/ping</code> endpoint is limited to five
|
|
17
18
|
requests per ten seconds by a scoped <code class="font-mono">middleware.ts</code>.
|
|
18
19
|
Ping past the limit to get a <code class="font-mono">429</code> with a
|
|
19
20
|
<code class="font-mono">Retry-After</code> header.
|
|
20
|
-
|
|
21
|
+
`)}
|
|
21
22
|
<rate-probe></rate-probe>
|
|
22
|
-
<p class="text-muted-foreground text-sm
|
|
23
|
+
<p class="text-muted-foreground text-sm">
|
|
23
24
|
With JavaScript off, hit
|
|
24
|
-
<a class="text-primary" href="/features/rate-limit/ping" data-no-router>/features/rate-limit/ping</a>
|
|
25
|
+
<a class="text-primary underline underline-offset-2" href="/features/rate-limit/ping" data-no-router>/features/rate-limit/ping</a>
|
|
25
26
|
directly (refresh past five times in ten seconds).
|
|
26
27
|
</p>
|
|
27
28
|
`;
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { html } from '@webjsdev/core';
|
|
2
2
|
import type { Metadata } from '@webjsdev/core';
|
|
3
|
+
import { pageHeading, lede } from '#lib/utils/ui.ts';
|
|
3
4
|
import '#modules/route-handler/components/rich-data.ts';
|
|
4
5
|
|
|
5
6
|
export const metadata: Metadata = { title: 'Route handlers (route.ts) | features' };
|
|
6
7
|
|
|
7
8
|
export default function RouteHandlerExample() {
|
|
8
9
|
return html`
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
<p>GET <a class="text-primary" href="/features/route-handler/data" data-no-router>/features/route-handler/data</a> returns rich JSON via <code class="font-mono">json()</code>. It carries <code class="font-mono">data-no-router</code> so the client router does not try to soft-navigate to it: a <code>route.ts</code> is not a page, so the browser loads its JSON directly.</p>
|
|
10
|
+
${pageHeading('Route handlers')}
|
|
11
|
+
${lede(html`A <code>route.ts</code> is a server-only HTTP endpoint (named <code>GET</code>/<code>POST</code>/... exports), the WebJs equivalent of a Next route handler. It never ships to the client.`)}
|
|
12
|
+
<p>GET <a class="text-primary underline underline-offset-2" href="/features/route-handler/data" data-no-router>/features/route-handler/data</a> returns rich JSON via <code class="font-mono">json()</code>. It carries <code class="font-mono">data-no-router</code> so the client router does not try to soft-navigate to it: a <code>route.ts</code> is not a page, so the browser loads its JSON directly.</p>
|
|
12
13
|
<p class="text-muted-foreground mt-6 mb-2">A client component reading it with <code class="font-mono">richFetch</code>, so <code class="font-mono">at</code> comes back as a real <code class="font-mono">Date</code>:</p>
|
|
13
14
|
<rich-data></rich-data>
|
|
14
15
|
`;
|