Publish it公開する

@4 means “the newest 4.x”, built for you on request. A published site should run the version you tested: pin it, load it as one file, and let the browser check that the file is the one released. Then put the shop on GitHub Pages, with its product pages reachable directly.

@4は「最新の4.x」で、要求のたびにCDNが組み立てます。公開するサイトは、テストしたバージョンで動くべきです。バージョンを固定し、1つのファイルで読み込み、それがリリースされたファイルそのものかをブラウザに確かめさせます。それから、商品ページへ直接たどり着けるショップをGitHub Pagesに置きます。

  1. 13
  2. 14
  3. 15
  4. 16

The resultできあがり

The same shop, now loaded from one pinned file that the browser has verified. It works as in step 14.

同じショップを、ブラウザが検証した、固定した1つのファイルから読み込んでいます。動きはステップ14と同じです。

Live — this step's pageライブ — このステップのページOpen in a new tab ↗新しいタブで開く ↗

Pin one file1つのファイルに固定する

In <head>, replace the four esm.run scripts with one:

<head>で、esm.runの4つのscriptを1つに置き換えます。

index.html — <head>changed変更
<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の隣に置きます。

publish.mjsnew file新しいファイル
// 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}`);
terminal
$ 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のままで、訪問者には見えませんが、検索エンジンには見えます。

Try itやってみよう

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:

ハッシュの打ち間違いや、別のバージョンのハッシュだと、ブラウザはファイルを拒みます。ページの何も動かず、コンソールが理由を言います。

console
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秒で終わるテスト、そしてテストしたバージョンで動く公開サイト。その途中で学んだこと:

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を表示
index.htmlstep 16ステップ16
<!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>