Latest release: v2.4.0

What if the browser
had these built in?

wcstack is a thought experiment turned into code. We imagine what future web standards could look like — reactive data binding, declarative routing, and thirty-plus Web APIs as plain HTML tags — and build them as if they already existed in the browser.

未来のWeb標準を想像し、それがすでにブラウザに存在するかのようにコードにする思考実験。30以上のWeb APIをHTMLタグに。

47
Packages
30+
Web API Tags
0
Dependencies
1
Script Tag Each

Shipped in v2.4.0

A getter derives from now, and a stream folds within one run — $scan owns the value that has to outlive both. Five fixes ship with it, each a place where state used to go quietly wrong: a replaced row, a failed start, a moved or re-set element, a row that changed.

getterは現在から導き、streamは1回の実行の中で畳み込む — その両方より長く生きる値を$scanが持つ。5つの修正が伴う。どれもstateが黙って間違えていた場所だ: 置き換えた行、失敗した起動、動かされた・再セットされた要素、変わった行。

v2.4.0 $scan

An accumulation with an owner

What if a value could outlive the stream that fed it?

An infinite-scroll feed is built across page runs, and until now nothing could hold it. A $streams fold accumulates within one run and goes back to initial the moment the page cursor restarts the stream; $watch owns no value, so the feed was concatenated by hand inside a handler, with nothing declaring how often it folds or when it resets. $scan: { feed: { from: "pageResult", initial, fold } } gives that value an owner. A from scan folds each landing of a state path once per update batch; an on scan folds each event of a declared event token, synchronously. The output is materialized from initial — plain arrays and objects are copied, so writing a child path never edits the declaration — and it survives stream restarts, disconnects and re-sets of the same object. The fold is synchronous, gets no this, and returning acc itself writes nothing; a throw, a returned Promise or an unreadable value is reported to the console and DevTools and writes nothing. examples/state-intersect-scroll now builds its feed this way. Root-only: a volume refuses it.

無限スクロールのフィードは複数のページ実行をまたいで組み立てられるが、これまでそれを保持できるものは無かった。$streamsfold1回の実行の中で蓄積し、ページカーソルがstreamを再起動した瞬間にinitialへ戻る。$watchは値を持たないので、フィードはハンドラの中で手で連結され、何回畳み込むのか・いつリセットするのかを宣言するものは無かった。$scan: { feed: { from: "pageResult", initial, fold } }はその値に持ち主を与える。fromスキャンはstateパスの着地を更新バッチごとに1回畳み込み、onスキャンは宣言済みイベントトークンのイベントを1つずつ同期的に畳み込む。出力はinitialから実体化され — 素の配列とオブジェクトはコピーされるので、子パスに書いても宣言は変わらない — streamの再起動、切断、同じオブジェクトの再セットを越えて残る。foldは同期で、thisを受け取らず、accそのものを返せば何も書かない。throw・返されたPromise・読めない値はコンソールとDevToolsに報告され、何も書かない。examples/state-intersect-scrollはいまこの形でフィードを組む。ルート専用: ボリュームは拒否する。

  • Outlives a Restart
  • from: Per Landing
  • on: Per Event
  • Returning acc Writes Nothing
v2.4.0 from / on / resetOn

Where the fold sits

What if an accumulator had a fixed place in the drain?

A from scan runs at the end of the drain in a fixed slot — $updatedCallback$scan$watch → the dependency-driven $streams restart — so a $watch handler already reads the folded output. An on scan folds inside the event, before that token’s $on handlers. Two rules settle the collisions: when a resetOn path is written, the output returns to initial and a from scan skips that batch’s fold (reset wins); a chunk that lands in the same batch as its stream’s restart belongs to the aborted run and is not folded (restart wins). Two shapes are refused outright. A getter is never a source — it re-evaluates whenever an input changes, so the fold would count re-evaluations instead of occurrences (wcs/scan-source-computed). And a stream whose args derive from its own scan output would restart on its own result (wcs/scan-feedback-loop): keep the cursor a plain property and advance it from an event. What the runtime guarantees is once per landing, not once per page — a Retry after done lands the same page again, so keep an idempotency key in the accumulator. wcs-validate checks the declaration under the runtime’s codes and adds wcs/scan-path-missing for a from / resetOn typo, which would otherwise just never fold.

