Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/webjs-scaffold-sync/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ it applies, then update or consciously skip each.
|---|---|
| New / changed **gallery or showcase demo** | the template file(s) or generator strings for the demo + the home-page `features`/index array + the scaffold AGENTS.md gallery list + `test/scaffolds/*` FEATURES/assertions + **generate + boot the affected template** |
| New / removed **template** | the `create.js` template branch (+ a `*-template.js` if large) + the "only N templates exist" list in EVERY per-agent rule file + the framework `AGENTS.md`/getting-started/README template matrix + the CLI `--template` validation + `--help` + `test/scaffolds/*` |
| New **control-flow throw or routing boundary file** (`notFound` / `redirect` / `forbidden` / `unauthorized` and their `not-found` / `forbidden` / `unauthorized` / `error` / `loading` / `global-error` / `global-not-found` boundary files) | a runnable **gallery demo** that exercises it (a route that throws it plus the nearest boundary file), NOT just an app-tree bullet in the rule files + the home-page `features` array + `test/scaffolds/*` FEATURES/boundary-file asserts + **generate + boot + hit the route**. A doc bullet in `AGENTS.md` / `CONVENTIONS.md` is necessary but NOT sufficient: the gallery is the primary teaching surface, so an undemoed thrower is invisible to a scaffolding agent (the #848 gap). Carve-out: a **root-only** boundary (`global-error` / `global-not-found`) cannot mount under `app/features/` without clashing with the generated app root, so teach those in the demo's PROSE rather than as a live route. |
| New **convention/rule** for generated apps | ALL per-agent rule files in lockstep (surface 3) + repo `CONVENTIONS.md` if the repo demonstrates it + `agent-docs` only if it also changes a framework API |
| Changed **generated file** (layout, theme, home, schema, middleware) | the generator (`create.js`/`*-template.js`) + any scaffold test asserting it + any doc/preview describing it + **regenerate + boot** |
| New **scaffold-shipped config/hook** (`.hooks/`, `webjs.*` in the generated `package.json`, a check rule) | `templates/**` + `webjs doctor`/`check` that reads it + the per-agent rule files if agents must know it |
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/lib/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -1363,6 +1363,7 @@ export const metadata = {
// do not use (delete the route AND its modules/<name>), then reshape this page.
const features = [
{ href: '/features/routing', title: 'Routing', blurb: 'A static route plus a dynamic [id] segment that reads params. The file-based router in miniature.' },
{ href: '/features/boundaries', title: 'Boundaries', blurb: 'The control-flow throws (forbidden / unauthorized / notFound) and the nearest boundary file that catches each.' },
{ href: '/features/components', title: 'Components', blurb: 'The WebComponent factory, reactive props, instance signals, and slot projection in light DOM.' },
{ 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.' },
{ href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
Expand Down Expand Up @@ -1460,6 +1461,7 @@ export const metadata = {
// AND its modules/<name>). Keep this list in sync with the full-stack home.
const features = [
{ href: '/features/routing', title: 'Routing', blurb: 'A static route plus a dynamic [id] segment that reads params. The file-based router in miniature.' },
{ href: '/features/boundaries', title: 'Boundaries', blurb: 'The control-flow throws (forbidden / unauthorized / notFound) and the nearest boundary file that catches each.' },
{ href: '/features/components', title: 'Components', blurb: 'The WebComponent factory, reactive props, instance signals, and slot projection in light DOM.' },
{ 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.' },
{ href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/templates/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ app/ ROUTING ONLY: thin route adapters (import from modules/
styles/, lib/utils/, and modules/. globals.css is at
styles/, NOT app/.
page.ts → / (the scaffold home links to the gallery)
features/<name>/ single-feature demos (routing, components,
features/<name>/ single-feature demos (routing, boundaries, components,
server-actions, optimistic-ui, async-render,
directives, route-handler, forms, metadata, caching,
env, client-router, service-worker); prune what you skip
Expand Down Expand Up @@ -405,9 +405,10 @@ instrumentation-client.ts optional client boot hook, runs first before app modu
The scaffold ships a gallery organized by KIND, so features and whole apps are
not mixed:
- `app/features/<name>/` are single-feature demos, one webjs concept each
(routing, components, server-actions, optimistic-ui, async-render, directives,
route-handler, forms, metadata, caching, env, client-router, service-worker,
plus the infra demos websockets, file-storage, rate-limit, broadcast).
(routing, boundaries, components, server-actions, optimistic-ui, async-render,
directives, route-handler, forms, metadata, caching, env, client-router,
service-worker, plus the infra demos websockets, file-storage, rate-limit,
broadcast).
- `app/examples/<name>/` are whole example apps that compose several features
(todo: optimistic UI + progressive enhancement + a11y + db + modules).

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/templates/CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ api / saas templates) is a **starting point**.

The full-stack and saas scaffolds ship a **gallery** organized by kind so features
and whole apps are not mixed. `app/features/<name>/` are single-feature
demos, one webjs concept each (routing, components, server-actions,
demos, one webjs concept each (routing, boundaries, components, server-actions,
optimistic-ui, async-render, directives, route-handler). `app/examples/<name>/`
are whole example apps that compose several features (todo: optimistic UI
+ progressive enhancement + a11y + db + modules). Both keep their logic in
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// The nearest 403 boundary for a thrown forbidden(). A forbidden.ts default-
// exports a function returning a TemplateResult, rendered at status 403 in place
// of the page that threw. Nearest wins: this one (inside gated/) beats any
// forbidden.ts higher up the tree. Keep the message actionable for an
// authenticated user who lacks permission.
import { html } from '@webjsdev/core';

export default function Forbidden() {
return html`
<h1 class="text-h2 font-bold mb-4">403 Forbidden</h1>
<p class="text-muted-foreground mb-4">
You are signed in but do not have permission to view this page. This is the
nearest <code class="font-mono">forbidden.ts</code> boundary, rendered
because the page threw <code class="font-mono">forbidden()</code>.
</p>
<p><a class="text-primary" href="/features/boundaries">Back to boundaries</a></p>
`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// A page that throws forbidden(). In a real app you would call this only when
// an authenticated user lacks permission (checked against the session). Here it
// throws unconditionally so the demo always shows the 403 boundary. The nearest
// forbidden.ts (this folder's sibling) renders in place of this page at status
// 403; without a nearest one the framework renders a default 403 page.
import { forbidden } from '@webjsdev/core';

export default function Gated() {
forbidden();
}
67 changes: 67 additions & 0 deletions packages/cli/templates/gallery/app/features/boundaries/page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// 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.
// Boundaries: the four control-flow throws and their boundary files. A page (or
// a page `action`) can THROW to short-circuit rendering, and the framework
// renders the NEAREST matching boundary file in the route's ancestor chain
// (innermost wins), exactly like an error boundary:
// notFound() -> the nearest not-found.ts (404)
// forbidden() -> the nearest forbidden.ts (403, authenticated but not allowed)
// unauthorized() -> the nearest unauthorized.ts (401, not authenticated)
// redirect(url) -> an HTTP 3xx (no boundary file; it sends a Location)
// Two boundaries are ROOT-ONLY, so they live at the app root, not here under a
// feature folder: global-error.ts (the app-wide catch-all, owns its own <html>)
// and global-not-found.ts (a 404 for a URL that matches nothing anywhere).
//
// This demo ships two live sub-routes. Visit each and the page throws, so you
// see the nearest boundary render in place of the page:
// /features/boundaries/gated throws forbidden() -> gated/forbidden.ts
// /features/boundaries/private throws unauthorized() -> private/unauthorized.ts
import { html } from '@webjsdev/core';
import type { Metadata } from '@webjsdev/core';

export const metadata: Metadata = { title: 'Boundaries (throws + boundary files) | features' };

export default function BoundariesExample() {
return html`
<h1 class="text-h2 font-bold mb-4">Boundaries</h1>
<p class="text-muted-foreground mb-4">
Throw a control-flow function from a page (or a page
<code class="font-mono">action</code>) to short-circuit the render. The
framework catches it and renders the nearest matching boundary file in the
route's chain, innermost wins.
</p>
<ul class="list-disc pl-5 mb-4">
<li>
<a class="text-primary" href="/features/boundaries/gated">/features/boundaries/gated</a>
throws <code class="font-mono">forbidden()</code>, caught by
<code class="font-mono">gated/forbidden.ts</code> (403).
</li>
<li>
<a class="text-primary" href="/features/boundaries/private">/features/boundaries/private</a>
throws <code class="font-mono">unauthorized()</code>, caught by
<code class="font-mono">private/unauthorized.ts</code> (401).
</li>
</ul>
<p class="text-muted-foreground text-sm mb-2">
<code class="font-mono">forbidden()</code> is for an authenticated user who
lacks permission (403); <code class="font-mono">unauthorized()</code> is for
a request that is not authenticated at all (401). Both import from
<code class="font-mono">@webjsdev/core</code> and are thrown, never returned.
</p>
<p class="text-muted-foreground text-sm mb-2">
Same throw model as <code class="font-mono">notFound()</code> (renders the
nearest <code class="font-mono">not-found.ts</code>) and
<code class="font-mono">redirect(url)</code> (sends an HTTP 3xx). Inside a
<code class="font-mono">'use server'</code> RPC action, return an
<code class="font-mono">ActionResult</code> for an auth failure instead of
throwing, since a raw throw there is a generic 500.
</p>
<p class="text-muted-foreground text-sm">
Two boundaries are root-only and live at the app root:
<code class="font-mono">app/global-error.ts</code> (the app-wide catch-all,
which renders its own <code class="font-mono">&lt;html&gt;</code> document)
and <code class="font-mono">app/global-not-found.ts</code> (a 404 for a URL
that matches nothing anywhere).
</p>
<p class="mt-3"><a class="text-primary" href="/">Back to the gallery</a></p>
`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// A page that throws unauthorized(). In a real app you would call this when the
// request is not authenticated (no valid session), typically to prompt a sign-in.
// Here it throws unconditionally so the demo always shows the 401 boundary. The
// nearest unauthorized.ts (this folder's sibling) renders in place of this page
// at status 401; without a nearest one the framework renders a default 401 page.
import { unauthorized } from '@webjsdev/core';

export default function Private() {
unauthorized();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// The nearest 401 boundary for a thrown unauthorized(). An unauthorized.ts
// default-exports a function returning a TemplateResult, rendered at status 401
// in place of the page that threw. Nearest wins: this one (inside private/) beats
// any unauthorized.ts higher up the tree. A real one usually links to sign-in.
import { html } from '@webjsdev/core';

export default function Unauthorized() {
return html`
<h1 class="text-h2 font-bold mb-4">401 Unauthorized</h1>
<p class="text-muted-foreground mb-4">
You need to sign in to view this page. This is the nearest
<code class="font-mono">unauthorized.ts</code> boundary, rendered because the
page threw <code class="font-mono">unauthorized()</code>.
</p>
<p><a class="text-primary" href="/features/boundaries">Back to boundaries</a></p>
`;
}
8 changes: 8 additions & 0 deletions packages/cli/templates/gallery/app/features/routing/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,13 @@ export default function RoutingExample() {
<code class="font-mono">redirect()</code> on the server, call
<code class="font-mono">navigate()</code> on the client.
</p>
<p class="text-muted-foreground text-sm mt-3">
A page can also THROW to short-circuit rendering:
<code class="font-mono">notFound()</code> (404),
<code class="font-mono">forbidden()</code> (403), and
<code class="font-mono">unauthorized()</code> (401), each rendering the
nearest matching boundary file. See the
<a class="text-primary" href="/features/boundaries">Boundaries</a> demo.
</p>
`;
}
7 changes: 6 additions & 1 deletion test/scaffolds/scaffold-gallery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { scaffoldApp } from '../../packages/cli/lib/create.js';

// Single-feature demos under app/features/<name>.
const FEATURES = [
'routing', 'components', 'server-actions', 'optimistic-ui',
'routing', 'boundaries', 'components', 'server-actions', 'optimistic-ui',
'async-render', 'directives', 'route-handler', 'forms',
'metadata', 'caching', 'env', 'client-router', 'service-worker',
'websockets', 'broadcast', 'rate-limit', 'file-storage',
Expand Down Expand Up @@ -55,6 +55,11 @@ test('full-stack scaffold ships feature demos and one example app', async () =>
for (const name of EXAMPLE_APPS) {
assert.ok(await exists(join(appDir, 'app', 'examples', name, 'page.ts')), `app/examples/${name}/page.ts`);
}
// Boundaries demo: a forbidden()/unauthorized() thrower next to its nearest boundary file.
assert.ok(await exists(join(appDir, 'app', 'features', 'boundaries', 'gated', 'page.ts')));
assert.ok(await exists(join(appDir, 'app', 'features', 'boundaries', 'gated', 'forbidden.ts')));
assert.ok(await exists(join(appDir, 'app', 'features', 'boundaries', 'private', 'page.ts')));
assert.ok(await exists(join(appDir, 'app', 'features', 'boundaries', 'private', 'unauthorized.ts')));
// Dynamic route param example + the route.ts handler.
assert.ok(await exists(join(appDir, 'app', 'features', 'routing', '[id]', 'page.ts')));
assert.ok(await exists(join(appDir, 'app', 'features', 'route-handler', 'data', 'route.ts')));
Expand Down
Loading