@webjsdev/cli 0.10.30 → 0.10.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/webjs.js +1 -1
- package/lib/api-gallery.js +229 -0
- package/lib/create.js +462 -161
- package/lib/saas-template.js +39 -15
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +78 -1
- package/templates/.claude/hooks/check-server-imports.mjs +86 -0
- package/templates/.claude/hooks/check-server-imports.sh +26 -0
- package/templates/.claude/hooks/cleanup-merged-worktree.sh +129 -0
- package/templates/.claude/hooks/commit-before-stop.sh +52 -0
- package/templates/.claude/settings.json +28 -0
- package/templates/.cursorrules +50 -1
- package/templates/.github/copilot-instructions.md +50 -1
- package/templates/AGENTS.md +180 -13
- package/templates/CLAUDE.md +22 -0
- package/templates/CONVENTIONS.md +165 -12
- package/templates/gallery/app/examples/todo/page.ts +34 -0
- package/templates/gallery/app/features/async-render/page.ts +14 -0
- package/templates/gallery/app/features/broadcast/feed/route.ts +19 -0
- package/templates/gallery/app/features/broadcast/page.ts +24 -0
- package/templates/gallery/app/features/caching/page.ts +39 -0
- package/templates/gallery/app/features/client-router/page.ts +34 -0
- package/templates/gallery/app/features/client-router/second/page.ts +20 -0
- package/templates/gallery/app/features/components/page.ts +14 -0
- package/templates/gallery/app/features/directives/page.ts +14 -0
- package/templates/gallery/app/features/env/page.ts +36 -0
- package/templates/gallery/app/features/file-storage/file/[key]/route.ts +19 -0
- package/templates/gallery/app/features/file-storage/page.ts +62 -0
- package/templates/gallery/app/features/forms/page.ts +78 -0
- package/templates/gallery/app/features/metadata/page.ts +55 -0
- package/templates/gallery/app/features/optimistic-ui/page.ts +14 -0
- package/templates/gallery/app/features/rate-limit/page.ts +29 -0
- package/templates/gallery/app/features/rate-limit/ping/middleware.ts +8 -0
- package/templates/gallery/app/features/rate-limit/ping/route.ts +7 -0
- package/templates/gallery/app/features/route-handler/data/route.ts +8 -0
- package/templates/gallery/app/features/route-handler/page.ts +13 -0
- package/templates/gallery/app/features/routing/[id]/page.ts +25 -0
- package/templates/gallery/app/features/routing/page.ts +47 -0
- package/templates/gallery/app/features/server-actions/page.ts +14 -0
- package/templates/gallery/app/features/service-worker/page.ts +36 -0
- package/templates/gallery/app/features/websockets/echo/route.ts +19 -0
- package/templates/gallery/app/features/websockets/page.ts +25 -0
- package/templates/gallery/modules/async-render/components/server-clock.ts +26 -0
- package/templates/gallery/modules/async-render/queries/server-greeting.server.ts +9 -0
- package/templates/gallery/modules/broadcast/components/broadcast-feed.ts +61 -0
- package/templates/gallery/modules/components/components/browser/counter-card.test.js +36 -0
- package/templates/gallery/modules/components/components/counter-card.ts +35 -0
- package/templates/gallery/modules/directives/components/directive-demo.ts +53 -0
- package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +18 -0
- package/templates/gallery/modules/optimistic-ui/actions/like-post.server.ts +9 -0
- package/templates/gallery/modules/optimistic-ui/components/like-button.ts +35 -0
- package/templates/gallery/modules/rate-limit/components/rate-probe.ts +49 -0
- package/templates/gallery/modules/server-actions/actions/greet.server.ts +12 -0
- package/templates/gallery/modules/server-actions/components/greeter.ts +30 -0
- package/templates/gallery/modules/server-actions/utils/format.server.ts +8 -0
- package/templates/gallery/modules/todo/actions/create-todo.server.ts +16 -0
- package/templates/gallery/modules/todo/actions/delete-todo.server.ts +15 -0
- package/templates/gallery/modules/todo/actions/toggle-todo.server.ts +21 -0
- package/templates/gallery/modules/todo/components/todo-app.ts +140 -0
- package/templates/gallery/modules/todo/queries/list-todos.server.ts +25 -0
- package/templates/gallery/modules/todo/types.ts +12 -0
- package/templates/gallery/modules/websockets/components/ws-echo.ts +62 -0
- package/templates/lib/utils/ui.ts +4 -4
- package/templates/test/hello/browser/hello.test.js +6 -0
- package/templates/web-test-runner.config.js +9 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// webjs-scaffold-placeholder. Feature gallery route. Keep and adapt it, or prune it (delete this app/features/forms route), then delete this marker line. webjs check fails while the marker remains.
|
|
2
|
+
// forms: the no-JS write path. A real <form method="post"> posts to this page's
|
|
3
|
+
// `action` export, and the framework re-renders the SAME page with the result on
|
|
4
|
+
// `actionData`. WHY it matters: the form works with JS OFF (server round-trip),
|
|
5
|
+
// and with JS the client router applies the response in place (no full reload).
|
|
6
|
+
// Never reach for fetch() + a click handler where a <form> + page action does.
|
|
7
|
+
// On failure the framework re-renders at 422 with the result; on success it
|
|
8
|
+
// does a 303 Post-Redirect-Get, so we redirect to ?sent=1 to show a confirmation.
|
|
9
|
+
import { html } from '@webjsdev/core';
|
|
10
|
+
import type { Metadata } from '@webjsdev/core';
|
|
11
|
+
|
|
12
|
+
export const metadata: Metadata = { title: 'Forms (no-JS PE) | features' };
|
|
13
|
+
|
|
14
|
+
interface Result {
|
|
15
|
+
success: boolean;
|
|
16
|
+
fieldErrors?: Record<string, string>;
|
|
17
|
+
values?: Record<string, string>;
|
|
18
|
+
redirect?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const field = (label: string, name: string, input: unknown, error?: string) => html`
|
|
22
|
+
<div class="grid gap-1.5">
|
|
23
|
+
<label for=${name} class="text-[13px] font-medium text-muted-foreground">${label}</label>
|
|
24
|
+
${input}
|
|
25
|
+
${error ? html`<p class="m-0 text-[12.5px] text-destructive">${error}</p>` : ''}
|
|
26
|
+
</div>
|
|
27
|
+
`;
|
|
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/70';
|
|
30
|
+
|
|
31
|
+
export default function FormsFeature({ searchParams, actionData }: { searchParams: Record<string, string | undefined>; actionData?: Result }) {
|
|
32
|
+
if (searchParams.sent) {
|
|
33
|
+
return html`
|
|
34
|
+
<h1 class="text-h2 font-bold mb-4">Forms</h1>
|
|
35
|
+
<div class="max-w-[460px] grid gap-3 p-6 rounded-2xl bg-card border border-border text-center">
|
|
36
|
+
<span class="mx-auto grid place-items-center w-12 h-12 rounded-2xl bg-primary/15 text-primary">
|
|
37
|
+
<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>
|
|
38
|
+
</span>
|
|
39
|
+
<p class="m-0 text-lg font-semibold text-foreground">Message sent</p>
|
|
40
|
+
<p class="m-0 text-sm text-muted-foreground">Thanks, we got it. <a class="text-primary" href="/features/forms">Send another</a>.</p>
|
|
41
|
+
</div>
|
|
42
|
+
`;
|
|
43
|
+
}
|
|
44
|
+
const errs = actionData?.fieldErrors ?? {};
|
|
45
|
+
const v = actionData?.values ?? {};
|
|
46
|
+
return html`
|
|
47
|
+
<h1 class="text-h2 font-bold mb-2">Forms</h1>
|
|
48
|
+
<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>
|
|
49
|
+
<form method="post" action="" class="max-w-[460px] grid gap-4 p-5 rounded-2xl bg-card border border-border">
|
|
50
|
+
${field('Name', 'name', html`<input id="name" name="name" value=${v.name ?? ''} class=${inputCls} placeholder="Ada Lovelace" />`, errs.name)}
|
|
51
|
+
${field('Email', 'email', html`<input id="email" name="email" type="email" value=${v.email ?? ''} class=${inputCls} placeholder="ada@example.com" />`, errs.email)}
|
|
52
|
+
${field('Message', 'message', html`<textarea id="message" name="message" rows="3" class=${inputCls} placeholder="Say hello...">${v.message ?? ''}</textarea>`, errs.message)}
|
|
53
|
+
<button type="submit" class="justify-self-start 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]">Send message</button>
|
|
54
|
+
</form>
|
|
55
|
+
`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The page action runs on a non-GET submission to this URL (the no-JS write
|
|
59
|
+
// path). Validate, then return a failure (re-renders at 422 with fieldErrors +
|
|
60
|
+
// values) or a success with a same-site `redirect` (a 303 PRG to the confirmation).
|
|
61
|
+
//
|
|
62
|
+
// FOOTGUN: to redirect on success, RETURN `{ success: true, redirect: '/path' }`
|
|
63
|
+
// (a 303 See Other, so the browser follows with a GET). Do NOT THROW `redirect()`
|
|
64
|
+
// from a page action, that is a 307 which PRESERVES the POST method and body, so
|
|
65
|
+
// the browser re-POSTs to the target and re-runs the mutation (a duplicate write).
|
|
66
|
+
// Throw `redirect()` only from a page render / GET context, never a page action.
|
|
67
|
+
export async function action({ formData }: { formData: FormData }): Promise<Result> {
|
|
68
|
+
const name = String(formData.get('name') ?? '').trim();
|
|
69
|
+
const email = String(formData.get('email') ?? '').trim();
|
|
70
|
+
const message = String(formData.get('message') ?? '').trim();
|
|
71
|
+
const fieldErrors: Record<string, string> = {};
|
|
72
|
+
if (!name) fieldErrors.name = 'Your name is required.';
|
|
73
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) fieldErrors.email = 'A valid email is required.';
|
|
74
|
+
if (message.length < 5) fieldErrors.message = 'Message must be at least 5 characters.';
|
|
75
|
+
if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values: { name, email, message } };
|
|
76
|
+
// A real app would persist / email here. We just confirm.
|
|
77
|
+
return { success: true, redirect: '/features/forms?sent=1' };
|
|
78
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// webjs-scaffold-placeholder. Feature gallery route. Keep and adapt it, or prune it (delete this app/features/metadata route), then delete this marker line. webjs check fails while the marker remains.
|
|
2
|
+
// Metadata: how a page declares its <title>, description, and Open Graph tags.
|
|
3
|
+
// A static `metadata` export is read as-is. `generateMetadata(ctx)` takes
|
|
4
|
+
// precedence and can read the request (params, searchParams, url) to compute
|
|
5
|
+
// the tags, and may be async. Both run only on the server, so the tags are in
|
|
6
|
+
// the first paint (no JS, crawler-friendly). Whole-page metadata ROUTES
|
|
7
|
+
// (sitemap.ts, robots.ts, opengraph-image.ts, ...) live at the app root.
|
|
8
|
+
import { html } from '@webjsdev/core';
|
|
9
|
+
import type { Metadata } from '@webjsdev/core';
|
|
10
|
+
|
|
11
|
+
// generateMetadata wins over a static `metadata` export when both exist. Here
|
|
12
|
+
// it derives the title from a ?topic= query param to show the dynamic form;
|
|
13
|
+
// with no param it falls back to a default. Change the URL to ?topic=webjs and
|
|
14
|
+
// view source: the <title> follows.
|
|
15
|
+
export function generateMetadata({
|
|
16
|
+
searchParams,
|
|
17
|
+
}: {
|
|
18
|
+
searchParams: Record<string, string | undefined>;
|
|
19
|
+
}): Metadata {
|
|
20
|
+
const topic = (searchParams.topic || '').trim();
|
|
21
|
+
return {
|
|
22
|
+
title: topic ? topic + ' | metadata' : 'Metadata (generateMetadata) | features',
|
|
23
|
+
description: 'How a webjs page declares title, description, and Open Graph tags.',
|
|
24
|
+
openGraph: { title: topic || 'Metadata', type: 'article' },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function MetadataExample({
|
|
29
|
+
searchParams,
|
|
30
|
+
}: {
|
|
31
|
+
searchParams: Record<string, string | undefined>;
|
|
32
|
+
}) {
|
|
33
|
+
const topic = (searchParams.topic || '').trim();
|
|
34
|
+
return html`
|
|
35
|
+
<h1 class="text-h2 font-bold mb-4">Metadata</h1>
|
|
36
|
+
<p class="text-muted-foreground mb-4">
|
|
37
|
+
<code>generateMetadata(ctx)</code> runs on the server and can read the
|
|
38
|
+
request, so the <code><title></code> is computed per URL. View source
|
|
39
|
+
to see the tag this page produced.
|
|
40
|
+
</p>
|
|
41
|
+
<p class="mb-4">
|
|
42
|
+
Current title source:
|
|
43
|
+
<code class="font-mono text-sm">${topic ? '?topic=' + topic : '(default, no ?topic=)'}</code>
|
|
44
|
+
</p>
|
|
45
|
+
<ul class="list-disc pl-5 mb-4">
|
|
46
|
+
<li><a class="text-primary" href="/features/metadata?topic=webjs">?topic=webjs</a></li>
|
|
47
|
+
<li><a class="text-primary" href="/features/metadata?topic=Routing">?topic=Routing</a></li>
|
|
48
|
+
<li><a class="text-primary" href="/features/metadata">clear the param</a></li>
|
|
49
|
+
</ul>
|
|
50
|
+
<p class="text-muted-foreground text-sm">
|
|
51
|
+
Site-wide metadata (sitemap, robots, Open Graph images) lives in metadata
|
|
52
|
+
ROUTES at the app root, e.g. <code class="font-mono">app/sitemap.ts</code>.
|
|
53
|
+
</p>
|
|
54
|
+
`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// webjs-scaffold-placeholder. Feature gallery route. Keep and adapt it, or prune it (delete this app/features/optimistic-ui route AND modules/optimistic-ui), then delete this marker line. webjs check fails while the marker remains.
|
|
2
|
+
import { html } from '@webjsdev/core';
|
|
3
|
+
import type { Metadata } from '@webjsdev/core';
|
|
4
|
+
import '#modules/optimistic-ui/components/like-button.ts';
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = { title: 'Optimistic UI (imperative flip) | features' };
|
|
7
|
+
|
|
8
|
+
export default function OptimisticUiFeature() {
|
|
9
|
+
return html`
|
|
10
|
+
<h1 class="text-h2 font-bold mb-4">Optimistic UI</h1>
|
|
11
|
+
<p class="text-muted-foreground mb-4">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" href="/examples/todo">/examples/todo</a>.</p>
|
|
12
|
+
<like-button></like-button>
|
|
13
|
+
`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// webjs-scaffold-placeholder. Feature gallery route. Keep and adapt it, or prune it (delete this app/features/rate-limit route AND modules/rate-limit), then delete this marker line. webjs check fails while the marker remains.
|
|
2
|
+
// Rate limiting: rateLimit() from '@webjsdev/server' is a middleware. It lives in
|
|
3
|
+
// a middleware.ts scoped to the endpoint it protects (here app/features/
|
|
4
|
+
// rate-limit/ping/middleware.ts, so it limits /features/rate-limit/ping WITHOUT
|
|
5
|
+
// touching this page). It is backed by the pluggable cache store, in-memory by
|
|
6
|
+
// default; point the store at Redis to share the window across instances.
|
|
7
|
+
import { html } from '@webjsdev/core';
|
|
8
|
+
import type { Metadata } from '@webjsdev/core';
|
|
9
|
+
import '#modules/rate-limit/components/rate-probe.ts';
|
|
10
|
+
|
|
11
|
+
export const metadata: Metadata = { title: 'Rate limiting (rateLimit middleware) | features' };
|
|
12
|
+
|
|
13
|
+
export default function RateLimitExample() {
|
|
14
|
+
return html`
|
|
15
|
+
<h1 class="text-h2 font-bold mb-4">Rate limiting</h1>
|
|
16
|
+
<p class="text-muted-foreground mb-4">
|
|
17
|
+
The <code class="font-mono">/ping</code> endpoint is limited to five
|
|
18
|
+
requests per ten seconds by a scoped <code class="font-mono">middleware.ts</code>.
|
|
19
|
+
Ping past the limit to get a <code class="font-mono">429</code> with a
|
|
20
|
+
<code class="font-mono">Retry-After</code> header.
|
|
21
|
+
</p>
|
|
22
|
+
<rate-probe></rate-probe>
|
|
23
|
+
<p class="text-muted-foreground text-sm mt-4">
|
|
24
|
+
With JavaScript off, hit
|
|
25
|
+
<a class="text-primary" href="/features/rate-limit/ping">/features/rate-limit/ping</a>
|
|
26
|
+
directly (refresh past five times in ten seconds).
|
|
27
|
+
</p>
|
|
28
|
+
`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Per-segment middleware. It sits in the ping/ folder, so it applies ONLY to
|
|
2
|
+
// /features/rate-limit/ping (its route.ts), not to the demo page one level up.
|
|
3
|
+
// rateLimit() returns a standard webjs middleware: return a Response to
|
|
4
|
+
// short-circuit (the 429), or call next() to continue. Keyed by client IP by
|
|
5
|
+
// default; pass `key` to key by user id, API key, etc.
|
|
6
|
+
import { rateLimit } from '@webjsdev/server';
|
|
7
|
+
|
|
8
|
+
export default rateLimit({ window: '10s', max: 5, message: 'Slow down: five requests per ten seconds.' });
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The rate-limited endpoint. A plain GET route handler: it just reports success.
|
|
2
|
+
// The rateLimit() middleware in this folder runs BEFORE it and stamps the
|
|
3
|
+
// X-RateLimit-* headers onto the response (and returns a 429 once the window is
|
|
4
|
+
// exhausted, so this handler never runs on a limited request).
|
|
5
|
+
export function GET() {
|
|
6
|
+
return Response.json({ ok: true, at: new Date().toISOString() });
|
|
7
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// A route.ts is a server-only HTTP handler (named GET / POST / PUT / PATCH /
|
|
2
|
+
// DELETE exports). It is NOT isomorphic and never ships to the client, the webjs
|
|
3
|
+
// equivalent of a Next route handler. Each handler returns a Response (a plain
|
|
4
|
+
// value auto-JSONs). A folder cannot have BOTH page.ts and route.ts, so this
|
|
5
|
+
// endpoint lives one segment deeper, at /features/route-handler/data.
|
|
6
|
+
export async function GET() {
|
|
7
|
+
return Response.json({ ok: true, at: new Date().toISOString() });
|
|
8
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// webjs-scaffold-placeholder. Feature gallery route. Keep and adapt it, or prune it (delete this app/features/route-handler route, including its data/route.ts handler), then delete this marker line. webjs check fails while the marker remains.
|
|
2
|
+
import { html } from '@webjsdev/core';
|
|
3
|
+
import type { Metadata } from '@webjsdev/core';
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = { title: 'Route handlers (route.ts) | features' };
|
|
6
|
+
|
|
7
|
+
export default function RouteHandlerExample() {
|
|
8
|
+
return html`
|
|
9
|
+
<h1 class="text-h2 font-bold mb-4">Route handlers</h1>
|
|
10
|
+
<p class="text-muted-foreground mb-4">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.</p>
|
|
11
|
+
<p><a class="text-primary" href="/features/route-handler/data">GET /features/route-handler/data</a> returns JSON.</p>
|
|
12
|
+
`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// A dynamic route: `params.id` is the URL segment. The default export is a
|
|
2
|
+
// (possibly async) function receiving { params, searchParams, url }; it runs
|
|
3
|
+
// ONLY on the server. Throw notFound() / redirect() to short-circuit.
|
|
4
|
+
//
|
|
5
|
+
// Type-safe routes: instead of hand-typing `{ params: { id: string } }`, type
|
|
6
|
+
// the props with PageProps<'<route>'>. `webjs types` (run automatically by
|
|
7
|
+
// `webjs dev`) generates .webjs/routes.d.ts with a Route union and per-route
|
|
8
|
+
// params, so PageProps<'/features/routing/[id]'>['params'] narrows to
|
|
9
|
+
// { id: string } automatically. Rename the folder and the type follows; pass a
|
|
10
|
+
// route literal that does not exist and it is a compile error.
|
|
11
|
+
import { html } from '@webjsdev/core';
|
|
12
|
+
import type { PageProps } from '@webjsdev/core';
|
|
13
|
+
|
|
14
|
+
export default function RoutingParam({ params }: PageProps<'/features/routing/[id]'>) {
|
|
15
|
+
return html`
|
|
16
|
+
<h1 class="text-h2 font-bold mb-4">Route param</h1>
|
|
17
|
+
<p>The <code>[id]</code> segment is: <strong>${params.id}</strong></p>
|
|
18
|
+
<p class="text-muted-foreground text-sm mt-3">
|
|
19
|
+
Typed with <code class="font-mono">PageProps<'/features/routing/[id]'></code>,
|
|
20
|
+
so <code class="font-mono">params.id</code> is a checked
|
|
21
|
+
<code class="font-mono">string</code> from the generated route union.
|
|
22
|
+
</p>
|
|
23
|
+
<p class="mt-3"><a class="text-primary" href="/features/routing">Back</a></p>
|
|
24
|
+
`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// webjs-scaffold-placeholder. Feature gallery route. Keep and adapt it, or prune it (delete this app/features/routing route), then delete this marker line. webjs check fails while the marker remains.
|
|
2
|
+
// Routing basics: a static page that links to a dynamic route. app/ is routing
|
|
3
|
+
// only; a folder maps to a URL segment, and [id] is a dynamic segment read from
|
|
4
|
+
// `params`. See app/features/routing/[id]/page.ts.
|
|
5
|
+
import { html } from '@webjsdev/core';
|
|
6
|
+
import type { Metadata } from '@webjsdev/core';
|
|
7
|
+
|
|
8
|
+
export const metadata: Metadata = { title: 'Routing (dynamic params) | features' };
|
|
9
|
+
|
|
10
|
+
export default function RoutingExample() {
|
|
11
|
+
return html`
|
|
12
|
+
<h1 class="text-h2 font-bold mb-4">Routing</h1>
|
|
13
|
+
<p class="text-muted-foreground mb-4">A folder is a URL segment; a <code>[id]</code> folder is a dynamic param.</p>
|
|
14
|
+
<ul class="list-disc pl-5 mb-4">
|
|
15
|
+
<li><a class="text-primary" href="/features/routing/42">/features/routing/42</a></li>
|
|
16
|
+
<li><a class="text-primary" href="/features/routing/hello">/features/routing/hello</a></li>
|
|
17
|
+
</ul>
|
|
18
|
+
<p class="text-muted-foreground text-sm mb-2">
|
|
19
|
+
Routes are type-safe: <code class="font-mono">webjs types</code> (run by
|
|
20
|
+
<code class="font-mono">webjs dev</code>) generates a
|
|
21
|
+
<code class="font-mono">Route</code> union, and the
|
|
22
|
+
<code class="font-mono">[id]</code> page types its props with
|
|
23
|
+
<code class="font-mono">PageProps<'/features/routing/[id]'></code>
|
|
24
|
+
so <code class="font-mono">params</code> is checked against the real routes.
|
|
25
|
+
</p>
|
|
26
|
+
<p class="text-muted-foreground text-sm mb-2">
|
|
27
|
+
Programmatic navigation is checked too:
|
|
28
|
+
<code class="font-mono">navigate(url)</code> takes that
|
|
29
|
+
<code class="font-mono">Route</code> union, so
|
|
30
|
+
<code class="font-mono">navigate('/random/42')</code> is a compile error in
|
|
31
|
+
your editor and <code class="font-mono">webjs typecheck</code>. (Plain
|
|
32
|
+
<code class="font-mono"><a href></code> strings are not checked, so
|
|
33
|
+
prefer <code class="font-mono">navigate()</code> for internal links you want
|
|
34
|
+
verified.)
|
|
35
|
+
</p>
|
|
36
|
+
<p class="text-muted-foreground text-sm">
|
|
37
|
+
Changing route in code, two sides:
|
|
38
|
+
<code class="font-mono">navigate(url)</code> runs in the browser (a soft,
|
|
39
|
+
in-place client-router nav from an event handler, no reload), while
|
|
40
|
+
<code class="font-mono">redirect(url)</code> runs on the server (throw it in
|
|
41
|
+
a page or an <code class="font-mono">action</code> to bail before render and
|
|
42
|
+
return an HTTP 3xx). Throw
|
|
43
|
+
<code class="font-mono">redirect()</code> on the server, call
|
|
44
|
+
<code class="font-mono">navigate()</code> on the client.
|
|
45
|
+
</p>
|
|
46
|
+
`;
|
|
47
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// webjs-scaffold-placeholder. Feature gallery route. Keep and adapt it, or prune it (delete this app/features/server-actions route AND modules/server-actions), then delete this marker line. webjs check fails while the marker remains.
|
|
2
|
+
import { html } from '@webjsdev/core';
|
|
3
|
+
import type { Metadata } from '@webjsdev/core';
|
|
4
|
+
import '#modules/server-actions/components/greeter.ts';
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = { title: 'Server actions (.server vs use server) | features' };
|
|
7
|
+
|
|
8
|
+
export default function ServerActionsExample() {
|
|
9
|
+
return html`
|
|
10
|
+
<h1 class="text-h2 font-bold mb-4">Server actions</h1>
|
|
11
|
+
<p class="text-muted-foreground mb-4">A 'use server' action is RPC-callable from the client; a plain .server.ts is a server-only utility you never import into a component.</p>
|
|
12
|
+
<server-greeter></server-greeter>
|
|
13
|
+
`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// webjs-scaffold-placeholder. Feature gallery route. Keep and adapt it, or prune it (delete this app/features/service-worker route), then delete this marker line. webjs check fails while the marker remains.
|
|
2
|
+
// Service worker: opt-in progressive enhancement. webjs does NOT register one by
|
|
3
|
+
// default (an active SW caches aggressively and would confuse dev). You opt in
|
|
4
|
+
// by adding public/sw.js and registering it from a component's connectedCallback
|
|
5
|
+
// (browser-only). This page is a guided reference, not an active registration,
|
|
6
|
+
// so the scaffold stays predictable until you deliberately turn it on. See
|
|
7
|
+
// agent-docs/service-worker.md for the full recipe.
|
|
8
|
+
import { html } from '@webjsdev/core';
|
|
9
|
+
import type { Metadata } from '@webjsdev/core';
|
|
10
|
+
|
|
11
|
+
export const metadata: Metadata = { title: 'Service worker (opt-in) | features' };
|
|
12
|
+
|
|
13
|
+
export default function ServiceWorkerExample() {
|
|
14
|
+
return html`
|
|
15
|
+
<h1 class="text-h2 font-bold mb-4">Service worker</h1>
|
|
16
|
+
<p class="text-muted-foreground mb-4">
|
|
17
|
+
Opt-in offline/caching enhancement. Nothing is registered until you add
|
|
18
|
+
<code class="font-mono">public/sw.js</code> and register it from a
|
|
19
|
+
browser-only lifecycle hook.
|
|
20
|
+
</p>
|
|
21
|
+
<p class="mb-2 text-sm font-medium">Register inside a component (never in a page or layout):</p>
|
|
22
|
+
<pre class="bg-card border border-border rounded-xl p-4 overflow-x-auto text-sm font-mono mb-4"><code>connectedCallback() {
|
|
23
|
+
super.connectedCallback();
|
|
24
|
+
if ('serviceWorker' in navigator) {
|
|
25
|
+
// The framework serves your public/sw.js at the site root /sw.js (with a
|
|
26
|
+
// Service-Worker-Allowed: / header), so the worker's scope is the whole origin.
|
|
27
|
+
navigator.serviceWorker.register('/sw.js');
|
|
28
|
+
}
|
|
29
|
+
}</code></pre>
|
|
30
|
+
<p class="text-muted-foreground text-sm">
|
|
31
|
+
Registration lives in a component because it is browser-only work.
|
|
32
|
+
A page or layout never hydrates, so it is the wrong home for it. Full
|
|
33
|
+
recipe: <code class="font-mono">agent-docs/service-worker.md</code>.
|
|
34
|
+
</p>
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The WebSocket endpoint for the websockets feature demo. A route.ts is a
|
|
2
|
+
// server-only HTTP file; exporting WS(ws, req, ctx) upgrades it to a WebSocket
|
|
3
|
+
// endpoint. `ws` is a standard ws-library socket: listen with ws.on('message'),
|
|
4
|
+
// reply with ws.send(). In dev the module is re-imported per connection, so keep
|
|
5
|
+
// shared state on globalThis if you need it (this echo endpoint is stateless).
|
|
6
|
+
|
|
7
|
+
// Structural type for the socket, so the demo needs no `@types/ws` dependency.
|
|
8
|
+
type WSLike = {
|
|
9
|
+
on(event: 'message' | 'close', cb: (data: Buffer) => void): void;
|
|
10
|
+
send(msg: string): void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function WS(ws: WSLike) {
|
|
14
|
+
ws.on('message', (data) => {
|
|
15
|
+
// Echo the text back to just this client. For a fan-out to ALL connected
|
|
16
|
+
// clients, use broadcast() from '@webjsdev/server' (see the broadcast demo).
|
|
17
|
+
ws.send('echo: ' + data.toString());
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// webjs-scaffold-placeholder. Feature gallery route. Keep and adapt it, or prune it (delete this app/features/websockets route AND modules/websockets), then delete this marker line. webjs check fails while the marker remains.
|
|
2
|
+
// WebSockets: a route.ts exports WS(ws, req) for the server endpoint, and a
|
|
3
|
+
// component uses connectWS() to talk to it. WebSockets are inherently JS-only
|
|
4
|
+
// (there is no no-JS fallback for a live socket), so the component degrades to a
|
|
5
|
+
// clear "requires JavaScript" note at SSR and enhances on hydration. The echo
|
|
6
|
+
// endpoint lives at app/features/websockets/echo/route.ts (a sibling folder, so
|
|
7
|
+
// it does not collide with this page).
|
|
8
|
+
import { html } from '@webjsdev/core';
|
|
9
|
+
import type { Metadata } from '@webjsdev/core';
|
|
10
|
+
import '#modules/websockets/components/ws-echo.ts';
|
|
11
|
+
|
|
12
|
+
export const metadata: Metadata = { title: 'WebSockets (connectWS + WS) | features' };
|
|
13
|
+
|
|
14
|
+
export default function WebSocketsExample() {
|
|
15
|
+
return html`
|
|
16
|
+
<h1 class="text-h2 font-bold mb-4">WebSockets</h1>
|
|
17
|
+
<p class="text-muted-foreground mb-4">
|
|
18
|
+
A <code class="font-mono">WS(ws, req)</code> export in
|
|
19
|
+
<code class="font-mono">route.ts</code> is the server endpoint;
|
|
20
|
+
<code class="font-mono">connectWS()</code> (auto-reconnect, JSON
|
|
21
|
+
encode/decode, queued sends) is the client. This echoes each message back.
|
|
22
|
+
</p>
|
|
23
|
+
<ws-echo></ws-echo>
|
|
24
|
+
`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// async render() awaits server data directly in the leaf component. Three
|
|
2
|
+
// decoupled concerns to know (see AGENTS.md "Async render"):
|
|
3
|
+
// 1. SSR always BLOCKS, so the resolved data is in the first paint with no
|
|
4
|
+
// fallback markup (progressive-enhancement safe, readable with JS off).
|
|
5
|
+
// 2. The client re-fetch default is stale-while-revalidate (old content stays
|
|
6
|
+
// until the new render resolves, no blank flash).
|
|
7
|
+
// 3. renderFallback() is the OPTIONAL re-fetch loading UI, never first paint.
|
|
8
|
+
// Reach for async render() when request-time SERVER data belongs in the first
|
|
9
|
+
// paint; reach for a Task / signals for genuinely client-only data.
|
|
10
|
+
import { WebComponent, html } from '@webjsdev/core';
|
|
11
|
+
import { serverGreeting } from '../queries/server-greeting.server.ts';
|
|
12
|
+
|
|
13
|
+
export class ServerClock extends WebComponent {
|
|
14
|
+
// A bare async-render component (no other client signal) is ELIDED, since its
|
|
15
|
+
// SSR'd HTML is already the complete output. `static refresh = true` opts into
|
|
16
|
+
// keeping the on-load re-fetch so the module ships (drop it for request-stable
|
|
17
|
+
// data you are happy to leave server-rendered).
|
|
18
|
+
static refresh = true;
|
|
19
|
+
|
|
20
|
+
async render() {
|
|
21
|
+
const info = await serverGreeting();
|
|
22
|
+
return html`<p class="font-mono text-sm">server rendered this at
|
|
23
|
+
<strong>${info.at}</strong> (pid ${info.pid})</p>`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
ServerClock.register('server-clock');
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
// A GET 'use server' query a component awaits directly inside `async render()`.
|
|
3
|
+
// It returns request-time SERVER data the client cannot know, which is exactly
|
|
4
|
+
// what async render() is for (the resolved value lands in the first paint).
|
|
5
|
+
export const method = 'GET';
|
|
6
|
+
|
|
7
|
+
export async function serverGreeting(): Promise<{ at: string; pid: number }> {
|
|
8
|
+
return { at: new Date().toISOString(), pid: process.pid };
|
|
9
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// A shared broadcast room. Same connectWS() client as the websockets demo, but
|
|
2
|
+
// the server fans each message out to all connected sockets, so every open tab
|
|
3
|
+
// shows the same feed. The socket opens in connectedCallback (browser-only) and
|
|
4
|
+
// closes in disconnectedCallback. At SSR it renders the disconnected state, so
|
|
5
|
+
// the page still reads with JS off (a live feed has no no-JS equivalent).
|
|
6
|
+
import { WebComponent, signal, html, connectWS } from '@webjsdev/core';
|
|
7
|
+
|
|
8
|
+
export class BroadcastFeed extends WebComponent {
|
|
9
|
+
private connected = signal(false);
|
|
10
|
+
private lines = signal<string[]>([]);
|
|
11
|
+
#conn: ReturnType<typeof connectWS> | null = null;
|
|
12
|
+
|
|
13
|
+
connectedCallback() {
|
|
14
|
+
super.connectedCallback();
|
|
15
|
+
this.#conn = connectWS('/features/broadcast/feed', {
|
|
16
|
+
onOpen: () => this.connected.set(true),
|
|
17
|
+
onClose: () => this.connected.set(false),
|
|
18
|
+
onMessage: (msg: unknown) => this.lines.set([...this.lines.get(), String(msg)]),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
disconnectedCallback() {
|
|
23
|
+
super.disconnectedCallback();
|
|
24
|
+
this.#conn?.close();
|
|
25
|
+
this.#conn = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private send(e: SubmitEvent) {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
const form = e.target as HTMLFormElement;
|
|
31
|
+
const input = form.elements.namedItem('msg') as HTMLInputElement;
|
|
32
|
+
const text = input.value.trim();
|
|
33
|
+
if (!text) return;
|
|
34
|
+
this.#conn?.send(text);
|
|
35
|
+
input.value = '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
render() {
|
|
39
|
+
const on = this.connected.get();
|
|
40
|
+
return html`
|
|
41
|
+
<div class="grid gap-4 max-w-[420px]">
|
|
42
|
+
<div class="flex items-center gap-2 text-sm">
|
|
43
|
+
<span class="w-2 h-2 rounded-full ${on ? 'bg-primary' : 'bg-muted-foreground/40'}"></span>
|
|
44
|
+
<span class="text-muted-foreground">${on ? 'in the room' : 'connecting (the live room needs JavaScript)'}</span>
|
|
45
|
+
</div>
|
|
46
|
+
<form @submit=${(e: SubmitEvent) => this.send(e)} class="flex gap-2">
|
|
47
|
+
<input name="msg" autocomplete="off" placeholder="Message everyone"
|
|
48
|
+
class="flex-1 px-3 py-2 rounded-xl bg-card border border-border text-foreground text-sm outline-none focus:border-border-strong" />
|
|
49
|
+
<button type="submit"
|
|
50
|
+
class="px-3.5 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]">Send</button>
|
|
51
|
+
</form>
|
|
52
|
+
<ul class="grid gap-1.5 list-none m-0 p-0">
|
|
53
|
+
${this.lines.get().map((line) => html`
|
|
54
|
+
<li class="px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground">${line}</li>
|
|
55
|
+
`)}
|
|
56
|
+
</ul>
|
|
57
|
+
</div>
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
BroadcastFeed.register('broadcast-feed');
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Co-located browser test for the <counter-card> component. This is the webjs
|
|
2
|
+
// component-test SHAPE, so copy it for your own components:
|
|
3
|
+
// - It runs in REAL Chromium (webjs test --browser, or npx wtr), not jsdom. If
|
|
4
|
+
// the browser binary is missing, install it once: npx playwright install chromium.
|
|
5
|
+
// - The runner's mocha UI is `tdd`, so use suite() / test(), NOT describe / it().
|
|
6
|
+
// - There is NO assertion library in the importmap (no chai, no expect). Throw
|
|
7
|
+
// to fail. A tiny inline `assert` is plenty.
|
|
8
|
+
// - `ssrFixture` server-renders AND hydrates the component, so you exercise the
|
|
9
|
+
// REAL SSR output and the client interactivity, not a jsdom approximation.
|
|
10
|
+
// It lives NEXT TO the component (a `browser/` dir inside the module), the
|
|
11
|
+
// co-located default; the runner discovers browser tests under any browser dir.
|
|
12
|
+
import { html } from '@webjsdev/core';
|
|
13
|
+
import { ssrFixture } from '@webjsdev/core/testing';
|
|
14
|
+
import '../counter-card.ts';
|
|
15
|
+
|
|
16
|
+
const assert = (cond, msg) => { if (!cond) throw new Error(msg || 'assertion failed'); };
|
|
17
|
+
|
|
18
|
+
suite('<counter-card>', () => {
|
|
19
|
+
test('SSRs its initial state and the default label', async () => {
|
|
20
|
+
const el = await ssrFixture(html`<counter-card></counter-card>`);
|
|
21
|
+
assert(el.textContent.includes('0'), 'the count starts at 0');
|
|
22
|
+
assert(el.textContent.includes('Clicks'), 'the default label renders');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('reads the label reactive prop', async () => {
|
|
26
|
+
const el = await ssrFixture(html`<counter-card label="Taps"></counter-card>`);
|
|
27
|
+
assert(el.textContent.includes('Taps'), 'the provided label renders');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('increments on click (hydrated interactivity)', async () => {
|
|
31
|
+
const el = await ssrFixture(html`<counter-card></counter-card>`);
|
|
32
|
+
el.querySelector('button').click();
|
|
33
|
+
await el.updateComplete;
|
|
34
|
+
assert(el.textContent.includes('1'), 'the count becomes 1 after one click');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Demonstrates the WebComponent essentials: the declare-free reactive-prop
|
|
2
|
+
// factory, an instance signal for local state, a lifecycle hook, and a <slot>
|
|
3
|
+
// for content projection (works in light DOM, the default). This is the core
|
|
4
|
+
// component authoring shape. Styling is crafted Tailwind on the app's design
|
|
5
|
+
// tokens so it stays visually coherent with the rest of the app.
|
|
6
|
+
import { WebComponent, prop, signal, html } from '@webjsdev/core';
|
|
7
|
+
|
|
8
|
+
export class CounterCard extends WebComponent({
|
|
9
|
+
// A reactive prop rides an HTML attribute / SSR hydration. Declared ONLY here
|
|
10
|
+
// (no `static properties`, no class-field `label = ''` which would clobber the
|
|
11
|
+
// accessor). Set defaults in the constructor.
|
|
12
|
+
label: prop(String),
|
|
13
|
+
}) {
|
|
14
|
+
private count = signal(0); // instance signal: component-local state
|
|
15
|
+
|
|
16
|
+
constructor() {
|
|
17
|
+
super();
|
|
18
|
+
this.label = this.label || 'Clicks';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
render() {
|
|
22
|
+
return html`
|
|
23
|
+
<div class="grid gap-4 p-5 rounded-2xl bg-card border border-border max-w-[320px]">
|
|
24
|
+
<slot></slot>
|
|
25
|
+
<div class="flex items-baseline gap-2">
|
|
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/70">${this.label}</span>
|
|
28
|
+
</div>
|
|
29
|
+
<button @click=${() => this.count.set(this.count.get() + 1)}
|
|
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>
|
|
31
|
+
</div>
|
|
32
|
+
`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
CounterCard.register('counter-card');
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The lit-html directive set webjs re-exports (from '@webjsdev/core/directives').
|
|
2
|
+
// `repeat` keys a list so DOM nodes are REUSED across reorders instead of being
|
|
3
|
+
// recreated (use it for keyed lists that reorder; plain `.map()` is fine for
|
|
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.
|
|
6
|
+
import { WebComponent, signal, html } from '@webjsdev/core';
|
|
7
|
+
import { repeat, watch } from '@webjsdev/core/directives';
|
|
8
|
+
|
|
9
|
+
interface Item { id: number; label: string }
|
|
10
|
+
|
|
11
|
+
let nextId = 4;
|
|
12
|
+
|
|
13
|
+
export class DirectiveDemo extends WebComponent {
|
|
14
|
+
private items = signal<Item[]>([
|
|
15
|
+
{ id: 1, label: 'Alpha' },
|
|
16
|
+
{ id: 2, label: 'Bravo' },
|
|
17
|
+
{ id: 3, label: 'Charlie' },
|
|
18
|
+
]);
|
|
19
|
+
private ticks = signal(0);
|
|
20
|
+
|
|
21
|
+
private reverse() {
|
|
22
|
+
this.items.set(this.items.get().slice().reverse());
|
|
23
|
+
}
|
|
24
|
+
private add() {
|
|
25
|
+
const id = nextId++;
|
|
26
|
+
this.items.set([...this.items.get(), { id, label: 'Item ' + id }]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
render() {
|
|
30
|
+
return html`
|
|
31
|
+
<div class="grid gap-4 max-w-[420px]">
|
|
32
|
+
<div class="flex gap-2">
|
|
33
|
+
<button @click=${() => this.reverse()}
|
|
34
|
+
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>
|
|
35
|
+
<button @click=${() => this.add()}
|
|
36
|
+
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>
|
|
37
|
+
</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
|
+
</div>
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
DirectiveDemo.register('directive-demo');
|