fromスキャンはdrainの終わりの決まった位置で走る — $updatedCallback$scan$watch → 依存駆動の$streams再起動 — ので、$watchハンドラは畳み込み済みの出力を読む。onスキャンはイベントの中で、そのトークンの$onハンドラより先に畳み込む。衝突は2つの規則が決める: resetOnのパスが書かれると出力はinitialに戻り、fromスキャンはそのバッチのfoldを飛ばす(リセットが勝つ)。streamの再起動と同じバッチに着地したチャンクは中断された実行のものなので畳み込まれない(再起動が勝つ)。はっきり拒まれる形も2つある。getterは決してソースにならない — 入力が変わるたびに再評価されるので、foldは出来事ではなく再評価を数えてしまう(wcs/scan-source-computed)。そしてargsが自分のスキャン出力から導かれるstreamは、自分の結果で再起動してしまう(wcs/scan-feedback-loop): カーソルは素のプロパティにして、イベントから進めること。ランタイムが保証するのはページごとではなく着地ごとに1回だ — doneの後のRetryは同じページをもう一度着地させるので、蓄積値に冪等キーを持たせること。wcs-validateは宣言をランタイムと同じコードで検査し、さらにfrom / resetOnのタイプミス — 放っておけば黙って一度も畳み込まない — をwcs/scan-path-missingとして報告する。

  • Before $watch, Before $on
  • Reset Wins, Restart Wins
  • Never a Getter
  • Once Per Landing, Not Per Page
v2.4.0 { ...n }

Repaired, not rebuilt

What if the ordinary immutable update kept every total right?

this.nodes = this.nodes.map(n => ({ ...n })) is the ordinary immutable update, and on 2.3.0 it froze exactly one number with no diagnostic. The child list’s rows still hung under the row object that had just been retired, so a later leaf write dirtied the retired row’s address while the rendered binding watched the live one: the leaf, the deeper totals and every [] union stayed right, and only that row’s own nodes.*.total stopped moving. Rows are now repaired instead of rebuilt: when the parent a list’s rows hang under has been retired, those same rows are re-pointed at the live row, so anything keyed by row identity survives the update. That was measured, not assumed — rebuilding them destroyed a bind-component child scope’s rendered rows, and with them an open <details>, focus and a JS marker; the repair keeps all four. A plain nested for still rebuilds its child rows under a replaced parent, as it did before. Sharing one children array between two rows is a separate case with rules of its own: an array has exactly one set of rows, so give every node its own array when a child getter reads upward.

this.nodes = this.nodes.map(n => ({ ...n }))はごく普通のイミュータブル更新で、2.3.0ではそれが診断なしにちょうど1つの数を凍らせた。子リストの行は、たったいま引退した行オブジェクトの下にぶら下がったままだった。そのため後の葉への書き込みは引退した行のアドレスをdirtyにし、描画中のバインディングは生きている行のほうを見ていた: 葉も、より深いtotalも、すべての[]連結も正しいまま、その行自身のnodes.*.totalだけが動かなくなった。いま行は作り直されずに修復される: リストの行がぶら下がる親が引退していれば、同じ行が生きている行へ付け替えられるので、行の同一性をキーにしたものは更新を生き延びる。これは推測ではなく実測だ — 作り直すとbind-componentの子スコープが描いた行が壊れ、それとともに開いた<details>・フォーカス・JSのマーカーも消えた。修復は4つとも残す。素のネストしたforは、置き換えられた親の下の子行を従来どおり作り直す。1つのchildren配列を2つの行で共有するのは別の話で、独自の規則がある: 配列が持つ行はちょうど1組なので、子のgetterが上を読むならノードごとに配列を持たせること。

  • Retired Row, Live Binding
  • Re-pointed, Not Rebuilt
  • Details, Focus, Markers Survive
  • One Array Per Node
v2.4.0 connectedCallbackPromise

A page that simply never rendered

What if a failed start said so instead of hanging?

connectedCallback had one unguarded await, so a throw anywhere in initialization skipped every settlement: connectedCallbackPromise and initializePromise stayed pending forever, the page never rendered, and renderToString() / mount() never returned — with nothing in the console. A failure on a root element is now reported once with console.error and rejects connectedCallbackPromise with the original error, not a wrapper, while initializePromise still resolves so one element’s mistake does not drag the rest of the page’s bindings down. That covers every $ declaration validator, every state source, the enable-ssr merge, DCC and bind-component setup, and the one-root rule — where a second root <wcs-state> used to kill the whole page, now only the duplicate is refused. getBindingsReady(root) rejects instead of calling a page with no bindings ready, and setInitialState() on a failed element throws, telling you to replace it. Detaching an element while its source is still loading is not a failure: that connection ends quietly, and a pooled element re-appended later initializes and reports ready.

