create-yerd 0.0.0-stage → 0.2.2
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/LICENSE +21 -0
- package/README.md +84 -2
- package/bin/create-yerd.js +17 -0
- package/package.json +42 -4
- package/src/args.js +140 -0
- package/src/cli.js +180 -0
- package/src/pm.js +33 -0
- package/src/prompt.js +284 -0
- package/src/render.js +159 -0
- package/src/style.js +33 -0
- package/src/target.js +68 -0
- package/src/versions.js +14 -0
- package/templates/_shared/_env.example +9 -0
- package/templates/_shared/_gitignore +6 -0
- package/templates/_shared/vitest.config.ts +8 -0
- package/templates/api/README.md +39 -0
- package/templates/api/src/app.ts +9 -0
- package/templates/api/src/main.ts +5 -0
- package/templates/api/src/notes.ts +65 -0
- package/templates/api/src/routes/notes.ts +49 -0
- package/templates/api/test/notes.test.ts +68 -0
- package/templates/api/tsconfig.json +16 -0
- package/templates/api/vite.config.ts +7 -0
- package/templates/full/README.md +46 -0
- package/templates/full/e2e/shop.spec.ts +18 -0
- package/templates/full/playwright.config.ts +16 -0
- package/templates/full/public/favicon.svg +1 -0
- package/templates/full/src/app.ts +8 -0
- package/templates/full/src/cart.ts +50 -0
- package/templates/full/src/catalog.ts +59 -0
- package/templates/full/src/components.tsx +31 -0
- package/templates/full/src/layouts.tsx +67 -0
- package/templates/full/src/main.ts +5 -0
- package/templates/full/src/orders.ts +52 -0
- package/templates/full/src/routes/api.ts +34 -0
- package/templates/full/src/routes/auth.tsx +71 -0
- package/templates/full/src/routes/cart.tsx +66 -0
- package/templates/full/src/routes/checkout.tsx +101 -0
- package/templates/full/src/routes/products.tsx +79 -0
- package/templates/full/src/styles.css +72 -0
- package/templates/full/src/users.ts +41 -0
- package/templates/full/test/shop.test.ts +130 -0
- package/templates/full/tsconfig.json +18 -0
- package/templates/full/vite.config.ts +7 -0
- package/templates/minimal/README.md +26 -0
- package/templates/minimal/public/favicon.svg +1 -0
- package/templates/minimal/src/app.ts +7 -0
- package/templates/minimal/src/layouts.tsx +32 -0
- package/templates/minimal/src/main.ts +5 -0
- package/templates/minimal/src/routes/home.tsx +44 -0
- package/templates/minimal/src/styles.css +32 -0
- package/templates/minimal/test/app.test.ts +35 -0
- package/templates/minimal/tsconfig.json +18 -0
- package/templates/minimal/vite.config.ts +7 -0
- package/templates/tailwind/_shared/vite.config.ts +8 -0
- package/templates/tailwind/full/src/styles.css +49 -0
- package/templates/tailwind/minimal/src/layouts.tsx +33 -0
- package/templates/tailwind/minimal/src/routes/home.tsx +48 -0
- package/templates/tailwind/minimal/src/styles.css +2 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ActionForm, ValidationFailed, action, get, inject, layout, post, query, redirect, route, session, type Session } from "@toyz/yerd";
|
|
2
|
+
import { safeNext, verifyPassword } from "@toyz/yerd/auth";
|
|
3
|
+
import { Field } from "../components.js";
|
|
4
|
+
import { Narrow } from "../layouts.js";
|
|
5
|
+
import { DEMO_ACCOUNT, LoginForm, Users, auth, type User } from "../users.js";
|
|
6
|
+
|
|
7
|
+
interface LoginState {
|
|
8
|
+
error?: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@route("/login", { meta: { title: "Sign in" } })
|
|
13
|
+
@layout(Narrow)
|
|
14
|
+
export class LoginPage {
|
|
15
|
+
@inject(Users) accessor users!: Users;
|
|
16
|
+
@auth.maybeUser accessor me: User | undefined;
|
|
17
|
+
@session accessor session!: Session;
|
|
18
|
+
@query("next") accessor next: string | undefined;
|
|
19
|
+
|
|
20
|
+
@get page() {
|
|
21
|
+
// safeNext: same-site paths only, so ?next=https://evil.example can't bounce anyone off-site.
|
|
22
|
+
if (this.me) return redirect(safeNext(this.next));
|
|
23
|
+
const state = (this.session.flash("login") ?? {}) as LoginState;
|
|
24
|
+
return (
|
|
25
|
+
<>
|
|
26
|
+
<h1>Sign in</h1>
|
|
27
|
+
<form action={this.submit}>
|
|
28
|
+
<Field name="name" label="Username" value={state.name} autocomplete="username" />
|
|
29
|
+
<Field name="password" label="Password" type="password" autocomplete="current-password" />
|
|
30
|
+
<p class="form-error" data-yx-error role="alert">{state.error}</p>
|
|
31
|
+
<button>Sign in</button>
|
|
32
|
+
</form>
|
|
33
|
+
<p class="hint">
|
|
34
|
+
Demo account: <code>{DEMO_ACCOUNT.name}</code> / <code>{DEMO_ACCOUNT.password}</code>
|
|
35
|
+
</p>
|
|
36
|
+
</>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@action async submit(_: {}, form: ActionForm) {
|
|
41
|
+
let input: LoginForm;
|
|
42
|
+
try {
|
|
43
|
+
input = form.parse(LoginForm);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (!(error instanceof ValidationFailed)) throw error;
|
|
46
|
+
return this.#fail("Enter your username and password.", form.get("name"));
|
|
47
|
+
}
|
|
48
|
+
const user = this.users.byName(input.name);
|
|
49
|
+
// Spends a full hash even for unknown names, so timing doesn't reveal who has an account.
|
|
50
|
+
if (!(await verifyPassword(input.password, user?.passwordHash)) || !user) {
|
|
51
|
+
return this.#fail("Wrong username or password.", input.name);
|
|
52
|
+
}
|
|
53
|
+
// Rotates the session id and keeps a guest's data: the cart comes along.
|
|
54
|
+
await auth.login(user);
|
|
55
|
+
return redirect(safeNext(this.next));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Back to the form with a message, with or without JavaScript. */
|
|
59
|
+
#fail(error: string, name: string | undefined) {
|
|
60
|
+
this.session.flash("login", { error, name } satisfies LoginState);
|
|
61
|
+
return redirect(`/login${this.next ? `?next=${encodeURIComponent(this.next)}` : ""}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@route("/logout")
|
|
66
|
+
export class Logout {
|
|
67
|
+
@post async leave() {
|
|
68
|
+
await auth.logout();
|
|
69
|
+
return redirect("/");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Region, action, get, inject, route, session, type Session } from "@toyz/yerd";
|
|
2
|
+
import { Cart, priceCart } from "../cart.js";
|
|
3
|
+
import { Catalog } from "../catalog.js";
|
|
4
|
+
import { Price } from "../components.js";
|
|
5
|
+
import { CartBadge } from "../layouts.js";
|
|
6
|
+
|
|
7
|
+
@route("/cart", { meta: { title: "Your cart" } })
|
|
8
|
+
export class CartPage {
|
|
9
|
+
@inject(Catalog) accessor catalog!: Catalog;
|
|
10
|
+
@session(Cart) accessor cart!: Session<Cart>;
|
|
11
|
+
|
|
12
|
+
@get page() {
|
|
13
|
+
return (
|
|
14
|
+
<>
|
|
15
|
+
<h1>Your cart</h1>
|
|
16
|
+
{this.lines()}
|
|
17
|
+
</>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The lines and the total: one Region, so the remove action can send it back. */
|
|
22
|
+
lines() {
|
|
23
|
+
const { lines, total } = priceCart(this.cart.lines, this.catalog);
|
|
24
|
+
if (lines.length === 0) {
|
|
25
|
+
return (
|
|
26
|
+
<Region id="cart">
|
|
27
|
+
<p class="empty">
|
|
28
|
+
Your cart is empty. <a href="/">Go shopping</a>.
|
|
29
|
+
</p>
|
|
30
|
+
</Region>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return (
|
|
34
|
+
<Region id="cart">
|
|
35
|
+
<ul class="lines">
|
|
36
|
+
{lines.map(({ product, qty, subtotal }) => (
|
|
37
|
+
<li key={product.id}>
|
|
38
|
+
<a href={`/products/${product.id}`}>{product.name}</a>
|
|
39
|
+
<span>x {qty}</span>
|
|
40
|
+
<Price cents={subtotal} />
|
|
41
|
+
{/* The id is signed into the action: the browser can't change which line goes. */}
|
|
42
|
+
<form action={() => this.remove({ id: product.id })}>
|
|
43
|
+
<button class="link" aria-label={`Remove ${product.name}`}>Remove</button>
|
|
44
|
+
</form>
|
|
45
|
+
</li>
|
|
46
|
+
))}
|
|
47
|
+
</ul>
|
|
48
|
+
<p class="total">
|
|
49
|
+
Total <Price cents={total} />
|
|
50
|
+
</p>
|
|
51
|
+
<a class="button" href="/checkout">Check out</a>
|
|
52
|
+
</Region>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@action remove({ id }: { id: number }) {
|
|
57
|
+
this.cart.remove(id);
|
|
58
|
+
// Every Region the change touches.
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
{this.lines()}
|
|
62
|
+
<CartBadge count={this.cart.count} />
|
|
63
|
+
</>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ActionForm, NotFound, ValidationFailed, action, currentContext, get, group, inject, param, redirect, route, session, toInt, type Session } from "@toyz/yerd";
|
|
2
|
+
import { Cart, priceCart } from "../cart.js";
|
|
3
|
+
import { Catalog } from "../catalog.js";
|
|
4
|
+
import { Field, Price } from "../components.js";
|
|
5
|
+
import { Narrow } from "../layouts.js";
|
|
6
|
+
import { CheckoutForm, Orders } from "../orders.js";
|
|
7
|
+
import { auth, type User } from "../users.js";
|
|
8
|
+
|
|
9
|
+
/** Everything here needs a signed-in user, in a narrow column. No URL prefix. */
|
|
10
|
+
@group("/", { guards: [auth.required], layout: Narrow })
|
|
11
|
+
export class Account {}
|
|
12
|
+
|
|
13
|
+
/** What a failed plain (no JavaScript) post leaves for the page it goes back to. */
|
|
14
|
+
interface CheckoutState {
|
|
15
|
+
errors?: Record<string, string>;
|
|
16
|
+
draft?: Record<string, string | undefined>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@route("/checkout", { group: Account, meta: { title: "Check out" } })
|
|
20
|
+
export class CheckoutPage {
|
|
21
|
+
@inject(Catalog) accessor catalog!: Catalog;
|
|
22
|
+
@inject(Orders) accessor orders!: Orders;
|
|
23
|
+
@auth.user accessor me!: User;
|
|
24
|
+
@session(Cart) accessor cart!: Session<Cart>;
|
|
25
|
+
@session accessor session!: Session;
|
|
26
|
+
|
|
27
|
+
@get page() {
|
|
28
|
+
const { lines, total } = priceCart(this.cart.lines, this.catalog);
|
|
29
|
+
if (lines.length === 0) return redirect("/cart");
|
|
30
|
+
const { errors = {}, draft = { name: this.me.name } } = (this.session.flash("checkout") ?? {}) as CheckoutState;
|
|
31
|
+
return (
|
|
32
|
+
<>
|
|
33
|
+
<h1>Check out</h1>
|
|
34
|
+
<ul class="summary">
|
|
35
|
+
{lines.map(({ product, qty, subtotal }) => (
|
|
36
|
+
<li>
|
|
37
|
+
{qty} x {product.name} <Price cents={subtotal} />
|
|
38
|
+
</li>
|
|
39
|
+
))}
|
|
40
|
+
</ul>
|
|
41
|
+
<p class="total">
|
|
42
|
+
Total <Price cents={total} />
|
|
43
|
+
</p>
|
|
44
|
+
<form action={this.place}>
|
|
45
|
+
<Field name="name" label="Name" value={draft.name} error={errors.name} autocomplete="name" />
|
|
46
|
+
<Field name="email" label="Email" type="email" value={draft.email} error={errors.email} autocomplete="email" />
|
|
47
|
+
<Field name="address" label="Address" value={draft.address} error={errors.address} autocomplete="street-address" />
|
|
48
|
+
<p class="form-error" data-yx-error role="alert"></p>
|
|
49
|
+
<button>Place order</button>
|
|
50
|
+
</form>
|
|
51
|
+
</>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@action place(_: {}, form: ActionForm) {
|
|
56
|
+
let details: CheckoutForm;
|
|
57
|
+
try {
|
|
58
|
+
details = form.parse(CheckoutForm);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
// From the runtime (it sends a `yx` header): rethrow, and each issue lands in its field's box.
|
|
61
|
+
if (!(error instanceof ValidationFailed) || currentContext().headers.yx !== undefined) throw error;
|
|
62
|
+
// A plain form post: keep the messages and the input for the page we send it back to.
|
|
63
|
+
const errors: Record<string, string> = {};
|
|
64
|
+
for (const { path, message } of error.issues) errors[path] ??= message;
|
|
65
|
+
const draft = { name: form.get("name"), email: form.get("email"), address: form.get("address") };
|
|
66
|
+
this.session.flash("checkout", { errors, draft } satisfies CheckoutState);
|
|
67
|
+
return redirect("/checkout");
|
|
68
|
+
}
|
|
69
|
+
const order = this.orders.place(this.me, details, this.cart.lines);
|
|
70
|
+
this.cart.empty();
|
|
71
|
+
return redirect(`/orders/${order.id}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@route("/orders/:id", { group: Account, meta: { title: "Thank you" } })
|
|
76
|
+
export class OrderPage {
|
|
77
|
+
@inject(Orders) accessor orders!: Orders;
|
|
78
|
+
@auth.user accessor me!: User;
|
|
79
|
+
@param("id", { transform: toInt }) accessor id!: number;
|
|
80
|
+
|
|
81
|
+
@get page() {
|
|
82
|
+
const order = this.orders.get(this.id, this.me);
|
|
83
|
+
if (!order) throw new NotFound("No such order");
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
<h1>Order {order.id} is in</h1>
|
|
87
|
+
<p>A receipt is on its way to {order.email}.</p>
|
|
88
|
+
<ul class="summary">
|
|
89
|
+
{order.lines.map((line) => (
|
|
90
|
+
<li>
|
|
91
|
+
{line.qty} x {line.name} <Price cents={line.price * line.qty} />
|
|
92
|
+
</li>
|
|
93
|
+
))}
|
|
94
|
+
</ul>
|
|
95
|
+
<p class="total">
|
|
96
|
+
Total <Price cents={order.total} />
|
|
97
|
+
</p>
|
|
98
|
+
</>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Conflict, Region, action, get, inject, param, query, route, session, setMeta, toInt, trim, type Session } from "@toyz/yerd";
|
|
2
|
+
import { Cart } from "../cart.js";
|
|
3
|
+
import { Catalog } from "../catalog.js";
|
|
4
|
+
import { Price, ProductCard } from "../components.js";
|
|
5
|
+
import { CartBadge } from "../layouts.js";
|
|
6
|
+
|
|
7
|
+
/** The front page: every product, narrowed by ?q=. */
|
|
8
|
+
@route("/", { meta: { title: "Shop" } })
|
|
9
|
+
export class ProductList {
|
|
10
|
+
@inject(Catalog) accessor catalog!: Catalog;
|
|
11
|
+
@query("q", { transform: trim }) accessor q = "";
|
|
12
|
+
|
|
13
|
+
@get page() {
|
|
14
|
+
const products = this.catalog.search(this.q);
|
|
15
|
+
return (
|
|
16
|
+
<>
|
|
17
|
+
{/* A GET form: the search is a URL you can share and reload. */}
|
|
18
|
+
<form class="search" method="get" role="search">
|
|
19
|
+
<input type="search" name="q" value={this.q} placeholder="Search" aria-label="Search" />
|
|
20
|
+
<button>Search</button>
|
|
21
|
+
</form>
|
|
22
|
+
{products.length === 0 ? (
|
|
23
|
+
<p class="empty">Nothing matches "{this.q}".</p>
|
|
24
|
+
) : (
|
|
25
|
+
<ul class="products">{products.map((p) => <ProductCard product={p} />)}</ul>
|
|
26
|
+
)}
|
|
27
|
+
</>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Stock left, as a Region: the add action sends a fresh one. */
|
|
33
|
+
function Stock({ stock }: { stock: number }) {
|
|
34
|
+
return (
|
|
35
|
+
<Region id="stock" as="p" class="stock">
|
|
36
|
+
{stock === 0 ? "Sold out" : `${stock} in stock`}
|
|
37
|
+
</Region>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One product. An unknown id is the catalog's ProductNotFound: a 404. */
|
|
42
|
+
@route("/products/:id")
|
|
43
|
+
export class ProductPage {
|
|
44
|
+
@inject(Catalog) accessor catalog!: Catalog;
|
|
45
|
+
@param("id", { transform: toInt }) accessor id!: number;
|
|
46
|
+
@session(Cart) accessor cart!: Session<Cart>;
|
|
47
|
+
|
|
48
|
+
@get page() {
|
|
49
|
+
const product = this.catalog.find(this.id);
|
|
50
|
+
setMeta({ title: product.name, description: product.description });
|
|
51
|
+
return (
|
|
52
|
+
<article class="product">
|
|
53
|
+
<h1>{product.name}</h1>
|
|
54
|
+
<p>{product.description}</p>
|
|
55
|
+
<Price cents={product.price} />
|
|
56
|
+
<Stock stock={product.stock} />
|
|
57
|
+
<form action={this.add}>
|
|
58
|
+
<button disabled={product.stock === 0}>Add to cart</button>
|
|
59
|
+
</form>
|
|
60
|
+
</article>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @param works in actions too: this.id is the product on the page. */
|
|
65
|
+
@action add() {
|
|
66
|
+
const product = this.catalog.find(this.id);
|
|
67
|
+
const inCart = this.cart.lines.find((line) => line.id === product.id)?.qty ?? 0;
|
|
68
|
+
// A 409: the runtime shows its message. Checkout checks the stock again anyway.
|
|
69
|
+
if (inCart >= product.stock) throw new Conflict(product.stock === 0 ? "Sold out." : `Only ${product.stock} in stock.`);
|
|
70
|
+
this.cart.add(product.id);
|
|
71
|
+
// With the runtime, these morph in; without it, the browser is sent back to the page.
|
|
72
|
+
return (
|
|
73
|
+
<>
|
|
74
|
+
<CartBadge count={this.cart.count} />
|
|
75
|
+
<Stock stock={product.stock} />
|
|
76
|
+
</>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/* Linked with <Stylesheet href="src/styles.css" />; hashed by `vite build`. */
|
|
2
|
+
:root {
|
|
3
|
+
--bg: #faf8f4;
|
|
4
|
+
--surface: #ffffff;
|
|
5
|
+
--text: #1f1d1a;
|
|
6
|
+
--muted: #6d675e;
|
|
7
|
+
--line: #e4dfd6;
|
|
8
|
+
--accent: #2f5d8a;
|
|
9
|
+
--accent-ink: #ffffff;
|
|
10
|
+
--danger: #b3261e;
|
|
11
|
+
--radius: 10px;
|
|
12
|
+
color-scheme: light;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@media (prefers-color-scheme: dark) {
|
|
16
|
+
:root {
|
|
17
|
+
--bg: #161513;
|
|
18
|
+
--surface: #201e1b;
|
|
19
|
+
--text: #f0ece5;
|
|
20
|
+
--muted: #a29b90;
|
|
21
|
+
--line: #35322d;
|
|
22
|
+
--accent: #8ab6e6;
|
|
23
|
+
--accent-ink: #10161d;
|
|
24
|
+
--danger: #ff8a80;
|
|
25
|
+
color-scheme: dark;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
30
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.5 ui-sans-serif, system-ui, sans-serif; }
|
|
31
|
+
a { color: var(--accent); }
|
|
32
|
+
button, input { font: inherit; color: inherit; }
|
|
33
|
+
button, .button {
|
|
34
|
+
display: inline-block; padding: 6px 14px; border: 0; border-radius: var(--radius);
|
|
35
|
+
background: var(--accent); color: var(--accent-ink); text-decoration: none; cursor: pointer;
|
|
36
|
+
}
|
|
37
|
+
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
38
|
+
button.link { background: none; color: var(--accent); padding: 0; text-decoration: underline; }
|
|
39
|
+
input { padding: 6px 10px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
|
40
|
+
|
|
41
|
+
.top { display: flex; align-items: center; gap: 16px; max-width: 760px; margin: 0 auto; padding: 20px 16px; }
|
|
42
|
+
.brand { font-weight: 700; font-size: 1.2rem; color: var(--text); text-decoration: none; margin-right: auto; }
|
|
43
|
+
.cart-badge { text-decoration: none; }
|
|
44
|
+
.badge { display: inline-block; min-width: 1.6em; padding: 0 6px; border-radius: 99px; background: var(--accent); color: var(--accent-ink); text-align: center; }
|
|
45
|
+
.account { display: flex; gap: 8px; align-items: center; margin: 0; }
|
|
46
|
+
main { max-width: 760px; margin: 0 auto; padding: 0 16px 48px; }
|
|
47
|
+
.narrow { max-width: 420px; }
|
|
48
|
+
|
|
49
|
+
.search { display: flex; gap: 8px; margin-bottom: 16px; }
|
|
50
|
+
.search input { flex: 1; }
|
|
51
|
+
.products { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
|
52
|
+
.card { display: grid; align-content: start; gap: 4px; padding: 14px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
|
53
|
+
.price { font-variant-numeric: tabular-nums; }
|
|
54
|
+
.sold-out, .empty, .hint { color: var(--muted); }
|
|
55
|
+
|
|
56
|
+
.product { display: grid; gap: 8px; justify-items: start; }
|
|
57
|
+
.product h1 { margin: 0; }
|
|
58
|
+
.stock { margin: 0; color: var(--muted); }
|
|
59
|
+
|
|
60
|
+
.lines { list-style: none; padding: 0; display: grid; gap: 8px; }
|
|
61
|
+
.lines li { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); }
|
|
62
|
+
.lines li > a { flex: 1; }
|
|
63
|
+
.lines form { margin: 0; }
|
|
64
|
+
.total { font-weight: 700; }
|
|
65
|
+
.summary { padding-left: 1.2em; }
|
|
66
|
+
|
|
67
|
+
.field { display: grid; gap: 4px; margin: 0 0 12px; }
|
|
68
|
+
.field-error, .form-error { color: var(--danger); font-size: 0.9rem; }
|
|
69
|
+
.field-error:empty, .form-error:empty { display: none; }
|
|
70
|
+
[aria-invalid="true"] { border-color: var(--danger); }
|
|
71
|
+
|
|
72
|
+
.error-page .status { font-size: 3rem; font-weight: 700; margin: 0; color: var(--muted); }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { is, lower, onBoot, resolve, service, trim } from "@toyz/yerd";
|
|
2
|
+
import { createAuth, hashPassword } from "@toyz/yerd/auth";
|
|
3
|
+
|
|
4
|
+
export interface User {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
passwordHash: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Listed on the sign-in page. */
|
|
11
|
+
export const DEMO_ACCOUNT = { name: "ada", password: "analytical-engine" };
|
|
12
|
+
|
|
13
|
+
export class LoginForm {
|
|
14
|
+
@is.string({ transform: [trim, lower] }) name!: string;
|
|
15
|
+
@is.string() password!: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Accounts, in memory. Swap in a database when you have one. */
|
|
19
|
+
@service
|
|
20
|
+
export class Users {
|
|
21
|
+
#users: User[] = [];
|
|
22
|
+
|
|
23
|
+
/** Hashing is async, so the demo account is made at boot, before the first request. */
|
|
24
|
+
@onBoot async seed() {
|
|
25
|
+
this.#users.push({ id: "u1", name: DEMO_ACCOUNT.name, passwordHash: await hashPassword(DEMO_ACCOUNT.password) });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get(id: string): User | undefined {
|
|
29
|
+
return this.#users.find((u) => u.id === id);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
byName(name: string): User | undefined {
|
|
33
|
+
return this.#users.find((u) => u.name === name);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Session sign-in: `app.use(auth)`, `guards: [auth.required]`, `@auth.user`. */
|
|
38
|
+
export const auth = createAuth<User>({
|
|
39
|
+
load: (id) => resolve(Users).get(id),
|
|
40
|
+
loginPath: "/login",
|
|
41
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { parse, ValidationFailed } from "@toyz/yerd";
|
|
3
|
+
import { createApp } from "../src/app.js";
|
|
4
|
+
import { CheckoutForm } from "../src/orders.js";
|
|
5
|
+
import { DEMO_ACCOUNT } from "../src/users.js";
|
|
6
|
+
import "../src/routes/products.js";
|
|
7
|
+
import "../src/routes/cart.js";
|
|
8
|
+
import "../src/routes/checkout.js";
|
|
9
|
+
import "../src/routes/auth.js";
|
|
10
|
+
import "../src/routes/api.js";
|
|
11
|
+
|
|
12
|
+
// The app main.ts serves, with the routes this file imports. No port is opened.
|
|
13
|
+
const app = createApp({ handleSignals: false });
|
|
14
|
+
beforeAll(() => app.boot());
|
|
15
|
+
afterAll(() => app.stop());
|
|
16
|
+
|
|
17
|
+
const page = { accept: "text/html" };
|
|
18
|
+
const address = { name: "Ada Lovelace", email: "ada@example.com", address: "12 St James's Square, London" };
|
|
19
|
+
|
|
20
|
+
/** A client with a Pocket notebook (id 2) in the cart, signed in as the demo account. */
|
|
21
|
+
async function shopper() {
|
|
22
|
+
const client = app.client();
|
|
23
|
+
await client.action("/products/2", {});
|
|
24
|
+
await client.action("/login?next=/checkout", DEMO_ACCOUNT);
|
|
25
|
+
return client;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("the catalog", () => {
|
|
29
|
+
it("lists every product, and searches", async () => {
|
|
30
|
+
const res = await app.request("/", { headers: page });
|
|
31
|
+
expect(res.status).toBe(200);
|
|
32
|
+
expect((await res.text()).match(/class="card"/g)).toHaveLength(5);
|
|
33
|
+
|
|
34
|
+
const html = await (await app.request("/?q=notebook", { headers: page })).text();
|
|
35
|
+
expect(html).toContain("Pocket notebook");
|
|
36
|
+
expect(html).not.toContain("Fountain pen");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("is a 404 for an unknown product: the error page for browsers, JSON for the rest", async () => {
|
|
40
|
+
const html = await app.request("/products/99", { headers: page });
|
|
41
|
+
expect(html.status).toBe(404);
|
|
42
|
+
expect(await html.text()).toContain("Not on the shelves");
|
|
43
|
+
|
|
44
|
+
const json = await app.request("/products/99");
|
|
45
|
+
expect(json.status).toBe(404);
|
|
46
|
+
expect(await json.json()).toEqual({ error: "No product 99", code: "not_found", id: 99 });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("the cart", () => {
|
|
51
|
+
it("adds to the cart and sends back the badge", async () => {
|
|
52
|
+
const client = app.client();
|
|
53
|
+
const res = await client.action("/products/2", {});
|
|
54
|
+
expect(res.status).toBe(200);
|
|
55
|
+
expect(await res.text()).toContain('<span class="badge">1</span>');
|
|
56
|
+
expect(await client.page("/cart")).toContain("Pocket notebook");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("refuses a sold-out product", async () => {
|
|
60
|
+
const res = await app.client().action("/products/5", {});
|
|
61
|
+
expect(res.status).toBe(409);
|
|
62
|
+
expect(await res.json()).toEqual({ error: "Sold out.", code: "conflict" });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("removes a line", async () => {
|
|
66
|
+
const client = app.client();
|
|
67
|
+
await client.action("/products/2", {});
|
|
68
|
+
const res = await client.action("/cart", {});
|
|
69
|
+
const html = await res.text();
|
|
70
|
+
expect(html).toContain("Your cart is empty.");
|
|
71
|
+
expect(html).toContain('<span class="badge">0</span>');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("sign in and checkout", () => {
|
|
76
|
+
it("sends guests to sign in first", async () => {
|
|
77
|
+
const res = await app.request("/checkout", { headers: page });
|
|
78
|
+
expect(res.status).toBe(303);
|
|
79
|
+
expect(res.headers.get("location")).toBe("/login?next=%2Fcheckout");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("refuses a wrong password", async () => {
|
|
83
|
+
const client = app.client();
|
|
84
|
+
await client.action("/login", { name: "ada", password: "nope" });
|
|
85
|
+
expect(await client.page("/login")).toContain("Wrong username or password.");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("reports every invalid field at once", async () => {
|
|
89
|
+
const client = await shopper();
|
|
90
|
+
const res = await client.action("/checkout", { name: "A", email: "nope", address: "" });
|
|
91
|
+
expect(res.status).toBe(400);
|
|
92
|
+
expect(await res.json()).toMatchObject({
|
|
93
|
+
issues: [{ path: "name" }, { path: "email", message: "Expected an email address" }, { path: "address" }],
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("places the order and empties the cart", async () => {
|
|
98
|
+
const client = await shopper();
|
|
99
|
+
const res = await client.action("/checkout", address);
|
|
100
|
+
const location = res.headers.get("yx-location");
|
|
101
|
+
expect(location).toMatch(/^\/orders\/\d+$/);
|
|
102
|
+
|
|
103
|
+
const receipt = await client.page(location!);
|
|
104
|
+
expect(receipt).toContain("A receipt is on its way to ada@example.com.");
|
|
105
|
+
expect(receipt).toContain('<span class="badge">0</span>');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("validates the form without a request", () => {
|
|
109
|
+
expect(() => parse(CheckoutForm, { name: "A", email: "nope", address: "" })).toThrow(ValidationFailed);
|
|
110
|
+
expect(parse(CheckoutForm, { ...address, name: " Ada " }).name).toBe("Ada");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("the JSON API", () => {
|
|
115
|
+
it("lists and shows products, with no session cookie", async () => {
|
|
116
|
+
const res = await app.request("/api/products?q=lamp");
|
|
117
|
+
expect(res.headers.get("set-cookie")).toBeNull();
|
|
118
|
+
expect(await res.json()).toMatchObject([{ id: 3, name: "Desk lamp" }]);
|
|
119
|
+
|
|
120
|
+
const missing = await app.request("/api/products/99");
|
|
121
|
+
expect(missing.status).toBe(404);
|
|
122
|
+
expect(await missing.json()).toMatchObject({ code: "not_found", id: 99 });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("answers health checks with 204", async () => {
|
|
126
|
+
const res = await app.request("/api/health");
|
|
127
|
+
expect(res.status).toBe(204);
|
|
128
|
+
expect(await res.text()).toBe("");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "nodenext",
|
|
5
|
+
"moduleResolution": "nodenext",
|
|
6
|
+
"lib": ["ES2023", "esnext.decorators", "DOM"],
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"noEmit": true,
|
|
14
|
+
"jsx": "react-jsx",
|
|
15
|
+
"jsxImportSource": "@toyz/yerd"
|
|
16
|
+
},
|
|
17
|
+
"include": ["src", "test", "e2e", "*.config.ts"]
|
|
18
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import yerd from "@toyz/yerd/vite";
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
// `client`: files `vite build` hashes; <Stylesheet> links them through the manifest.
|
|
6
|
+
plugins: [yerd({ entry: "/src/main.ts", client: ["src/styles.css"] })],
|
|
7
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
A [yerd](https://toyz.github.io/yerd/) app: one page, a layout and an action.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
{{run_dev}} # dev server with reloads, on http://localhost:5173
|
|
7
|
+
{{run_test}} # vitest, in-process: no port, no browser
|
|
8
|
+
{{run_check}} # type-check (Vite doesn't)
|
|
9
|
+
{{run_build}} # production build: dist/client and dist/server/server.js
|
|
10
|
+
{{run_start}} # node dist/server/server.js, on PORT or 3000
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Copy `.env.example` to `.env` and set `YERD_SECRET` before you deploy: it
|
|
14
|
+
signs forms and seals the session cookie.
|
|
15
|
+
|
|
16
|
+
| File | What |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| `src/main.ts` | the Vite entry: the app with every route under `src/routes/` |
|
|
19
|
+
| `src/app.ts` | `createApp()`: layout, error page, sessions; shared with the tests |
|
|
20
|
+
| `src/layouts.tsx` | `RootLayout`, the document; `ErrorPage` for 404s and crashes |
|
|
21
|
+
| `src/routes/home.tsx` | `GET /` and its `click` action |
|
|
22
|
+
| `src/styles.css` | linked with `<Stylesheet>`, hashed in the build |
|
|
23
|
+
| `test/app.test.ts` | `app.request()` and `app.client()` tests |
|
|
24
|
+
|
|
25
|
+
Next: [your first app](https://toyz.github.io/yerd/#/guide/your-first-app)
|
|
26
|
+
walks through routes, pages, forms and sessions.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#c2410c"/><path d="M9 9l7 9 7-9M16 18v6" fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { yerd, type YerdOptions } from "@toyz/yerd";
|
|
2
|
+
import { ErrorPage, RootLayout } from "./layouts.js";
|
|
3
|
+
|
|
4
|
+
/** The app without routes: main.ts discovers them, tests import the ones they need. */
|
|
5
|
+
export function createApp(options?: YerdOptions) {
|
|
6
|
+
return yerd(options).layout(RootLayout).errorPage(ErrorPage).sessions();
|
|
7
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Scripts, Stylesheet, type ErrorPageProps, type LayoutProps } from "@toyz/yerd";
|
|
2
|
+
|
|
3
|
+
/** The document around every HTML response, error pages included. */
|
|
4
|
+
export function RootLayout({ children, meta }: LayoutProps) {
|
|
5
|
+
return (
|
|
6
|
+
<html lang="en">
|
|
7
|
+
<head>
|
|
8
|
+
<meta charset="utf-8" />
|
|
9
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
10
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
11
|
+
<title>{meta.title ? `${meta.title} · {{name}}` : "{{name}}"}</title>
|
|
12
|
+
<Stylesheet href="src/styles.css" />
|
|
13
|
+
<Scripts />
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<main>{children}</main>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** app.errorPage(): 404s and crashes render here, inside RootLayout. */
|
|
23
|
+
export function ErrorPage({ status, message }: ErrorPageProps) {
|
|
24
|
+
return (
|
|
25
|
+
<section class="error">
|
|
26
|
+
<p class="status">{status}</p>
|
|
27
|
+
<h1>{status === 404 ? "Page not found" : "Something went wrong"}</h1>
|
|
28
|
+
<p>{message}</p>
|
|
29
|
+
<a href="/">Home</a>
|
|
30
|
+
</section>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
import { createApp } from "./app.js";
|
|
3
|
+
|
|
4
|
+
// The Vite entry. The plugin serves it in dev and the build's server.js listens: no listen() here.
|
|
5
|
+
export default createApp().discover(import.meta.glob("./routes/**/*.{ts,tsx}", { eager: true }));
|