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
2 changes: 1 addition & 1 deletion agent-docs/framework-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ The scaffold is webjs's primary teaching surface for AI agents, so a new framewo

- **Tier 2 (CI gate):** `test/scaffolds/gallery-coverage.test.js` reconciles the LIVE framework surface against `test/scaffolds/gallery-coverage.json` and FAILS when something new is neither demoed nor exempted. It gates three surfaces: `@webjsdev/core` exports (a `{ demo }` gallery-file pointer), `@webjsdev/server` exports (`{ demoed: true }`, verified by a generated app importing it), and routing convention files (the stems DERIVED from `packages/server/src/router.js`, each demonstrated by a file in a generated app). It runs under `npm test`, so a local `--no-verify` cannot skip it: a new export or convention turns CI red until classified. The `reconcile()` / `reconcileSet()` cores are pure and their failure modes (new name, stale key, missing/over-claimed demo, empty reason) are proven with synthetic inputs alongside the real-surface assertions. The deferred backlog is tracked in #859.

**When you add or rename a `@webjsdev/core` export, update the manifest** the same way you write a test: add a demo pointer, or an honest exemption. The gate covers the export surface (where the #848-class throwers live); a new routing convention FILE type (a `*.ts` boundary) is not an export, so it stays tier-1-only for now.
**When you add or rename a `@webjsdev/core` or `@webjsdev/server` export, or add a routing convention file the router parses, update the manifest** the same way you write a test: add a demo pointer / `{ demoed: true }`, or an honest exemption. All three surfaces are gated (the convention stems are derived from `packages/server/src/router.js`, so a new `stem === '...'` branch auto-appears and must be classified).

---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// A page that throws a real render error, so the nearest error.ts boundary
// (../error.ts) catches it. In a real app an unexpected throw (a failed query,
// a bug) lands here; expected failures should return an ActionResult or throw
// notFound() / forbidden() instead.
export default function Crash() {
throw new Error('demo: this page threw during render');
}
16 changes: 16 additions & 0 deletions packages/cli/templates/gallery/app/features/boundaries/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// error.ts is the error boundary for this segment's subtree. A render-time
// exception in a sibling or deeper page (see crash/page.ts) is caught here and
// rendered scoped to this boundary, so outer layouts stay alive. The default
// export receives { error, ...ctx }; in production only error.message is sent.
import { html } from '@webjsdev/core';

export default function BoundariesError({ error }: { error: Error }) {
return html`
<h1 class="text-h2 font-bold mb-4">Something went wrong</h1>
<p class="text-muted-foreground mb-4">
This segment's <code class="font-mono">error.ts</code> boundary caught a
render error: <code class="font-mono">${error?.message ?? 'unknown'}</code>.
</p>
<p><a class="text-primary" href="/features/boundaries">Back to boundaries</a></p>
`;
}
15 changes: 15 additions & 0 deletions packages/cli/templates/gallery/app/features/boundaries/loading.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// loading.ts auto-wraps the sibling page in a Suspense boundary: its default
// export is the fallback shown while an async page (or its streamed regions)
// resolves. The boundaries index is fast, so you rarely see this, but any async
// page in this subtree gets this skeleton for free during navigation.
import { html } from '@webjsdev/core';

export default function BoundariesLoading() {
return html`
<div class="animate-pulse">
<div class="h-7 w-40 rounded bg-muted mb-4"></div>
<div class="h-4 w-full rounded bg-muted mb-2"></div>
<div class="h-4 w-3/4 rounded bg-muted"></div>
</div>
`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// not-found.ts is the nearest 404 boundary for this subtree. It renders both
// for a thrown notFound() in a page below here AND for an unmatched URL under
// /features/boundaries/ (try /features/boundaries/does-not-exist). Nearest wins,
// so this beats the root not-found for anything in this segment.
import { html } from '@webjsdev/core';

export default function BoundariesNotFound() {
return html`
<h1 class="text-h2 font-bold mb-4">404: Not here</h1>
<p class="text-muted-foreground mb-4">
This segment's <code class="font-mono">not-found.ts</code> boundary rendered,
because a page threw <code class="font-mono">notFound()</code> or the URL
matched nothing under this segment.
</p>
<p><a class="text-primary" href="/features/boundaries">Back to boundaries</a></p>
`;
}
10 changes: 10 additions & 0 deletions packages/cli/templates/gallery/app/features/boundaries/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ export default function BoundariesExample() {
throws <code class="font-mono">unauthorized()</code>, caught by
<code class="font-mono">private/unauthorized.ts</code> (401).
</li>
<li>
<a class="text-primary" href="/features/boundaries/crash">/features/boundaries/crash</a>
throws a render error, caught by this segment's
<code class="font-mono">error.ts</code> (500).
</li>
<li>
<a class="text-primary" href="/features/boundaries/does-not-exist">/features/boundaries/does-not-exist</a>
matches nothing, caught by the nearest
<code class="font-mono">not-found.ts</code> (404).
</li>
</ul>
<p class="text-muted-foreground text-sm mb-2">
<code class="font-mono">forbidden()</code> is for an authenticated user who
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// JS off, every link is a normal full-page navigation.
import { html } from '@webjsdev/core';
import type { Metadata } from '@webjsdev/core';
import '#modules/client-router/components/router-controls.ts';

export const metadata: Metadata = { title: 'Client router (soft nav) | features' };

Expand All @@ -24,6 +25,8 @@ export default function ClientRouterExample() {
<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>
<a href="/" class="text-muted-foreground no-underline font-medium text-sm hover:text-foreground transition-colors">Home</a>
</div>
<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>
<router-controls></router-controls>
<p class="text-muted-foreground text-sm mt-6">
Opt out app-wide with <code class="font-mono">{ "webjs": { "clientRouter": false } }</code>,
or per-link with <code class="font-mono">data-no-router</code> (use it for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { html } from '@webjsdev/core';
import type { Metadata } from '@webjsdev/core';
import '#modules/components/components/counter-card.ts';
import '#modules/components/components/reactive-meter.ts';

export const metadata: Metadata = { title: 'Components (signals + slots) | features' };

Expand All @@ -10,5 +11,7 @@ export default function ComponentsExample() {
<h1 class="text-h2 font-bold mb-4">Components</h1>
<p class="text-muted-foreground mb-4">The WebComponent factory, a reactive prop, an instance signal, and a slot.</p>
<counter-card label="Taps"><strong>A slotted title</strong></counter-card>
<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>
<reactive-meter></reactive-meter>
`;
}
22 changes: 22 additions & 0 deletions packages/cli/templates/gallery/app/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// webjs-scaffold-placeholder. Metadata route. Keep and adapt it, or prune it
// (delete this file), then delete this marker line. webjs check fails while the
// marker remains.
//
// app/manifest.ts serves /manifest.json (the web app manifest). The default
// export returns an object, serialized to JSON. Adapt the name, colors, and
// icons to your app; pair it with the opt-in service worker for an installable
// PWA. See agent-docs/service-worker.md. (Gallery files are copied verbatim, so
// set the real app name here by hand rather than expecting substitution.)
export default function Manifest() {
return {
name: 'webjs app',
short_name: 'webjs app',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#1c1613',
icons: [
{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' },
],
};
}
16 changes: 16 additions & 0 deletions packages/cli/templates/gallery/app/robots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// webjs-scaffold-placeholder. Metadata route. Keep and adapt it, or prune it
// (delete this file), then delete this marker line. webjs check fails while the
// marker remains.
//
// app/robots.ts serves /robots.txt. The default export returns a string (served
// as text/plain) or an object. This example allows all crawlers and points them
// at the sitemap. Tighten the rules (Disallow paths) for a real app.
const SITE_URL = (process.env.SITE_URL || 'http://localhost:8080').replace(/\/$/, '');

export default function Robots() {
return [
'User-agent: *',
'Allow: /',
`Sitemap: ${SITE_URL}/sitemap.xml`,
].join('\n');
}
23 changes: 23 additions & 0 deletions packages/cli/templates/gallery/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// webjs-scaffold-placeholder. Metadata route. Keep and adapt it to your real
// routes, or prune it (delete this file), then delete this marker line. webjs
// check fails while the marker remains.
//
// app/sitemap.ts serves /sitemap.xml. The default export is a (possibly async)
// server function; `sitemap(entries)` from @webjsdev/server serializes a
// spec-valid XML sitemap. In a real app, build the entries from your content
// (a query over posts/products), so new content is discoverable without editing
// this file. Here it lists the gallery's static routes as an example.
import { sitemap } from '@webjsdev/server';

const SITE_URL = (process.env.SITE_URL || 'http://localhost:8080').replace(/\/$/, '');

export default function Sitemap() {
const routes = ['/', '/features/routing', '/features/boundaries'];
return sitemap(
routes.map((path) => ({
url: `${SITE_URL}${path}`,
changeFrequency: 'weekly' as const,
priority: path === '/' ? 1.0 : 0.7,
})),
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Programmatic client navigation. `navigate(url)` does the same soft, in-place
// swap an <a> click does, but from an event handler (after a save, a wizard
// step, etc.). `revalidate(url?)` evicts the browser snapshot cache so the next
// visit refetches fresh HTML instead of the cached page. Both are client-only
// (they run in the browser), so a component is the right home; a page/layout
// never hydrates. With JS off this component is inert, so keep real navigation
// on plain <a href> and use navigate() only for JS-driven flows.
import { WebComponent, html, navigate, revalidate } from '@webjsdev/core';

export class RouterControls extends WebComponent {
render() {
return html`
<div class="flex gap-3 items-center">
<button
@click=${() => navigate('/features/client-router/second')}
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>
<button
@click=${() => revalidate()}
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>
</div>
`;
}
}
RouterControls.register('router-controls');
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// A shadow-DOM component: `static shadow = true` scopes `static styles = css\`\``
// to this element (bare selectors do not leak out), the one place the lit reflex
// to scope CSS is right in webjs. It also shows the rest of the signals API:
// `computed` (a derived signal), `effect` (a browser-side reaction that runs on
// change), and `batch` (coalesce several writes into ONE re-render).
import { WebComponent, html, css, signal, computed, effect, batch } from '@webjsdev/core';

export class ReactiveMeter extends WebComponent {
static shadow = true;
static styles = css`
.row { display: flex; align-items: center; gap: 8px; }
button {
padding: 6px 12px; border-radius: 10px; border: 1px solid #8883;
background: transparent; color: inherit; cursor: pointer; font: inherit;
}
.val { font-variant-numeric: tabular-nums; font-weight: 600; }
.muted { opacity: 0.6; font-size: 13px; }
`;

private count = signal(0);
// computed: recomputed lazily when count changes, cached otherwise.
private doubled = computed(() => this.count.get() * 2);
private lastLogged = signal('');
private disposeEffect?: () => void;

connectedCallback() {
super.connectedCallback();
// effect: runs now and again whenever a signal it reads changes. Browser-only
// (SSR never calls connectedCallback), so it is the place for reactive
// side effects.
this.disposeEffect = effect(() => {
this.lastLogged.set('count is ' + this.count.get());
});
}
disconnectedCallback() {
super.disconnectedCallback?.();
this.disposeEffect?.();
}

private bump(n: number) {
this.count.set(this.count.get() + n);
}
// batch: both writes commit together, so the component re-renders ONCE.
private reset() {
batch(() => {
this.count.set(0);
this.lastLogged.set('reset');
});
}

render() {
return html`
<div class="row">
<button @click=${() => this.bump(-1)}>-1</button>
<span class="val">${this.count.get()}</span>
<button @click=${() => this.bump(1)}>+1</button>
<span class="muted">doubled: ${this.doubled.get()}</span>
<button @click=${() => this.reset()}>reset</button>
</div>
<p class="muted">${this.lastLogged.get()}</p>
`;
}
}
ReactiveMeter.register('reactive-meter');
Loading
Loading