connectedCallbackにはガードされていないawaitが1つあり、初期化のどこかでthrowすると、すべてのsettleが飛ばされた: connectedCallbackPromiseinitializePromiseは永久にpendingのまま、ページは描画されず、renderToString() / mount()は戻らない — コンソールには何も出ない。ルート要素での失敗はいまconsole.error一度だけ報告され、ラッパーではなく元のエラーconnectedCallbackPromiseをrejectする。initializePromiseは引き続きresolveするので、1つの要素の誤りがページの残りのバインディングを道連れにはしない。対象はすべての$宣言バリデータ、すべてのstateソース、enable-ssrのマージ、DCCとbind-componentのセットアップ、そしてルート1つの規則 — 2つ目のルート<wcs-state>がページ全体を殺していたところを、いまは重複したほうだけが拒否される。getBindingsReady(root)はバインディングの無いページを「準備完了」と呼ばずにrejectし、失敗した要素へのsetInitialState()は要素を置き換えるよう告げてthrowする。ソースの読み込み中に要素を外すのは失敗ではない: その接続は静かに終わり、あとで再追加されたプール要素は初期化して準備完了を報告する。

  • Reported Once
  • The Original Error Rejects
  • Only the Duplicate Refused
  • Detach While Loading Is Fine
v2.4.0 re-attach / re-set

Moved, or refilled

What if one element stayed live after a move or a re-set?

