The resultできあがり
The same shop, now loaded from one pinned file that the browser has verified. It works as in step 14.
同じショップを、ブラウザが検証した、固定した1つのファイルから読み込んでいます。動きはステップ14と同じです。
Pin one file1つのファイルに固定する
In <head>, replace the four esm.run scripts with one:
<head>で、esm.runの4つのscriptを1つに置き換えます。
<base href="/">
<link rel="stylesheet" href="https://wcstack.github.io/tutorial/shop.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/wcstack@4.0.4/dist/auto.min.js"
integrity="sha384-EdACDTGJnYdmrieiNaWtKkVkcxL2L4QAC8fWCfA315Vsy1/eYUWDvshJMJ1LFleB"></script>
<script type="module" src="product-card.js"></script>
wcstack/auto is one file with state, the router, fetch, storage and the autoloader — everything the shop uses, about 70 KB compressed. The integrity value is the file's SHA-384 hash, from sri.json on the v4.0.4 release page, where every release lists the hash of each package's file.
wcstack/autoは、state・ルーター・fetch・storage・autoloaderを1つにしたファイルで、ショップが使うものがすべて入っています。圧縮して約70KBです。integrityの値はファイルのSHA-384ハッシュで、v4.0.4のリリースページのsri.jsonにあります。どのリリースも、各パッケージのファイルのハッシュをそこに載せています。
Put it on GitHub PagesGitHub Pagesに置く
Push the shop's folder to a repository — say little-roastery (leave node_modules out with a .gitignore). GitHub Pages will serve it at https://<you>.github.io/little-roastery/: under /little-roastery/, not at /, where npx serve serves it on your machine. Rather than editing <base href> back and forth, write the published copy with a small script, next to index.html:
ショップのフォルダをリポジトリ(たとえばlittle-roastery)にpushします(node_modulesは.gitignoreで外します)。GitHub Pagesはそれをhttps://<you>.github.io/little-roastery/で配信します。手元のnpx serveが配信する/ではなく、/little-roastery/の下です。<base href>を行ったり来たり書き換える代わりに、公開用のコピーを小さなスクリプトで書き出します。index.htmlの隣に置きます。
// Writes the copy of the shop that GitHub Pages serves, into docs/.
// On a project site the shop lives at /<repository>/, not at /, so the copy gets that base.
import { mkdirSync, readFileSync, writeFileSync, copyFileSync } from "node:fs";
const base = "/little-roastery/"; // "/" + your repository's name + "/"
const html = readFileSync("index.html", "utf8").replace('<base href="/">', `<base href="${base}">`);
mkdirSync("docs", { recursive: true });
writeFileSync("docs/index.html", html);
// GitHub Pages answers a path it has no file for (/little-roastery/products/2) with 404.html:
// the same page, so the router reads the URL and shows the product.
writeFileSync("docs/404.html", html);
copyFileSync("product-card.js", "docs/product-card.js");
console.log(`docs/ written for ${base}`);
$ node publish.mjs
docs/ written for /little-roastery/
Commit docs/ and push. In the repository's Settings → Pages, choose Deploy from a branch, your main branch and the /docs folder. Run node publish.mjs again after every change, before you push.
docs/をコミットしてpushします。リポジトリのSettings → PagesでDeploy from a branchを選び、mainのブランチと/docsフォルダを指定します。変更のたびに、pushの前にnode publish.mjsをもう一度実行します。
How it worksしくみ
The browser checks the fileブラウザがファイルを確かめる
With integrity, the browser hashes the file it downloaded and runs it only if the hash matches. A file changed on the CDN — by accident or by an attacker — does not run on your site. That works only for a file that never changes: esm.run builds its files on request, so it can't be checked, while jsDelivr's /npm/wcstack@4.0.4/dist/auto.min.js is the file as published. integrity covers that one file; product-card.js and the page itself come from your own site.
integrityがあると、ブラウザはダウンロードしたファイルのハッシュを計算し、一致したときだけ実行します。CDN上で変わったファイルは(事故でも攻撃でも)、サイトでは動きません。これは変わらないファイルでしか成り立ちません。esm.runは要求のたびにファイルを組み立てるので確かめられず、jsDelivrの/npm/wcstack@4.0.4/dist/auto.min.jsは公開されたファイルそのものです。integrityが守るのはその1つのファイルです。product-card.jsとページ自体は、自分のサイトから届きます。
404.html is the fallback404.htmlがfallback
GitHub Pages has no single-page fallback, but for a path it has no file for — /little-roastery/products/2 — it serves your 404.html. publish.mjs makes that file the shop itself, so the router reads the URL and shows the product. The response still carries status 404, which a visitor never sees, though a search engine does.
GitHub Pagesには単一ページアプリ向けのfallbackがありませんが、ファイルの無いパス(/little-roastery/products/2)には404.htmlを返します。publish.mjsがそのファイルをショップそのものにするので、ルーターがURLを読み、商品を表示します。応答のステータスは404のままで、訪問者には見えませんが、検索エンジンには見えます。
Have GitHub check every push. Commit package-lock.json, and add .github/workflows/check.yml that runs npm ci, npx @wcstack/lint --strict index.html and npm test on ubuntu-latest with Node 22: a push that breaks a path or a test gets a red mark.
pushのたびにGitHubに確かめさせてみましょう。package-lock.jsonをコミットし、ubuntu-latestとNode 22でnpm ci・npx @wcstack/lint --strict index.html・npm testを実行する.github/workflows/check.ymlを加えます。パスやテストを壊したpushには赤い印が付きます。
Common mistakesよくある間違い
An integrity that doesn't match一致しないintegrity
A typo in the hash, or a hash from another version, and the browser refuses the file: nothing on the page works, and the console says why:
ハッシュの打ち間違いや、別のバージョンのハッシュだと、ブラウザはファイルを拒みます。ページの何も動かず、コンソールが理由を言います。
Failed to find a valid digest in the 'integrity' attribute for resource 'https://cdn.jsdelivr.net/npm/wcstack@4.0.4/dist/auto.min.js' with computed SHA-384 integrity '…'. The resource has been blocked.
Don't fix it by hashing what the CDN sends you: that checks the file against itself. Take the value from the release's sri.json.
CDNが送ってきたもののハッシュを計算して直してはいけません。それはファイルをそれ自身と比べるだけです。値はリリースのsri.jsonから取ります。
Publishing index.html as it isindex.htmlをそのまま公開する
With <base href="/"> at /little-roastery/, the router counts its paths from the site's root: the shop's own address is a page it doesn't have, so it says “Page not found.”, and any link the page writes (products/2) would point outside the repository. Publish the copy that publish.mjs writes.
/little-roastery/で<base href="/">のままだと、ルーターはサイトの根からパスを数えます。ショップ自身のアドレスが知らないページになって「Page not found.」と出ます。ページが書くリンク(products/2)も、リポジトリの外を指すことになります。publish.mjsが書き出したコピーを公開します。
Part 3 completePart 3 完了
The shop is an app now: product pages with their own URLs, a component, tests that run in a second, and a published site that runs the version you tested. Along the way:
ショップはアプリになりました。自分のURLを持つ商品ページ、部品、1秒で終わるテスト、そしてテストしたバージョンで動く公開サイト。その途中で学んだこと:
<wcs-router>picks a route from the URL;typedParamsmakes the URL's parts state, links stay links, and<base href>says where the app starts. A server needs a single-page fallback for deep links.- A component is a custom element with
<wcs-state bind-component>: the page mounts data on it (state: .) and hears its events (onadd-to-cart:). @wcstack/testingmounts the real page in Node;fire()andsettle()act like a user, and a stubbedfetchkeeps the tests from the network.- Publish a pinned file with
integrityfrom the release'ssri.json; on GitHub Pages, a copy of the page as404.htmlis the fallback.
<wcs-router>はURLからルートを選ぶ。typedParamsがURLの部分をstateにし、リンクはリンクのまま、<base href>がアプリの始まりを示す。深いリンクには、サーバーの単一ページアプリ向けのfallbackが要る。- 部品は
<wcs-state bind-component>を持つカスタム要素。ページはデータをマウントし(state: .)、そのイベントを聞く(onadd-to-cart:)。 @wcstack/testingは本物のページをNodeでマウントする。fire()とsettle()がユーザーのように操作し、差し替えたfetchがテストをネットワークから切り離す。- 公開には、固定したファイルとリリースの
sri.jsonのintegrityを使う。GitHub Pagesでは、ページのコピーの404.htmlがfallbackになる。
That is the end of the tutorial. The state page is the complete reference for everything it didn't cover.
チュートリアルはここまでです。扱わなかったすべてについては、stateのページが完全なリファレンスです。
The whole fileファイル全体
The complete index.html of this step, as you keep it for npx serve (publish.mjs changes only its <base href>). npx @wcstack/lint index.html reports 0 error(s), 0 warning(s), 0 info for it.
このステップのindex.html全体で、npx serve用に手元に置く形です(publish.mjsが変えるのは<base href>だけ)。npx @wcstack/lint index.htmlの結果は0 error(s), 0 warning(s), 0 infoです。
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://cdn.jsdelivr.net/npm/wcstack@4.0.4/dist/auto.min.js"
integrity="sha384-EdACDTGJnYdmrieiNaWtKkVkcxL2L4QAC8fWCfA315Vsy1/eYUWDvshJMJ1LFleB"></script>
<script type="module" src="product-card.js"></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">
<product-card data-wcs="state: .; hidden: .matches|not; onadd-to-cart: addToCart"></product-card>
</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>