From here on: a local serverここから先: ローカルサーバー
Part 3 needs Node.js. Until now you could open index.html by double-clicking it; an app with pages needs real paths, and a file has none (its path is /C:/…/index.html), so the router takes it for a page it doesn't have — silently. In the folder of your index.html, run:
Part 3にはNode.jsが必要です。これまではindex.htmlをダブルクリックで開けましたが、ページのあるアプリには本物のパスが要ります。ファイルにはそれが無く(パスは/C:/…/index.html)、ルーターはそれを知らないページとして扱います。しかも何も言いません。index.htmlのあるフォルダで、次を実行します。
$ npx serve -s .
and open the address it prints (http://localhost:3000). -s makes it a server for a single-page app: a path it has no file for — /products/2 — gets index.html, so reloading a product page brings the page back and the router shows the product again.
表示されたアドレス(http://localhost:3000)を開きます。-sは単一ページアプリ向けのサーバーにする指定で、ファイルの無いパス(/products/2)にもindex.htmlを返します。だから商品のページを再読み込みしても、ページが戻り、ルーターがまたその商品を表示します。
The resultできあがり
Click a product's name: its page opens, and the URL shows …/products/3. Add it to the cart there, press the browser's Back, and you are on the catalog again — the cart never left. This demo lives in a folder of this site, which has no single-page fallback: a product page opened in a new tab and reloaded shows the site's 404. Step 16 publishes the shop with one.
商品の名前をクリックすると、その商品のページが開き、URLは…/products/3になります。そこでカートに入れてブラウザの「戻る」を押すと、商品一覧に戻ります。カートはずっとそのままです。このデモはこのサイトのフォルダの中にあり、このサイトには単一ページアプリ向けのfallbackがありません。新しいタブで開いた商品ページを再読み込みすると、サイトの404になります。fallback付きで公開する方法はステップ16で扱います。
Change the codeコードを変える
Start from your page of step 11. In <head>, say where the app starts, and load the router:
ステップ11のページから始めます。<head>で、アプリがどこから始まるかを示し、ルーターを読み込みます。
<title>Little Roastery</title>
<base href="/">
<!-- … -->
<script type="module" src="https://esm.run/@wcstack/storage@4/auto"></script>
<script type="module" src="https://esm.run/@wcstack/router@4/auto"></script>
In the state, add routeParams after cart, and two getters before "products.*.matches":
stateでは、cartの後にrouteParamsを、"products.*.matches"の前にgetterを2つ加えます。
cart: [],
routeParams: {},
// …
get "products.*.href"() {
return "products/" + this["products.*.id"];
},
get product() {
const id = this["routeParams.id"];
return this.products.find(product => product.id === id) ?? null;
},
A product page needs its own “Add to cart”, so addToCart hands the work to a method both can call:
商品のページにも「Add to cart」が要るので、addToCartは両方から呼べるメソッドに仕事を渡します。
addToCart(event, index) {
this.addProduct(this.products[index].id);
},
addCurrent() {
this.addProduct(this.product.id);
},
addProduct(id) {
const line = this.cart.findIndex(item => item.id === id);
if (line >= 0) {
this[`cart.${line}.quantity`] += 1;
} else {
this.cart = this.cart.concat({ id, quantity: 1 });
}
},
In <main>, where the catalog section is, put the router and, right after it, the outlet it fills. Move your whole <section class="catalog"> into the first route, and turn each card's name into a link:
<main>の中、商品一覧のセクションがある場所に、ルーターと、そのすぐ後に、ルーターが中身を入れるoutletを置きます。<section class="catalog">を丸ごと最初のルートの中へ移し、カードの名前をリンクにします。
<main class="shop" data-wcs="hidden: catalogReady|not">
<wcs-router data-wcs="typedParams: routeParams">
<template>
<wcs-route path="/">
<section class="catalog">
<!-- … -->
<h3><a data-wcs="attr.href: .href">{{ .name }}</a></h3>
<!-- … -->
</section>
</wcs-route>
<wcs-route path="/products/:id(int)">
<article class="product-page">
<p class="back"><a href="./">← All products</a></p>
<template data-wcs="if: product">
<div class="card-icon">{{ product.icon }}</div>
<h2>{{ product.name }}</h2>
<p>{{ product.description }}</p>
<p class="price">${{ product.price|toFixed(2) }}</p>
<p class="badge" data-wcs="hidden: product.stock|ne(0)">Sold out</p>
<button type="button" data-wcs="onclick: addCurrent; disabled: product.stock|eq(0)">Add to cart</button>
</template>
<template data-wcs="else:">
<p class="no-results">No such product.</p>
</template>
</article>
</wcs-route>
<wcs-route fallback>
<p class="no-results">Page not found. <a href="./">Back to the shop</a></p>
</wcs-route>
</template>
</wcs-router>
<wcs-outlet></wcs-outlet>
<aside class="cart">
The router fills the element right after it, so keep <wcs-outlet> there. shop.css has wcs-router { display: contents; }: the router holds only its routes and takes no place in the grid.
ルーターは自分のすぐ後の要素に中身を入れるので、<wcs-outlet>はそこに置きます。shop.cssにはwcs-router { display: contents; }があり、ルーターはルートを持つだけで、グリッドの場所を取りません。
How it worksしくみ
Routes are templatesルートはテンプレート
<wcs-router> holds a <template> of <wcs-route> elements. When the URL changes, it finds the matching route and puts that route's content into <wcs-outlet>; the state binds it like everything else, so the catalog's for: and the product page's if: work as before. Leaving a route takes its content out; coming back puts the same nodes back. The cart is outside the router, so it stays on every page.
<wcs-router>は、<wcs-route>要素を並べた<template>を持ちます。URLが変わると、合うルートを見つけ、その中身を<wcs-outlet>に入れます。stateはそれをほかの部分と同じようにバインドするので、商品一覧のfor:も商品ページのif:もこれまでどおり働きます。ルートを離れると中身は取り出され、戻ると同じノードが戻ってきます。カートはルーターの外にあるので、どのページにも残ります。
The URL is inputURLは入力
typedParams is an output of the router, like value of <wcs-fetch>: typedParams: routeParams carries the URL's parameters into state. :id(int) converts the id to a number, so product.id === id compares numbers; a URL whose id is not an integer (/products/abc) matches no route and lands on the fallback. The product getter turns the id into a product, and the page reads product.name — or shows “No such product.” for an id the catalog doesn't have.
typedParamsは、<wcs-fetch>のvalueと同じく、ルーターの出力です。typedParams: routeParamsが、URLのパラメータをstateへ運びます。:id(int)はidを数値に変えるので、product.id === idは数値どうしの比較になります。idが整数でないURL(/products/abc)はどのルートにも合わず、fallbackに落ちます。productのgetterがidを商品に変え、ページはproduct.nameを読みます。商品一覧に無いidなら「No such product.」を出します。
Links are linksリンクはリンク
A card's name is an ordinary <a href="products/4">, relative to <base href="/">. When it is clicked, the browser asks the page before leaving (the Navigation API), and the router answers by switching the route in place — no reload, and an entry in the history, so Back and Forward step through the pages. <base href> also tells the router where the app starts: its paths (/products/:id) are counted from there.
カードの名前は、<base href="/">からの相対の、普通の<a href="products/4">です。クリックされると、ブラウザはページを離れる前にページに尋ね(Navigation API)、ルーターはその場でルートを切り替えて応えます。再読み込みはせず、履歴には記録が残るので、「戻る」と「進む」でページをたどれます。<base href>は、アプリがどこから始まるかもルーターに伝えます。ルートのパス(/products/:id)はそこから数えます。
Link the cart to the product pages too. Add get "cart.*.href"() { return "products/" + this["cart.*.id"]; } to the state, and in the cart row wrap the name: <span class="line-name"><a data-wcs="attr.href: .href">{{ .name }}</a></span>.
カートからも商品ページへリンクしてみましょう。stateにget "cart.*.href"() { return "products/" + this["cart.*.id"]; }を加え、カートの行の名前を包みます: <span class="line-name"><a data-wcs="attr.href: .href">{{ .name }}</a></span>。
Common mistakesよくある間違い
Opening the fileファイルを開く
Double-click index.html now and the header and the cart appear, but where the catalog should be it says “Page not found.”: the router takes /C:/…/index.html for a page it doesn't have, and nothing in the console says why. Serve the folder.
ここでindex.htmlをダブルクリックすると、ヘッダーとカートは出ますが、商品一覧の場所には「Page not found.」と出ます。ルーターは/C:/…/index.htmlを知らないページとして扱い、コンソールにも理由は出ません。フォルダをサーバーで開きます。
Forgetting <base href><base href>を忘れる
Without it the router takes the address the page was loaded at as the start of the app. Loaded at /, all is well; loaded at /products/2 — a reload — it counts its paths from there, and the product page comes back as the catalog. The checker warns (@wcstack/lint 4.0.3 or later):
無いと、ルーターはページを読み込んだアドレスをアプリの始まりとみなします。/で読み込めば問題ありませんが、/products/2で読み込む(再読み込みする)と、そこからパスを数えるので、商品のページが商品一覧として戻ってきます。検査器は警告します(@wcstack/lint 4.0.3以降)。
index.html:11:30 warning wcs/base-href-missing An SPA using @wcstack/router needs <base href="/"> in <head> (without it, deep links misderive the basename)
0 error(s), 1 warning(s), 0 info
The outlet away from the routerルーターから離れたoutlet
The router looks for its outlet in one place: the element right after it. Put the router at the top of <body> and the outlet inside <main>, and the router makes an outlet of its own after itself: the pages appear above the header, and the outlet in <main> stays empty. Since 4.0.4 the router warns in the console ([wcs/router-outlet-misplaced]), and so does the checker.
ルーターがoutletを探すのは1か所、自分のすぐ後の要素だけです。ルーターを<body>の先頭に、outletを<main>の中に置くと、ルーターは自分の後ろに自前のoutletを作ります。ページはヘッダーの上に現れ、<main>の中のoutletは空のままです。4.0.4からは、ルーターがコンソールで警告し([wcs/router-outlet-misplaced])、検査器も報告します。
A server without the fallbackfallbackの無いサーバー
With npx serve . (no -s), everything works until you reload a product page: the server has no file products/2 and answers 404. Clicking through the shop never asks the server, which is why the mistake hides until a reload.
npx serve .(-sなし)でも、商品ページを再読み込みするまではすべて動きます。サーバーにはproducts/2というファイルが無いので、404を返します。ショップの中をクリックしてたどる間はサーバーに尋ねないので、この間違いは再読み込みまで隠れています。
The whole fileファイル全体
The complete index.html of this step, as you serve it from your own folder. npx @wcstack/lint index.html reports 0 error(s), 0 warning(s), 0 info for it. (The demo above differs in one line: its <base href> points at its folder on this site.)
このステップのindex.html全体で、自分のフォルダから配信する形です。npx @wcstack/lint index.htmlの結果は0 error(s), 0 warning(s), 0 infoです。(上のデモは1行だけ違い、<base href>がこのサイトの中のフォルダを指しています。)
Show index.htmlindex.htmlを表示
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Little Roastery</title>
<base href="/">
<link rel="stylesheet" href="https://wcstack.github.io/tutorial/shop.css">
<script type="module" src="https://esm.run/@wcstack/state@4/auto"></script>
<script type="module" src="https://esm.run/@wcstack/fetch@4/auto"></script>
<script type="module" src="https://esm.run/@wcstack/storage@4/auto"></script>
<script type="module" src="https://esm.run/@wcstack/router@4/auto"></script>
</head>
<body>
<wcs-state>
<script type="module">
/** @typedef {{ id: number, name: string, category: string, description: string, price: number, stock: number, icon: string }} Product */
export default {
/** @type {Product[] | null} */
catalog: null,
loading: false,
loadError: null,
reload: false,
cart: [],
routeParams: {},
query: "",
category: "all",
get products() {
return this.catalog ?? [];
},
get catalogReady() {
return this.catalog !== null;
},
get "products.*.href"() {
return "products/" + this["products.*.id"];
},
get product() {
const id = this["routeParams.id"];
return this.products.find(product => product.id === id) ?? null;
},
get "products.*.matches"() {
const query = this.query.trim().toLowerCase();
return (this.category === "all" || this["products.*.category"] === this.category) &&
this["products.*.name"].toLowerCase().includes(query);
},
get matchCount() {
return this.$getAll("products.*.matches", []).filter(Boolean).length;
},
get "cart.*.product"() {
const id = this["cart.*.id"];
return this.products.find(product => product.id === id);
},
get "cart.*.name"() {
return this["cart.*.product"]?.name ?? "…";
},
get "cart.*.price"() {
return this["cart.*.product"]?.price ?? 0;
},
get "cart.*.lineTotal"() {
return this["cart.*.price"] * this["cart.*.quantity"];
},
get itemCount() {
return this.$getAll("cart.*.quantity", []).reduce((sum, n) => sum + n, 0);
},
get subtotal() {
return this.$getAll("cart.*.lineTotal", []).reduce((sum, n) => sum + n, 0);
},
get shipping() {
return this.subtotal >= 50 ? 0 : 5;
},
get total() {
return this.subtotal + this.shipping;
},
get "cart.*.saved"() {
return { id: this["cart.*.id"], quantity: this["cart.*.quantity"] };
},
get savedCart() {
return this.$getAll("cart.*.saved", []);
},
set savedCart(lines) {
if (Array.isArray(lines)) this.cart = lines;
},
retry() {
this.reload = true;
},
addToCart(event, index) {
this.addProduct(this.products[index].id);
},
addCurrent() {
this.addProduct(this.product.id);
},
addProduct(id) {
const line = this.cart.findIndex(item => item.id === id);
if (line >= 0) {
this[`cart.${line}.quantity`] += 1;
} else {
this.cart = this.cart.concat({ id, quantity: 1 });
}
},
increase(event, index) {
this[`cart.${index}.quantity`] += 1;
},
decrease(event, index) {
this[`cart.${index}.quantity`] -= 1;
},
remove(event, index) {
this.cart = this.cart.toSpliced(index, 1);
}
};
</script>
</wcs-state>
<wcs-fetch url="https://wcstack.github.io/tutorial/data/products.json"
data-wcs="value: catalog; loading: loading; error: loadError; trigger: reload"></wcs-fetch>
<wcs-storage key="little-roastery-cart"
data-wcs="value#init=element: savedCart"></wcs-storage>
<header class="shop-header">
<h1>Little Roastery</h1>
<p class="cart-badge">Cart: <span data-wcs="textContent: itemCount"></span></p>
</header>
<p class="status" data-wcs="hidden: loading|not">Loading products…</p>
<p class="status error" data-wcs="hidden: loadError|not">
Couldn't load the products.
<button type="button" data-wcs="onclick: retry">Try again</button>
</p>
<main class="shop" data-wcs="hidden: catalogReady|not">
<wcs-router data-wcs="typedParams: routeParams">
<template>
<wcs-route path="/">
<section class="catalog">
<h2>Products</h2>
<div class="toolbar">
<input type="search" placeholder="Search products" aria-label="Search products" data-wcs="value: query">
<fieldset class="categories">
<legend class="visually-hidden">Category</legend>
<label><input type="radio" name="category" value="all" data-wcs="radio: category"> All</label>
<label><input type="radio" name="category" value="coffee" data-wcs="radio: category"> Coffee</label>
<label><input type="radio" name="category" value="tea" data-wcs="radio: category"> Tea</label>
<label><input type="radio" name="category" value="tools" data-wcs="radio: category"> Tools</label>
</fieldset>
</div>
<div class="grid">
<template data-wcs="for: products">
<article class="card" data-wcs="hidden: .matches|not; class.sold-out: .stock|eq(0)">
<div class="card-icon">{{ .icon }}</div>
<h3><a data-wcs="attr.href: .href">{{ .name }}</a></h3>
<p>{{ .description }}</p>
<p class="price">${{ .price|toFixed(2) }}</p>
<p class="badge" data-wcs="hidden: .stock|ne(0)">Sold out</p>
<button type="button" data-wcs="onclick: addToCart; disabled: .stock|eq(0)">Add to cart</button>
</article>
</template>
</div>
<p class="no-results" data-wcs="hidden: matchCount|ne(0)">No products match.</p>
</section>
</wcs-route>
<wcs-route path="/products/:id(int)">
<article class="product-page">
<p class="back"><a href="./">← All products</a></p>
<template data-wcs="if: product">
<div class="card-icon">{{ product.icon }}</div>
<h2>{{ product.name }}</h2>
<p>{{ product.description }}</p>
<p class="price">${{ product.price|toFixed(2) }}</p>
<p class="badge" data-wcs="hidden: product.stock|ne(0)">Sold out</p>
<button type="button" data-wcs="onclick: addCurrent; disabled: product.stock|eq(0)">Add to cart</button>
</template>
<template data-wcs="else:">
<p class="no-results">No such product.</p>
</template>
</article>
</wcs-route>
<wcs-route fallback>
<p class="no-results">Page not found. <a href="./">Back to the shop</a></p>
</wcs-route>
</template>
</wcs-router>
<wcs-outlet></wcs-outlet>
<aside class="cart">
<h2>Cart</h2>
<template data-wcs="if: cart.length|eq(0)">
<p class="empty">Your cart is empty.</p>
</template>
<template data-wcs="else:">
<ul class="cart-lines">
<template data-wcs="for: cart">
<li>
<span class="line-name">{{ .name }}</span>
<span class="line-total">${{ .lineTotal|toFixed(2) }}</span>
<span class="stepper">
<button type="button" aria-label="Decrease" data-wcs="onclick: decrease; disabled: .quantity|le(1)">−</button>
<span>{{ .quantity }}</span>
<button type="button" aria-label="Increase" data-wcs="onclick: increase">+</button>
</span>
<button type="button" class="remove" data-wcs="onclick: remove">Remove</button>
</li>
</template>
</ul>
<dl class="totals">
<dt>Subtotal</dt><dd>${{ subtotal|toFixed(2) }}</dd>
<dt>Shipping</dt><dd>${{ shipping|toFixed(2) }}</dd>
<dt class="grand">Total</dt><dd class="grand">${{ total|toFixed(2) }}</dd>
</dl>
<p class="hint" data-wcs="hidden: shipping|eq(0)">Free shipping on orders of $50 or more.</p>
</template>
</aside>
</main>
</body>
</html>