Two ways of reusing one <wcs-state> went quietly stale. Moving the roothost.remove(); document.body.appendChild(host) — threw away the command-token and event-token registries on disconnect, while $on subscribes only when the state is set and a command.<method>: binding only when its value is applied; after reconnecting, every element event and every $command.<name>.emit() reached a fresh token with no subscribers. Disconnect now keeps both registries, as it already kept those of $streams and $watch. Re-setting a state object (setInitialState(), or assigning _state again) left reads on the previous generation: a wildcard-free getter’s address is the same object in both, so sum still read 3 after items became [5, 6], and a whole-list write threw. Cache entries now carry their generation, and the path registrations of live bindings are re-derived. The scope is stated plainly: a re-set still does not re-apply established bindings (#267), and a re-set on a mount= volume does nothing for the page (#268) — write individual paths instead.

1つの<wcs-state>を再利用する2つの方法が、黙って古びていた。ルートを動かすhost.remove(); document.body.appendChild(host) — と、切断時にコマンドトークンとイベントトークンのレジストリが捨てられた。一方$onが購読するのはstateがセットされたときだけ、command.<method>:バインディングは値が適用されたときだけなので、再接続後は要素のイベントも$command.<name>.emit()も、購読者のいない新しいトークンに届いていた。切断はいま両方のレジストリを保持する — $streams$watchのレジストリはすでにそうだった。再セット(setInitialState()、または_stateへの再代入)は読みを前の世代に残していた: ワイルドカードの無いgetterのアドレスは両世代で同じオブジェクトなので、items[5, 6]になってもsum3を読み続け、リスト全体への書き込みはthrowした。キャッシュのエントリはいま世代を持ち、生きているバインディングのパス登録は導き直される。範囲ははっきり書いておく: 再セットは確立済みのバインディングをまだ再適用しない(#267)。mount=ボリュームへの再セットはページに対して何もしない(#268) — 個々のパスに書くこと。

  • Token Registries Survive Disconnect
  • Reads Follow the Generation
  • Bindings Not Re-applied Yet
  • Volumes: Write the Paths
v2.4.0 $watch: "items.*"

As the list stands at the drain

What if a row watch fired for the rows that are actually there?

A wildcard $watch now fires once per row that actually changed, as the list stands when the batch drains. Replacing a nested list with a longer array — $resolve("groups.*.items", [0], [a, b]) over a one-row list — used to fire only for the positions the old array had: the write’s dependency walk read the nested list from the cache before the new array was committed there, saw no change and expanded the old rows, so the added rows never reached $watch while the DOM rendered them. The walk now reads the array it wrote, which also lets a $resolve into that nested list right after a structural write succeed where it threw ListIndexes not found. The other direction was worse. A row written and then removed in the same job was read by index, so shortening the list reported an evaluation error, and removing or replacing rows fired with the value of whatever row now sat at that position — twice when that row had changed too, and with the removed row’s prev when it had not. A row the list diff removed is now dropped, and each position fires once. $scan’s from shares the same narrowing.

ワイルドカードの$watchはいま、バッチをdrainする時点のリストに照らして、実際に変わった行ごとに1回発火する。ネストしたリストをより長い配列で置き換える — 1行のリストに対する$resolve("groups.*.items", [0], [a, b]) — と、以前は古い配列にあった位置の分しか発火しなかった: 書き込みの依存走査は、新しい配列がコミットされる前にキャッシュからネストしたリストを読み、変化なしと見て古い行だけを展開したので、追加された行はDOMには描画されても$watchには届かなかった。走査はいま自分が書いた配列を読む。そのおかげで、構造的な書き込みの直後にそのネストしたリストへ$resolveするのも、ListIndexes not foundを投げずに成功する。逆方向はもっと悪かった。同じジョブの中で書かれてから取り除かれた行はインデックスで読まれたので、リストを短くすると評価エラーが報告され、行の削除や置き換えは、いまその位置にある別の行の値で発火した — その行も変わっていれば2回、変わっていなければ取り除かれた行のprevで。リスト差分が取り除いた行はいま捨てられ、各位置は1回だけ発火する。$scanfromも同じ絞り込みを共有する。

  • Rows Past the Old Length
  • Removed Rows Stay Quiet
  • Once Per Position
  • $scan Shares It

The only contract is a path string

No hooks. No imports. No glue code.

フックなし。インポートなし。グルーコードなし。

In every existing framework, the component is where UI meets state. Even with external stores, you still write glue code inside the component to pull state in. State and UI always couple through JavaScript.

wcstack takes a different path. Literally. The only contract between UI and state is a path stringuser.name, cart.items.*.subtotal, cart.total. The component's JavaScript doesn't contain a single line that references state. The HTML alone describes every data dependency — the same idea as a REST URL: a simple string contract, no shared code.

State    "user.name"   UI          Path binds the two layers
Comp A   "cart.total"  Comp B      Mounted path crosses components
Loop     "items.*"     Template    Wildcard abstracts the index

UIと状態を結ぶ唯一の契約はパス文字列。UIを作り直しても状態に触れなくていい。状態をリファクタリングしてもDOMに触れなくていい。HTMLを読めばすべてが分かる。

HTML tags that should exist

What's missing from the browser — and what we built.

ブラウザに足りないもの — そして我々が作ったもの。

<wcs-state>

Reactive State

What if HTML had built-in reactive data binding?

Reactive state with Mustache syntax, path getters for computed properties, 40+ built-in filters, two-way binding, and structural directives — no virtual DOM.

Mustache構文、パスゲッター、40以上のビルトインフィルタ、双方向バインディング、構造ディレクティブ対応のリアクティブ状態管理。

  • Mustache Syntax
  • Path Getters
  • 40+ Filters
  • Two-way Binding
  • for / if / else
Learn more →
<wcs-router>

Declarative Router

What if you could define SPA routes directly in HTML?

Define routes with nested layouts, typed URL parameters (:id(int)), route guards, and per-route <head> management. Built on the Navigation API.

ネストレイアウト、型付きURLパラメータ、ルートガード、ルート別head管理を備えた宣言的ルーティング。

  • Nested Routes
  • Typed Params
  • Layouts
  • Route Guards
  • Head Management
<wcs-autoloader>

Component Autoloader

What if the browser could auto-import components just by seeing their tags?

Detects custom elements in the DOM and dynamically imports them via Import Maps. Supports eager & lazy loading with MutationObserver for dynamically added elements.

DOMのカスタム要素を検出し、Import Mapsで動的インポート。Eager/Lazy読み込みとMutationObserverに対応。

  • Import Maps
  • Eager / Lazy
  • MutationObserver
  • Pluggable Loaders
@wcstack/signals

Signals Core

What if TC39 signals were already here — and drove the same tags?

A fine-grained reactive core — signal / computed / effect, async resources, keyed For / Index — the JS-first counterpart to <wcs-state>, binding the same elements or their headless Cores.

signal / computed / effect、非同期resource、キー付きFor/Indexを備えたシグナルベースのリアクティブコア。同じ要素やヘッドレスCoreを束縛できるstateのJSファースト版。

  • TC39-shaped
  • Async Resources
  • Keyed For / Index
  • bindNode / mountNode
Learn more →
@wcstack/server

Server-Side Rendering

What if your templates rendered on the server — unchanged?

Same HTML, server-rendered. Add enable-ssr, call renderToString(), done. Automatic hydration with zero flicker, and version-safe fallback to CSR.

同じHTMLをサーバーでレンダリング。enable-ssrを付けてrenderToString()を呼ぶだけ。ゼロフリッカーの自動ハイドレーション付き。

  • Drop-in SSR
  • Auto Hydration
  • Zero Flicker
  • CSR Fallback
<wcs-fetch>

Fetch as a Tag

What if fetch was a tag?

Declarative HTTP as a headless component. Bind a URL, get data — URL changes automatically re-fetch. htmx-like HTML replace mode, and a headless core that runs in Node.js, Deno, and Workers.

宣言的HTTP通信。URLをバインドすればデータが届く。URL変更で自動再フェッチ。htmxライクなHTML置換モードも。

  • URL Observation
  • Trigger Property
  • HTML Replace
  • Headless Core

What if every Web API was a tag?

Thirty-plus declarative wrappers over the Web platform — each one a headless custom element that binds straight into your state.

Webプラットフォームの30以上のAPIを宣言的タグ化。すべてヘッドレスなカスタム要素として、状態に直接バインドされる。

<wcs-fetch>HTTP requests, auto re-fetch
<wcs-storage>localStorage / sessionStorage sync
<wcs-upload>File upload with progress
<wcs-ws>WebSocket real-time comms
<wcs-sse>Server-Sent Events streaming
<wcs-broadcast>Cross-tab messaging
<wcs-worker>Web Worker offloading
<wcs-timer>Ticks, elapsed time, polling
<wcs-raf>Frame ticks with dt, tab-aware
<wcs-debounce> <wcs-throttle>Value & signal coalescing
<wcs-clipboard>Clipboard read / write / monitor
<wcs-geo>Geolocation, live permission
<wcs-permission>Permissions API monitor
<wcs-notify>Desktop notifications
<wcs-intersect>Visibility, lazy-load, scrollspy
<wcs-resize>Element size observation
<wcs-wakelock>Screen Wake Lock
<wcs-camera> <wcs-recorder>Camera capture & recording
<wcs-speak> <wcs-listen>Speech synthesis & recognition
<wcs-defined>Custom-element readiness gate
<wcs-fullscreen>Fullscreen API
<wcs-pip>Picture-in-Picture
<wcs-pointer-lock>Pointer Lock for games / canvas
<wcs-screen-orientation>Orientation monitor & lock
<wcs-idle>Idle Detection
<wcs-network>Network Information monitor
<wcs-media-query>matchMedia: dark mode, reduced motion, breakpoints
<wcs-share>Web Share sheet
<wcs-contacts>Contact Picker
<wcs-credential>Credential Management
<wcs-eyedropper>EyeDropper color picker
<wcs-tilt>Device Orientation, iOS-safe
<wcs-accelerometer> <wcs-gyroscope>Motion sensors (x / y / z)
<wcs-magnetometer> <wcs-ambient-light-sensor>Magnetic field & illuminance
<wcs-audio> <wcs-osc> <wcs-gain>Web Audio graph as markup (11 tags)
<wcs-midi>Web MIDI in & out, one tag
<wcs-view-transition>Leave & move animations, policy only

One protocol behind them all: wc-bindable

Every I/O node exposes a static manifest — observable properties, settable inputs, invocable commands — so any binding core can discover and wire it generically. Since v1.24.0 each observable also declares its semanticsstate, event, or handle — so a generic adapter can tell a current value from an occurrence. <wcs-state> and @wcstack/signals speak it natively; thin adapters connect React, Vue, Svelte, and Solid.

すべてのI/Oノードは静的マニフェスト(properties / inputs / commands)を公開。v1.24.0からは各observableがsemantics(state / event / handle)も宣言し、汎用アダプタが現在値と発生を区別できる。stateとsignalsはネイティブ対応、薄いアダプタでReact / Vue / Svelte / Solidからも使える。

Code that reads like HTML

Because it is HTML.

HTMLのように読める。なぜなら、HTMLだから。

index.html State
<wcs-state>
  <script type="module">
    export default {
      taxRate: 0.1,
      cart: {
        items: [
          { name: "Widget", price: 500, quantity: 2 },
          { name: "Gadget", price: 1200, quantity: 1 }
        ]
      },
      removeItem(event, index) {
        this["cart.items"] = this["cart.items"].toSpliced(index, 1);
      },
      get "cart.items.*.subtotal"() {
        return this["cart.items.*.price"] * this["cart.items.*.quantity"];
      },
      get "cart.total"() {
        return this.$getAll("cart.items.*.subtotal", [])
          .reduce((a, b) => a + b, 0);
      },
      get "cart.grandTotal"() {
        return this["cart.total"] * (1 + this.taxRate);
      }
    };
  </script>
</wcs-state>

<template data-wcs="for: cart.items">
  <div>
    {{ .name }} &times;
    <input type="number" data-wcs="value: .quantity">
    = <span data-wcs="textContent: .subtotal|locale"></span>
    <button data-wcs="onclick: removeItem">Delete</button>
  </div>
</template>
<p>Grand Total: <span data-wcs="textContent: cart.grandTotal|locale(ja-JP)"></span></p>

<!-- Filters transform VALUES only. An event handler never takes a filter.
     BAD:  data-wcs="onkeydown: addTodo|enter"   <- no such filter exists
     GOOD: data-wcs="onkeydown: addTodo"         <- inspect event.key in the method
     Mutating state in place is not reactive either.
     BAD:  this.cart.items.push(x)
     GOOD: this["cart.items"] = [...this["cart.items"], x] -->
index.html Router
<wcs-router>
  <template>
    <wcs-route path="/">
      <wcs-layout layout="main-layout">
        <nav slot="header">
          <wcs-link to="/">Home</wcs-link>
          <wcs-link to="/products">Products</wcs-link>
        </nav>
        <wcs-route index>
          <wcs-head><title>Home</title></wcs-head>
          <app-home></app-home>
        </wcs-route>
        <wcs-route path="products">
          <wcs-route path=":id(int)">
            <product-detail data-bind="props"></product-detail>
          </wcs-route>
        </wcs-route>
      </wcs-layout>
    </wcs-route>
    <wcs-route fallback>
      <error-404></error-404>
    </wcs-route>
  </template>
</wcs-router>
<wcs-outlet></wcs-outlet>
index.html Fetch
<wcs-state>
  <script type="module">
    export default {
      users: [],
      loading: false,
      filterRole: "",
      get usersUrl() {
        const role = this.filterRole;
        return role ? "/api/users?role=" + role : "/api/users";
      },
    };
  </script>
</wcs-state>

<!-- URL changes automatically trigger re-fetch -->
<wcs-fetch data-wcs="url: usersUrl; value: users; loading: loading"></wcs-fetch>

<template data-wcs="if: loading">
  <p>Loading...</p>
</template>
<template data-wcs="for: users">
  <div data-wcs="textContent: .name"></div>
</template>
index.html Streams
<wcs-state>
  <script type="module">
    export default {
      prompt: "",
      // Fold an async producer into one reactive property.
      $streams: {
        tokens: {
          args:    (state) => state.prompt,            // dependency captured here
          source:  (prompt, signal) => llmStream(prompt, signal),
          fold:    (acc, chunk) => acc + chunk,        // accumulate
          initial: "",
        },
      },
    };
  </script>
</wcs-state>

<!-- Change prompt → the run aborts and restarts (switchMap) -->
<input data-wcs="value: prompt">
<p data-wcs="textContent: tokens"></p>
<p data-wcs="textContent: $streamStatus.tokens"></p>
<p data-wcs="textContent: $streamError.tokens"></p>
server.js SSR
import { renderToString } from "@wcstack/server";

const html = await renderToString(`
  <wcs-state enable-ssr>
    <script type="module">
      export default {
        items: [],
        async $connectedCallback() {
          const res = await fetch("/api/items");
          this.items = await res.json();
        }
      };
    </script>
  </wcs-state>
  <template data-wcs="for: items">
    <div data-wcs="textContent: items.*.name"></div>
  </template>
`);
// Client hydrates automatically — zero flicker.
index.html Autoloader
<!-- Define your import maps -->
<script type="importmap">
  {
    "imports": {
      "@components/ui/": "./components/ui/",
      "@components/ui|lit/": "./components/ui-lit/"
    }
  }
</script>

<!-- Auto-loaded from ./components/ui/button.js -->
<ui-button></ui-button>

<!-- Auto-loaded with Lit loader -->
<ui-lit-card></ui-lit-card>

Rules of the Game

This project follows five strict constraints. They're what make it interesting.

5つの厳格な制約。これが面白さの源泉。

1

Single CDN import

One <script> tag. That's it. No npm, no bundler, no config.

scriptタグ1つ。npm不要、バンドラー不要、設定不要。

2

Features as custom tags

Everything is a custom element. If it can't be expressed as <wcs-something>, it doesn't belong here.

すべてはカスタム要素。<wcs-something>で表現できなければ、ここには属さない。

3

Initial load = tag definitions only

The script just registers custom elements. No initialization code, no bootstrap ritual.

スクリプトはカスタム要素を登録するだけ。初期化コードもブートストラップ儀式も不要。

4

Respect HTML semantics

Expressions live in data-* attributes and text nodes — places HTML already allows extension. The DOM structure and semantics stay intact.

式はdata-*属性とテキストノードに配置 — HTMLが拡張を許可する場所のみ。DOM構造とセマンティクスはそのまま。

5

Latest ECMAScript

We actively adopt cutting-edge JS features. No transpiling to ES5. This is the future, after all.

最新のJS機能を積極採用。ES5へのトランスパイルなし。未来のプロジェクトだから。

These rules sound simple. They're not. Respecting HTML semantics means you need to deeply understand where the spec allows extension — and where it doesn't. Building everything as custom tags means solving lifecycle, ordering, and communication within the Custom Elements spec. No dependencies means every algorithm is yours to write. And it all has to feel like it could be a browser built-in.

シンプルに聞こえるルールだが、実際は違う。HTMLセマンティクスを尊重するなら、仕様が拡張を許す場所と許さない場所を深く理解する必要がある。すべてをカスタムタグで構築するなら、Custom Elementsの仕様の中でライフサイクル・順序・通信を解決しなければならない。依存ゼロなら、あらゆるアルゴリズムを自分で書く。そしてそのすべてが「ブラウザ組み込みかもしれない」と感じさせなければならない。

One package, one script tag.

Pick only the scoped packages you need. Element packages ship an /auto entry.

必要なスコープ付きパッケージだけを選ぶ。要素パッケージは/autoエントリを持つ。

index.html CDN via esm.run
<!-- Load the individual @wcstack/* packages your app uses -->
<script type="module" src="https://esm.run/@wcstack/router/auto"></script>
<script type="module" src="https://esm.run/@wcstack/fetch/auto"></script>
<script type="module" src="https://esm.run/@wcstack/autoloader/auto"></script>

<!-- Dev builds only: in-page DevTools overlay -->
<script type="module" src="https://esm.run/@wcstack/devtools/auto"></script>

<!-- State last: command-token emissions are not replayed -->
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>

<!-- Or (v1.32.0+): the SPA core in ONE request — state + router + fetch +
     storage + autoloader pre-linked, one SRI hash covering all of it -->
<script type="module" src="https://esm.run/wcstack/auto"></script>
terminal Verify what you wrote
# Check any HTML against the data-wcs contract. No install, no config.
# Exit code 0 means clean — iterate until it exits 0.
npx @wcstack/lint index.html

# Print the full authoring guide as plain text:
# binding syntax, filters, tokens, and the mistakes to avoid.
npm view wcstack readme

Each script registers its custom elements and does nothing else. No initialization, no bootstrap — tags activate when the browser parses them.

各スクリプトはカスタム要素を登録するだけ。初期化もブートストラップもなし — ブラウザがパースした時にタグが起動する。

Editor support: the wcstack-intellisense VS Code extension brings TypeScript language features — completion, diagnostics, hover — to <wcs-state> inline scripts in your HTML.

VS Code拡張「wcstack-intellisense」が、HTML内の<wcs-state>インラインスクリプトに補完・診断・ホバーを提供。