@wcstack/router 1.31.0 → 1.32.0

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/dist/index.esm.js CHANGED
@@ -162,16 +162,21 @@ const weights = {
162
162
  'param': 1,
163
163
  'catch-all': 0
164
164
  };
165
+ /**
166
+ * NOTE: RouteCore / Route は `static wcBindable` を**宣言しない**
167
+ * (docs/router-state-contract-design.md §5.1 / D2)。
168
+ *
169
+ * `<wcs-route>` は parse 時に clone された detached コントローラであり、live DOM に
170
+ * 入るのは placeholder コメントとスタンプされた子ノードだけ。data-wcs は live DOM
171
+ * 上の属性走査で結線する仕組みなので、宣言しても構造的に到達不能な「果たせない
172
+ * 約束」になる。params / typedParams / routeName の観測面は live DOM に居る
173
+ * `<wcs-router>` に集約した(Router.wcBindable)。
174
+ *
175
+ * `wcs-route:params-changed` / `wcs-route:active-changed` の dispatch は存置する —
176
+ * RouteCore は EventTarget であり、Core 直接消費(signals の正式推奨形)と
177
+ * ユニットテストの観測面として生きている。
178
+ */
165
179
  class RouteCore extends EventTarget {
166
- static wcBindable = {
167
- protocol: "wc-bindable",
168
- version: 1,
169
- properties: [
170
- { name: "params", event: "wcs-route:params-changed", semantics: "state" },
171
- { name: "typedParams", event: "wcs-route:params-changed", semantics: "state", getter: (e) => e.detail.typedParams },
172
- { name: "active", event: "wcs-route:active-changed", semantics: "state" },
173
- ],
174
- };
175
180
  _target;
176
181
  _parentCore = null;
177
182
  _path = '';
@@ -444,6 +449,14 @@ class RouteCore extends EventTarget {
444
449
  }
445
450
  return false;
446
451
  }
452
+ /**
453
+ * guard 属性の有無(parsePath の options.hasGuard)。SSR の guard バリア
454
+ * — guard 付きルートはサーバーで描かない(docs/ssr-router-design.md §2-4)—
455
+ * がハンドラのロードを待たずに判定するために使う。
456
+ */
457
+ get hasGuard() {
458
+ return this._hasGuard;
459
+ }
447
460
  get guardHandler() {
448
461
  if (!this._guardHandler) {
449
462
  raiseError(`${config.tagNames.route} has no guardHandler.`);
@@ -481,8 +494,9 @@ class RouteCore extends EventTarget {
481
494
  }
482
495
  }
483
496
 
497
+ // NOTE: `static wcBindable` は宣言しない — RouteCore.ts 冒頭の NOTE を参照
498
+ // (docs/router-state-contract-design.md §5.1 / D2)。
484
499
  class Route extends HTMLElement {
485
- static wcBindable = RouteCore.wcBindable;
486
500
  _core;
487
501
  _routeParentNode = null;
488
502
  _routeChildNodes = [];
@@ -522,6 +536,18 @@ class Route extends HTMLElement {
522
536
  }
523
537
  return this._childNodeArray;
524
538
  }
539
+ /**
540
+ * SSR ハイドレーションの採用(docs/ssr-router-design.md §4)。
541
+ * サーバー描画済みの DOM ノード列をこのルートの内容として引き取る。
542
+ * 以後の hideRoute / showRoute は採用ノードに対して従来どおり動く。
543
+ * template 由来の fresh クローン(自身の childNodes)は不要になるため破棄する。
544
+ */
545
+ adoptChildNodes(nodes) {
546
+ this._childNodeArray = [...nodes];
547
+ while (this.firstChild) {
548
+ this.removeChild(this.firstChild);
549
+ }
550
+ }
525
551
  get routes() {
526
552
  // matchRoutes / testPath のホットパスで再帰的に呼ばれるため遅延キャッシュする。
527
553
  // initialize 後は routeParentNode が固定されるためキャッシュしても安全。
@@ -581,6 +607,9 @@ class Route extends HTMLElement {
581
607
  get fullpath() {
582
608
  return this.absolutePath;
583
609
  }
610
+ get hasGuard() {
611
+ return this._core.hasGuard;
612
+ }
584
613
  get guardHandler() {
585
614
  return this._core.guardHandler;
586
615
  }
@@ -1178,6 +1207,15 @@ async function _parseNode(routerNode, node, routes, routesByPath) {
1178
1207
  }
1179
1208
  continue;
1180
1209
  }
1210
+ else if (tagName === "template") {
1211
+ // 不透明な葉として扱う。<template> の子は childNodes ではなく .content に
1212
+ // 居るため、汎用の再構築(_parseNode → innerHTML = "" → appendChild)に
1213
+ // 通すと content が空になり、ルート内容に書かれた state の構造テンプレート
1214
+ // (for / if)を黙って破壊する。route 定義は template の中には置けない
1215
+ // (inert)ので、中を辿る理由も無い。
1216
+ fragment.appendChild(element);
1217
+ continue;
1218
+ }
1181
1219
  else if (tagName === config.tagNames.layout) {
1182
1220
  // <wcs-layout> は他の case と異なり element と appendNode が別物になる。
1183
1221
  // - element: cloneElement (Layout 本体)。後続の `element.innerHTML = ""; element.appendChild(children)`
@@ -1215,6 +1253,62 @@ async function parse(routerNode) {
1215
1253
  return fr;
1216
1254
  }
1217
1255
 
1256
+ /**
1257
+ * route commit 後のオプトイン a11y ポリシー適用(docs/a11y-design.md §3-4 / D1〜D3)。
1258
+ *
1259
+ * 呼び出しは applyRoute の committed 判定後・mutate() の外・初回描画
1260
+ * (lastRoutes が空)を除く。guard 拒否はここに到達しない(D4)。
1261
+ *
1262
+ * - `announce="title"`: commit 時点の document.title のスナップショットを
1263
+ * live region へ書き込む(D2)。<wcs-head> の静的 title は mutate() 内で同期に
1264
+ * 差し替わるため、ここでは必ず新ルートの値が読める。バインド title の遅延窓・
1265
+ * ナビゲーション外の title 変化には追従しない(README の明記された制限)。
1266
+ * - `focus="heading"`: リーフ route が挿入した内容の最初の h1〜h6 に
1267
+ * tabindex="-1" を付けて focus() する。見出し不在時は何もしない — 旧フォーカス
1268
+ * 要素が遷移で消えていればブラウザが body へ落とすため、結果は仕様既定の
1269
+ * focusReset と同等に収束する(§3-4 の規定)。
1270
+ */
1271
+ function applyA11yPolicies(routerNode, matchResult) {
1272
+ if (routerNode.announcePolicy === "title") {
1273
+ const region = routerNode.a11yRegion;
1274
+ if (region !== null) {
1275
+ region.textContent = document.title;
1276
+ }
1277
+ }
1278
+ if (routerNode.focusPolicy === "heading") {
1279
+ // matchRoutes / fallbackRoute の構成上 routes は常に 1 件以上
1280
+ const leaf = matchResult.routes[matchResult.routes.length - 1];
1281
+ const heading = findFirstHeading(leaf.childNodeArray);
1282
+ if (heading !== null) {
1283
+ if (!heading.hasAttribute("tabindex")) {
1284
+ heading.setAttribute("tabindex", "-1");
1285
+ }
1286
+ heading.focus();
1287
+ }
1288
+ }
1289
+ }
1290
+ /**
1291
+ * リーフ route のトップレベルノード列を document order で走査し、最初の見出しを
1292
+ * 返す。祖先 route の内容へは遡らない — 読者が「新しい画面」と認識する単位は
1293
+ * リーフである(docs/a11y-design.md §3-4)。ルート内容は Comment placeholder の
1294
+ * 兄弟として挿入されるため安定した「箱」が無く、内容から探すのが唯一の現実解。
1295
+ */
1296
+ function findFirstHeading(nodes) {
1297
+ for (const node of nodes) {
1298
+ if (node.nodeType !== Node.ELEMENT_NODE)
1299
+ continue;
1300
+ const element = node;
1301
+ if (/^H[1-6]$/.test(element.tagName)) {
1302
+ return element;
1303
+ }
1304
+ const descendant = element.querySelector("h1,h2,h3,h4,h5,h6");
1305
+ if (descendant !== null) {
1306
+ return descendant;
1307
+ }
1308
+ }
1309
+ return null;
1310
+ }
1311
+
1218
1312
  function testPath(route, path, segments) {
1219
1313
  const params = {};
1220
1314
  const typedParams = {};
@@ -1348,6 +1442,92 @@ function matchRoutes(routerNode, normalizedPath) {
1348
1442
  return null;
1349
1443
  }
1350
1444
 
1445
+ // basenameFileExtensions ベースの正規表現をキャッシュ(config 変更時のみ再生成)。
1446
+ let _cachedExtensions = null;
1447
+ let _cachedExtPattern = null;
1448
+ /**
1449
+ * config.basenameFileExtensions から拡張子削除用の正規表現を生成(キャッシュ付き)。
1450
+ * config 変更が検知された場合のみ再生成する。
1451
+ */
1452
+ function getExtPattern() {
1453
+ const exts = config.basenameFileExtensions;
1454
+ if (exts.length === 0)
1455
+ return null;
1456
+ if (_cachedExtensions === exts && _cachedExtPattern) {
1457
+ return _cachedExtPattern;
1458
+ }
1459
+ _cachedExtensions = exts;
1460
+ _cachedExtPattern = new RegExp(`\\/[^/]+(?:${exts.map(e => e.replace(/\./g, '\\.')).join('|')})$`, 'i');
1461
+ return _cachedExtPattern;
1462
+ }
1463
+ /**
1464
+ * URL pathname を route path に正規化する。
1465
+ * - 先頭スラッシュを保証
1466
+ * - 連続スラッシュを単一化
1467
+ * - 末尾のファイル拡張子(例: .html)をディレクトリルートとして扱う
1468
+ * - ルート以外の末尾スラッシュを除去
1469
+ */
1470
+ function normalizePathname(path) {
1471
+ let p = path || "/";
1472
+ if (!p.startsWith("/"))
1473
+ p = "/" + p;
1474
+ p = p.replace(/\/{2,}/g, "/");
1475
+ const extPattern = getExtPattern();
1476
+ if (extPattern) {
1477
+ p = p.replace(extPattern, "");
1478
+ }
1479
+ if (p === "")
1480
+ p = "/";
1481
+ if (p.length > 1 && p.endsWith("/"))
1482
+ p = p.slice(0, -1);
1483
+ return p;
1484
+ }
1485
+ /**
1486
+ * fullPath から basename を取り除いた route path を返す。
1487
+ *
1488
+ * applyRoute のマッチング入力と same-match 判定
1489
+ * (docs/router-state-contract-design.md §4.4)で共有する。same-match の比較は
1490
+ * **basename スライス後の path 同士**で行う規範 — `router.path` に格納されるのは
1491
+ * スライス後のパスであり、スライス前の fullPath と比較すると basename 運用で
1492
+ * same-match が決して成立しない。
1493
+ */
1494
+ function sliceBasename(fullPath, basename) {
1495
+ let sliced = fullPath;
1496
+ if (basename !== "") {
1497
+ if (fullPath === basename) {
1498
+ sliced = "";
1499
+ }
1500
+ else if (fullPath.startsWith(basename + "/")) {
1501
+ sliced = fullPath.slice(basename.length);
1502
+ }
1503
+ }
1504
+ // when fullPath === basename (e.g. "/app"), treat it as root "/"
1505
+ return sliced === "" ? "/" : sliced;
1506
+ }
1507
+ /**
1508
+ * basename を正規化する。
1509
+ * - "" or "/" -> ""
1510
+ * - "/app/" -> "/app"
1511
+ * - "/app/index.html" -> "/app"
1512
+ */
1513
+ function normalizeBasename(path) {
1514
+ let p = path || "";
1515
+ if (!p)
1516
+ return "";
1517
+ if (!p.startsWith("/"))
1518
+ p = "/" + p;
1519
+ p = p.replace(/\/{2,}/g, "/");
1520
+ const extPattern = getExtPattern();
1521
+ if (extPattern) {
1522
+ p = p.replace(extPattern, "");
1523
+ }
1524
+ if (p.length > 1 && p.endsWith("/"))
1525
+ p = p.slice(0, -1);
1526
+ if (p === "/")
1527
+ return "";
1528
+ return p;
1529
+ }
1530
+
1351
1531
  function hideRoute(route) {
1352
1532
  route.clearParams();
1353
1533
  for (const node of route.childNodeArray) {
@@ -1355,7 +1535,17 @@ function hideRoute(route) {
1355
1535
  }
1356
1536
  }
1357
1537
 
1358
- function showRoute(route, matchResult) {
1538
+ /**
1539
+ * ルートへのパラメータ割り当て(setParams + 内容ノードへの data-bind /
1540
+ * LayoutOutlet 配送)。挿入とは独立に呼べるよう showRoute から抽出 —
1541
+ * SSR ハイドレーション(採用時は内容が既に DOM に居るため挿入しない)が
1542
+ * 同じ配送規則を共有する(docs/ssr-router-design.md §4)。
1543
+ *
1544
+ * connectedCallback が呼ばれる前に、プロパティにパラメータを割り当てる必要が
1545
+ * あるため(挿入時にパラメータはすでに設定されている必要がある)、showRoute は
1546
+ * これを挿入より先に呼ぶ。
1547
+ */
1548
+ function assignRouteParams(route, matchResult) {
1359
1549
  const params = {};
1360
1550
  const typedParams = {};
1361
1551
  for (const key of route.paramNames) {
@@ -1363,11 +1553,7 @@ function showRoute(route, matchResult) {
1363
1553
  typedParams[key] = matchResult.typedParams[key];
1364
1554
  }
1365
1555
  route.setParams(params, typedParams);
1366
- const parentNode = route.placeHolder.parentNode;
1367
- const nextSibling = route.placeHolder.nextSibling;
1368
1556
  for (const node of route.childNodeArray) {
1369
- // connectedCallbackが呼ばれる前に、プロパティにパラメータを割り当てる
1370
- // connectedCallbackを実行するときにパラメータはすでに設定されている必要があるため
1371
1557
  if (node.nodeType === Node.ELEMENT_NODE) {
1372
1558
  const element = node;
1373
1559
  element.querySelectorAll('[data-bind]').forEach((e) => {
@@ -1383,6 +1569,13 @@ function showRoute(route, matchResult) {
1383
1569
  element.assignParams(route.typedParams);
1384
1570
  }
1385
1571
  }
1572
+ }
1573
+ }
1574
+ function showRoute(route, matchResult) {
1575
+ assignRouteParams(route, matchResult);
1576
+ const parentNode = route.placeHolder.parentNode;
1577
+ const nextSibling = route.placeHolder.nextSibling;
1578
+ for (const node of route.childNodeArray) {
1386
1579
  if (nextSibling) {
1387
1580
  parentNode?.insertBefore(node, nextSibling);
1388
1581
  }
@@ -1393,22 +1586,268 @@ function showRoute(route, matchResult) {
1393
1586
  return true;
1394
1587
  }
1395
1588
 
1589
+ // ===========================================================================
1590
+ // AUTO-GENERATED FILE - DO NOT EDIT.
1591
+ // Generated from /protocol/transition-runner.ts by scripts/sync-protocol-types.mjs.
1592
+ // Run `node scripts/sync-protocol-types.mjs` after editing the source.
1593
+ // ===========================================================================
1594
+ // transition-runner protocol — how a package that mutates the DOM hands that
1595
+ // mutation to whoever is arbitrating view transitions on the page.
1596
+ //
1597
+ // @wcstack/state and @wcstack/router must not depend on @wcstack/view-transition
1598
+ // (zero runtime dependencies, independently publishable), so the arbiter installs
1599
+ // itself on a well-known global symbol and the participants look it up lazily.
1600
+ // No arbiter installed means the mutation is invoked directly, synchronously —
1601
+ // byte-for-byte the behavior these packages had before the protocol existed.
1602
+ //
1603
+ // docs/view-transition-design.md §4 is the normative description.
1604
+ //
1605
+ // SINGLE SOURCE OF TRUTH: edit only this file (/protocol/transition-runner.ts), then run
1606
+ // `node scripts/sync-protocol-types.mjs` to regenerate the per-package copies
1607
+ // (packages/<pkg>/src/protocol/transitionRunner.ts). Those copies are generated — do not edit them.
1396
1608
  /**
1397
- * ルートコンテンツを表示する。
1609
+ * Global key the arbiter installs itself under. `Symbol.for` so independently
1610
+ * loaded copies of this file (two CDN bundles on one page) still agree.
1611
+ */
1612
+ const TRANSITION_RUNNER_KEY = Symbol.for("wcstack.transition-runner");
1613
+ /**
1614
+ * The installed arbiter, or null when there is none, it speaks a version this
1615
+ * reader does not, or it does not accept this participant.
1398
1616
  *
1399
- * @returns ガードチェックを通過してコンテンツ表示が成立した場合 true、
1400
- * GuardCancel により中断(フォールバックへ再ナビゲート)した場合 false。
1401
- * 呼び出し側(applyRoute)は false の場合、router.path / outlet.lastRoutes を
1402
- * 更新しないことで「拒否されたパスでの path-changed 発火」を防ぐ。
1617
+ * Looked up on every call rather than cached: the tag can be added, removed, or
1618
+ * reconfigured at any point in a page's life, and a stale cache would either
1619
+ * animate what the author just switched off or miss what they switched on.
1403
1620
  */
1404
- async function showRouteContent(routerNode, matchResult, lastRoutes) {
1405
- // Hide previous routes
1406
- const routesSet = new Set(matchResult.routes);
1407
- for (const route of lastRoutes) {
1408
- if (!routesSet.has(route)) {
1409
- hideRoute(route);
1621
+ function getTransitionRunner(source) {
1622
+ const candidate = globalThis[TRANSITION_RUNNER_KEY];
1623
+ if (candidate === undefined || candidate === null)
1624
+ return null;
1625
+ if (candidate.protocol !== "wcs-transition-runner")
1626
+ return null;
1627
+ if (typeof candidate.version !== "number" || candidate.version < 1)
1628
+ return null;
1629
+ if (typeof candidate.run !== "function")
1630
+ return null;
1631
+ if (typeof candidate.accepts !== "function" || !candidate.accepts(source))
1632
+ return null;
1633
+ return candidate;
1634
+ }
1635
+ /**
1636
+ * Run `mutate` under the installed arbiter, or directly when there is none.
1637
+ *
1638
+ * Returns `undefined` in the no-arbiter case instead of a resolved promise: the
1639
+ * state drain calls this on every batch, and awaiting is a caller's choice, not
1640
+ * an allocation the common path should pay for. `await` accepts both.
1641
+ */
1642
+ function runTransition(source, mutate, types) {
1643
+ const runner = getTransitionRunner(source);
1644
+ if (runner === null) {
1645
+ mutate();
1646
+ return undefined;
1647
+ }
1648
+ return runner.run(mutate, { source, types });
1649
+ }
1650
+
1651
+ // ===========================================================================
1652
+ // AUTO-GENERATED FILE - DO NOT EDIT.
1653
+ // Generated from /protocol/binder.ts by scripts/sync-protocol-types.mjs.
1654
+ // Run `node scripts/sync-protocol-types.mjs` after editing the source.
1655
+ // ===========================================================================
1656
+ // binder protocol — how a package that inserts DOM hands those nodes to whoever
1657
+ // owns data bindings on the page.
1658
+ //
1659
+ // The dual of transition-runner: that one hands a *mutation* to whoever animates
1660
+ // it, this one hands *new nodes* to whoever binds them.
1661
+ //
1662
+ // A `data-wcs` binding exists only for nodes @wcstack/state walked when it built
1663
+ // its bindings. Nodes that arrive later — the content of a route that was not
1664
+ // active at that moment, a <wcs-head> child reflected into <head> — were never
1665
+ // walked, so their bindings silently do nothing, however often they are inserted.
1666
+ // @wcstack/router must not depend on @wcstack/state (zero runtime dependencies,
1667
+ // independently publishable), so state installs a binder on a well-known global
1668
+ // symbol and inserters look it up lazily.
1669
+ //
1670
+ // No binder installed means nothing happens — byte-for-byte the behavior these
1671
+ // packages had before the protocol existed.
1672
+ //
1673
+ // docs/binder-protocol-design.md is the normative description.
1674
+ //
1675
+ // SINGLE SOURCE OF TRUTH: edit only this file (/protocol/binder.ts), then run
1676
+ // `node scripts/sync-protocol-types.mjs` to regenerate the per-package copies
1677
+ // (packages/<pkg>/src/protocol/binder.ts). Those copies are generated — do not edit them.
1678
+ /**
1679
+ * Global key the binder installs itself under. `Symbol.for` so independently
1680
+ * loaded copies of this file (two CDN bundles on one page) still agree.
1681
+ */
1682
+ const BINDER_KEY = Symbol.for("wcstack.binder");
1683
+ /**
1684
+ * The installed binder, or null when there is none or it speaks a version this
1685
+ * reader does not.
1686
+ *
1687
+ * Looked up on every call rather than cached, for the same reason
1688
+ * transition-runner does: the page's composition can change at any point, and a
1689
+ * stale cache would keep calling into a binder that is no longer there.
1690
+ */
1691
+ function getBinder() {
1692
+ const candidate = globalThis[BINDER_KEY];
1693
+ if (candidate === undefined || candidate === null)
1694
+ return null;
1695
+ if (candidate.protocol !== "wcs-binder")
1696
+ return null;
1697
+ if (typeof candidate.version !== "number" || candidate.version < 1)
1698
+ return null;
1699
+ if (typeof candidate.bind !== "function")
1700
+ return null;
1701
+ return candidate;
1702
+ }
1703
+ /**
1704
+ * Subtrees offered before a binder existed, and the set of everything a binder
1705
+ * has taken. Both live on global symbols so that independently loaded copies of
1706
+ * this file — the router's and state's — share one queue.
1707
+ *
1708
+ * The queue is needed because of load order: the router's auto bundle runs
1709
+ * before state's, so `<wcs-head>` reflects its children into `<head>` while
1710
+ * there is still nothing to bind them. Offering them to a binder that arrives
1711
+ * later is the difference between working and silently blank.
1712
+ */
1713
+ const PENDING_KEY = Symbol.for("wcstack.binder.pending");
1714
+ const TAKEN_KEY = Symbol.for("wcstack.binder.taken");
1715
+ function pendingQueue() {
1716
+ const globals = globalThis;
1717
+ let queue = globals[PENDING_KEY];
1718
+ if (queue === undefined) {
1719
+ queue = [];
1720
+ globals[PENDING_KEY] = queue;
1721
+ }
1722
+ return queue;
1723
+ }
1724
+ function takenSet() {
1725
+ const globals = globalThis;
1726
+ let taken = globals[TAKEN_KEY];
1727
+ if (taken === undefined) {
1728
+ taken = new WeakSet();
1729
+ globals[TAKEN_KEY] = taken;
1730
+ }
1731
+ return taken;
1732
+ }
1733
+ /**
1734
+ * Hand `subtree` to the installed binder, or hold it for one that arrives later.
1735
+ *
1736
+ * Returns whether a binder took it *now*. A `false` does not yet mean the markup
1737
+ * is doomed — check {@link wasBoundBy} once module scripts have run.
1738
+ */
1739
+ function bindSubtree(subtree) {
1740
+ const binder = getBinder();
1741
+ if (binder === null) {
1742
+ pendingQueue().push(subtree);
1743
+ return false;
1744
+ }
1745
+ takenSet().add(subtree);
1746
+ binder.bind(subtree);
1747
+ return true;
1748
+ }
1749
+ /** Whether any binder has taken this subtree. */
1750
+ function wasBoundBy(subtree) {
1751
+ return takenSet().has(subtree);
1752
+ }
1753
+
1754
+ /**
1755
+ * 「後から差し込んだノードのバインドは効かない」ことを loud に報告する。
1756
+ *
1757
+ * `data-wcs` のバインドは、`@wcstack/state` がバインドを構築した時点で document に
1758
+ * 居たノードにしか作られない。router が後から差し込むノード —— 非活性ルートの内容
1759
+ * (`hideRoute` が切り離しているので走査されない)と `<wcs-head>` が head へ映す
1760
+ * クローン(元ノードとは別物)—— はどちらもその時点に存在せず、バインドは決して
1761
+ * 届かない。何度ナビゲーションを往復しても回復しない。
1762
+ *
1763
+ * **挙動は変えない。** 変えるのは「黙って空になる」を「原因を指す警告」にすること
1764
+ * だけである。症状(見出しが空・`<title>` が消える)は原因(バインド構築の時点)から
1765
+ * 遠く、しかも例外も出ないため、これまで気づく手立てが無かった。
1766
+ *
1767
+ * 恒久的な解決は binder プロトコル(docs/binder-protocol-design.md)で別途決める。
1768
+ *
1769
+ * 警告は要素ごとに 1 回。壊れている場合にのみ走るので、正常系のコストはゼロ。
1770
+ */
1771
+ const warned = new WeakSet();
1772
+ /** `data-wcs`。router は state の config を読めないので既定名を直接持つ */
1773
+ const BIND_ATTRIBUTE = "data-wcs";
1774
+ function hasBinding(element) {
1775
+ return element.hasAttribute(BIND_ATTRIBUTE) || element.querySelector(`[${BIND_ATTRIBUTE}]`) !== null;
1776
+ }
1777
+ /**
1778
+ * @param element 差し込まれるサブツリーの根
1779
+ * @param where 利用者が原因を特定できる位置の説明(例: `<wcs-route path="/about">`)
1780
+ * @param remedy その位置に固有の回避策
1781
+ */
1782
+ function warnUnboundMarkup(element, where, remedy) {
1783
+ if (warned.has(element) || !hasBinding(element)) {
1784
+ return;
1785
+ }
1786
+ warned.add(element);
1787
+ // 判定は **DOMContentLoaded まで**遅らせる。router の auto バンドルは state の
1788
+ // ものより先に走るので、`<wcs-head>` が差し出す時点では binder がまだ居ない。
1789
+ // そこで即断すると、この直後に正しく束ねられるノードを「壊れている」と報告する。
1790
+ //
1791
+ // タイマーでは足りない。deferred な module script はパース完了後に実行されるので、
1792
+ // `setTimeout(0)` は state の auto バンドルより**先に発火しうる**(実測)。
1793
+ // DOMContentLoaded は全 deferred script の実行後に発火するので、そこでは決着している。
1794
+ //
1795
+ // 見るのは「束ね終わったか」ではなく **binder が居るか**。バインド構築は
1796
+ // インライン state モジュールの読み込みを挟むので完了はさらに後になりうるが、
1797
+ // binder が居るなら保留キューはいずれ引き取られるので報告する理由が無い。
1798
+ whenLoadOrderSettled(() => {
1799
+ if (getBinder() !== null || wasBoundBy(element)) {
1800
+ return;
1410
1801
  }
1802
+ console.warn(`[@wcstack/router] ${where} contains ${BIND_ATTRIBUTE} bindings that will never be applied. ` +
1803
+ `A binding exists only for nodes that were in the document when @wcstack/state built its ` +
1804
+ `bindings, and these nodes were not. They will render empty. ${remedy}`);
1805
+ });
1806
+ }
1807
+ function whenLoadOrderSettled(check) {
1808
+ if (document.readyState !== "complete") {
1809
+ // `load` を待つ。`DOMContentLoaded` では足りない —— deferred script の実行中は
1810
+ // readyState が既に `"interactive"` なので「まだ loading か」では判別できず、
1811
+ // DOMContentLoaded を待つつもりが即断になる(実測)。`load` は必ず発火し、
1812
+ // 全 deferred script より確実に後に来る。診断なので多少遅くて構わない。
1813
+ window.addEventListener("load", check, { once: true });
1814
+ return;
1411
1815
  }
1816
+ // 起動後の挿入(ナビゲーション)。読み込み順はとうに決着している。
1817
+ check();
1818
+ }
1819
+
1820
+ /**
1821
+ * 差し込んだルート内容を binder へ渡す。binder が居なければ、バインドが効かない
1822
+ * ことを 1 回だけ報告する。
1823
+ *
1824
+ * 挿入の**後**に呼ぶ。`bind()` は初期値の適用まで同期で行うので、挿入前に呼ぶと
1825
+ * まだ document に居ないノードを走査することになる。
1826
+ *
1827
+ * binder が居ないのは state を読み込んでいないページで、そこでは `data-wcs` が
1828
+ * そもそも動かない。報告に直し方まで書くのは、これが仕様の穴ではなく**分担の
1829
+ * 境界**だからである(examples/router-spa と examples/router-i18n が同じ分担)。
1830
+ */
1831
+ function bindRouteContent(route) {
1832
+ for (const node of route.childNodeArray) {
1833
+ if (node.nodeType !== 1)
1834
+ continue;
1835
+ if (bindSubtree(node))
1836
+ continue;
1837
+ warnUnboundMarkup(node, `<${node.tagName.toLowerCase()}> inside a route`, `Load @wcstack/state on this page, or render data-driven markup outside ` +
1838
+ `<wcs-router> — bind the router's \`path\` into state and gate the markup ` +
1839
+ `with <template data-wcs="if: …">. See examples/router-i18n.`);
1840
+ }
1841
+ }
1842
+ /**
1843
+ * ガード相の単独実装。何も触らずに全ルートの guardCheck を待ち、GuardCancel なら
1844
+ * フォールバックへの再ナビゲートを microtask で予約して false を返す。
1845
+ *
1846
+ * showRouteContent の相 1 であると同時に、SSR ハイドレーション
1847
+ * (docs/ssr-router-design.md §4 — 採用はレンダリング最適化であって認可の
1848
+ * スキップではない)からも同じ規則で呼ばれるため抽出した。
1849
+ */
1850
+ async function runGuardPhase(routerNode, matchResult) {
1412
1851
  try {
1413
1852
  for (const route of matchResult.routes) {
1414
1853
  await route.guardCheck(matchResult);
@@ -1428,29 +1867,107 @@ async function showRouteContent(routerNode, matchResult, lastRoutes) {
1428
1867
  throw e;
1429
1868
  }
1430
1869
  }
1870
+ return true;
1871
+ }
1872
+ /**
1873
+ * ルートコンテンツを表示する。
1874
+ *
1875
+ * 二相構成(docs/view-transition-design.md §7.1):
1876
+ * 1. ガード相 — 何も触らずに全ルートの guardCheck を待つ。
1877
+ * 2. 変更相 — 旧ルートの hide と新ルートの show を「ひとまとまりの DOM 変更」
1878
+ * として transition arbiter に渡す。arbiter が居なければ同期実行され、
1879
+ * 従来と同じ挙動になる。ただし初回描画は渡さない(下記)。
1880
+ *
1881
+ * ガードを変更相の中に入れないのは、更新コールバックの中で任意の await を
1882
+ * 走らせると遷移が開きっぱなしになるため(ブラウザの猶予は約 4 秒)。
1883
+ * 相を分けたことで「ガードが拒否したのに旧ルートだけ先に消えている」という
1884
+ * 順序の歪みも同時に解消している。
1885
+ *
1886
+ * @returns ガードチェックを通過してコンテンツ表示が成立した場合 true、
1887
+ * GuardCancel により中断(フォールバックへ再ナビゲート)した場合 false。
1888
+ * 呼び出し側(applyRoute)は false の場合、router.path / outlet.lastRoutes を
1889
+ * 更新しないことで「拒否されたパスでの path-changed 発火」を防ぐ。
1890
+ */
1891
+ async function showRouteContent(routerNode, matchResult, lastRoutes) {
1892
+ // --- ガード相 ---
1893
+ if (!(await runGuardPhase(routerNode, matchResult))) {
1894
+ return false;
1895
+ }
1896
+ // --- 変更相 ---
1897
+ const routesSet = new Set(matchResult.routes);
1431
1898
  const lastRouteSet = new Set(lastRoutes);
1432
- let force = false;
1433
- for (const route of matchResult.routes) {
1434
- if (!lastRouteSet.has(route) || route.shouldChange(matchResult.params) || force) {
1435
- force = showRoute(route, matchResult);
1899
+ const mutate = () => {
1900
+ // Hide previous routes
1901
+ for (const route of lastRoutes) {
1902
+ if (!routesSet.has(route)) {
1903
+ hideRoute(route);
1904
+ }
1905
+ }
1906
+ let force = false;
1907
+ for (const route of matchResult.routes) {
1908
+ if (!lastRouteSet.has(route) || route.shouldChange(matchResult.params) || force) {
1909
+ force = showRoute(route, matchResult);
1910
+ // 挿入の後。初回描画(lastRoutes が空)の内容は state のバインド構築時に
1911
+ // document に居るので、そこは binder に渡す必要も報告する必要も無い。
1912
+ // `bind()` 自体は冪等なので渡しても壊れないが、渡さないほうが安い。
1913
+ if (lastRoutes.length > 0 && !lastRouteSet.has(route)) {
1914
+ bindRouteContent(route);
1915
+ }
1916
+ }
1436
1917
  }
1918
+ };
1919
+ // 初回描画(=置き換える旧ルートが無い)は遷移に渡さない。state 側の
1920
+ // 「初期レンダリングは決して包まない、包むのは drain だけ」と同じ規則で、
1921
+ // 理由も同じ: 差し替えではなく入場であり、対比すべき旧状態が無い。入場は
1922
+ // @starting-style の担当(docs/view-transition-design.md §1)。
1923
+ //
1924
+ // これは好みの問題ではない。router の初期化は最初のルート適用を await するが、
1925
+ // その時点のドキュメントはまだ最初の描画を終えていない。そこで開始した遷移は
1926
+ // Chromium で更新コールバックが呼ばれないまま留まることがあり、_initialize が
1927
+ // 永久に解決しなくなる(ページが白いまま・path が空のまま)。実ブラウザでのみ
1928
+ // 再現するので e2e/tests/view-transition.spec.ts が唯一の回帰テストになる。
1929
+ let pending;
1930
+ if (lastRoutes.length === 0) {
1931
+ mutate();
1932
+ }
1933
+ else {
1934
+ pending = runTransition("router", mutate);
1935
+ }
1936
+ // arbiter が居ないときは同期適用済みで undefined が返る。そこで await すると
1937
+ // 無条件に 1 tick 増えて、既存のナビゲーション完了タイミングが変わってしまう。
1938
+ if (pending !== undefined) {
1939
+ await pending;
1437
1940
  }
1438
1941
  return true;
1439
1942
  }
1440
1943
 
1441
- async function applyRoute(routerNode, outlet, fullPath, lastPath) {
1442
- const basename = routerNode.basename;
1443
- let sliced = fullPath;
1444
- if (basename !== "") {
1445
- if (fullPath === basename) {
1446
- sliced = "";
1447
- }
1448
- else if (fullPath.startsWith(basename + "/")) {
1449
- sliced = fullPath.slice(basename.length);
1450
- }
1944
+ /**
1945
+ * ルートを適用する。返り値は committed — guard 拒否(GuardCancel)で中断された
1946
+ * 場合のみ false。呼び出し側はこれで commit 後の処理(フォールバック経路の
1947
+ * スクロール等)をゲートできる(docs/a11y-design.md §3-2 / D4)。
1948
+ *
1949
+ * `search` は現在 URL のクエリ("?k=v" 形式または "")。隠れた `window.location`
1950
+ * 読みにせず呼び出し元が明示供給する(docs/router-state-contract-design.md §3.6 —
1951
+ * テスト容易性と権威の明示のため)。
1952
+ */
1953
+ async function applyRoute(routerNode, outlet, fullPath, lastPath, search = "") {
1954
+ const path = sliceBasename(fullPath, routerNode.basename);
1955
+ // same-match 高速パス(docs/router-state-contract-design.md §4.4)。
1956
+ // guard はルートへの進入を守るものであり、クエリ変化は進入ではない —
1957
+ // matchRoutes / guard 相 / showRouteContent をスキップし、transition-runner にも
1958
+ // 渡さず(DOM mutation が無いのに arbiter へ空遷移を依頼しない)、a11y の
1959
+ // 再アナウンスもしない。search を commit し、§3.4 の規範で発火する
1960
+ // (この場合 search-changed のみが発火し得る)。
1961
+ if (routerNode.isSameMatch(path)) {
1962
+ routerNode.commitNavigation({
1963
+ params: routerNode.params,
1964
+ typedParams: routerNode.typedParams,
1965
+ routeName: routerNode.routeName,
1966
+ search,
1967
+ path,
1968
+ });
1969
+ return true;
1451
1970
  }
1452
- // when fullPath === basename (e.g. "/app"), treat it as root "/"
1453
- const path = sliced === "" ? "/" : sliced;
1454
1971
  let matchResult = matchRoutes(routerNode, path);
1455
1972
  if (!matchResult) {
1456
1973
  if (routerNode.fallbackRoute) {
@@ -1472,10 +1989,25 @@ async function applyRoute(routerNode, outlet, fullPath, lastPath) {
1472
1989
  // GuardCancel により中断された場合は state を更新しない
1473
1990
  // (拒否されたパスでの wcs-router:path-changed 発火を防ぐため)
1474
1991
  if (!committed)
1475
- return;
1992
+ return false;
1476
1993
  // if successful, update router and outlet state
1477
- routerNode.path = path;
1994
+ // routeName は最深マッチの name。fallback 時は fallback ルートの name(D8)。
1995
+ routerNode.commitNavigation({
1996
+ params: matchResult.params,
1997
+ typedParams: matchResult.typedParams,
1998
+ routeName: matchResult.routes[matchResult.routes.length - 1]?.name ?? "",
1999
+ search,
2000
+ path,
2001
+ });
1478
2002
  outlet.lastRoutes = matchResult.routes;
2003
+ // オプトインの focus/announce は commit 直後・mutate() の外で適用する(D3)。
2004
+ // 初回描画(lastRoutes が空)では動かない — ページロードはブラウザの担当で、
2005
+ // view-transition の「初回は包まない」と同じ判定・同じ理由(§3-5)。
2006
+ // guard 拒否は上の return false で既に抜けている(D4)。
2007
+ if (lastRoutes.length > 0) {
2008
+ applyA11yPolicies(routerNode, matchResult);
2009
+ }
2010
+ return true;
1479
2011
  }
1480
2012
 
1481
2013
  function getNavigation() {
@@ -1489,68 +2021,62 @@ function getNavigation() {
1489
2021
  return nav;
1490
2022
  }
1491
2023
 
1492
- // basenameFileExtensions ベースの正規表現をキャッシュ(config 変更時のみ再生成)。
1493
- let _cachedExtensions = null;
1494
- let _cachedExtPattern = null;
1495
2024
  /**
1496
- * config.basenameFileExtensions から拡張子削除用の正規表現を生成(キャッシュ付き)。
1497
- * config 変更が検知された場合のみ再生成する。
2025
+ * searchParams の正規化(docs/router-state-contract-design.md §3.5)。
2026
+ *
2027
+ * - 読み取り形状は `Record<string, string>`。`URLSearchParams` の生ハンドルは
2028
+ * 露出しない(生ハンドルを state に入れない規範)。
2029
+ * - キー重複(`?tag=a&tag=b`)は **last-wins**。
2030
+ * - 値のデコードは `URLSearchParams` に委ねる(`+` → space を含む)。
2031
+ * - 露出オブジェクトは freeze したスナップショット(消費側の変異は loud failure)。
1498
2032
  */
1499
- function getExtPattern() {
1500
- const exts = config.basenameFileExtensions;
1501
- if (exts.length === 0)
1502
- return null;
1503
- if (_cachedExtensions === exts && _cachedExtPattern) {
1504
- return _cachedExtPattern;
2033
+ function parseSearchParams(search) {
2034
+ const result = {};
2035
+ for (const [key, value] of new URLSearchParams(search)) {
2036
+ result[key] = value;
1505
2037
  }
1506
- _cachedExtensions = exts;
1507
- _cachedExtPattern = new RegExp(`\\/[^/]+(?:${exts.map(e => e.replace(/\./g, '\\.')).join('|')})$`, 'i');
1508
- return _cachedExtPattern;
2038
+ return Object.freeze(result);
1509
2039
  }
1510
2040
  /**
1511
- * URL pathname を route path に正規化する。
1512
- * - 先頭スラッシュを保証
1513
- * - 連続スラッシュを単一化
1514
- * - 末尾のファイル拡張子(例: .html)をディレクトリルートとして扱う
1515
- * - ルート以外の末尾スラッシュを除去
2041
+ * Record の shallow 比較。params の変化判定(§3.3: 文字列値の shallow 比較)と
2042
+ * searchParams の変化判定(§3.5: キーをソートした pair 列の比較 = 順序非依存)に
2043
+ * 共通で使う。
1516
2044
  */
1517
- function normalizePathname(path) {
1518
- let p = path || "/";
1519
- if (!p.startsWith("/"))
1520
- p = "/" + p;
1521
- p = p.replace(/\/{2,}/g, "/");
1522
- const extPattern = getExtPattern();
1523
- if (extPattern) {
1524
- p = p.replace(extPattern, "");
2045
+ function shallowEqualRecords(a, b) {
2046
+ const aKeys = Object.keys(a);
2047
+ const bKeys = Object.keys(b);
2048
+ if (aKeys.length !== bKeys.length)
2049
+ return false;
2050
+ for (const key of aKeys) {
2051
+ if (!Object.prototype.hasOwnProperty.call(b, key))
2052
+ return false;
2053
+ if (a[key] !== b[key])
2054
+ return false;
1525
2055
  }
1526
- if (p === "")
1527
- p = "/";
1528
- if (p.length > 1 && p.endsWith("/"))
1529
- p = p.slice(0, -1);
1530
- return p;
2056
+ return true;
2057
+ }
2058
+
2059
+ function splitUrlTarget(to) {
2060
+ let rest = to;
2061
+ let hash = "";
2062
+ const hashIndex = rest.indexOf("#");
2063
+ if (hashIndex >= 0) {
2064
+ hash = rest.slice(hashIndex);
2065
+ rest = rest.slice(0, hashIndex);
2066
+ }
2067
+ let search = "";
2068
+ const searchIndex = rest.indexOf("?");
2069
+ if (searchIndex >= 0) {
2070
+ search = rest.slice(searchIndex);
2071
+ rest = rest.slice(0, searchIndex);
2072
+ }
2073
+ return { pathname: rest, search, hash };
1531
2074
  }
1532
2075
  /**
1533
- * basename を正規化する。
1534
- * - "" or "/" -> ""
1535
- * - "/app/" -> "/app"
1536
- * - "/app/index.html" -> "/app"
2076
+ * URL 再結合時の search。`?` 単独は「クエリの全消去」の合図なので "" にする。
1537
2077
  */
1538
- function normalizeBasename(path) {
1539
- let p = path || "";
1540
- if (!p)
1541
- return "";
1542
- if (!p.startsWith("/"))
1543
- p = "/" + p;
1544
- p = p.replace(/\/{2,}/g, "/");
1545
- const extPattern = getExtPattern();
1546
- if (extPattern) {
1547
- p = p.replace(extPattern, "");
1548
- }
1549
- if (p.length > 1 && p.endsWith("/"))
1550
- p = p.slice(0, -1);
1551
- if (p === "/")
1552
- return "";
1553
- return p;
2078
+ function effectiveSearch(search) {
2079
+ return search === "?" ? "" : search;
1554
2080
  }
1555
2081
 
1556
2082
  // ===========================================================================
@@ -1595,18 +2121,71 @@ function upgradeProperties(element) {
1595
2121
  }
1596
2122
  }
1597
2123
 
2124
+ /**
2125
+ * SSR モード判定。@wcstack/server の renderToString がレンダリング中の document
2126
+ * 要素へ `data-wcs-server` 属性を設定する。state 側(packages/state/src/config.ts の
2127
+ * inSsr)と同じ規約 — パッケージ間 import はせず、属性規約で合意する
2128
+ * (docs/ssr-router-design.md §3.2)。
2129
+ *
2130
+ * キャッシュしない: SSR モードはプロセスの属性ではなく「現在の document」の属性。
2131
+ * サーバーレンダリングの後、同一プロセスでクライアント側の起動(SSR→hydrate の
2132
+ * e2e)が走り得るため、呼び出しごとに現在の document を見る。
2133
+ */
2134
+ function inSsr() {
2135
+ const html = document.documentElement;
2136
+ return html ? html.hasAttribute('data-wcs-server') : false;
2137
+ }
2138
+
2139
+ /**
2140
+ * SSR ハイドレーションマーカー(docs/ssr-router-design.md §3.3 / §4)。
2141
+ *
2142
+ * サーバー(_renderForSsr)が書き、クライアント(_hydrateFromSsr / Link の採用)が
2143
+ * 読む。キーは route の absolutePath — placeholder の UUID はパースごとに再生成され
2144
+ * サーバーとクライアントで一致しないため、同一 template から決定的に導ける
2145
+ * absolutePath だけが突合キーになれる。
2146
+ */
2147
+ /** サーバー描画済み outlet の目印(要素属性) */
2148
+ const SSR_OUTLET_ATTR = 'data-wcs-ssr';
2149
+ /** Link がサーバーで生成した anchor の目印(要素属性)。クライアントが採用して外す */
2150
+ const SSR_LINK_ATTR = 'data-wcs-ssr-link';
2151
+ /** route placeholder コメントの安定キー形式(`@@wcs-route-ph:<absolutePath>`) */
2152
+ const ROUTE_PH_PREFIX = '@@wcs-route-ph:';
2153
+ /** 表示中ルート内容の開始マーカー(`@@wcs-route-start:<absolutePath>`) */
2154
+ const ROUTE_START_PREFIX = '@@wcs-route-start:';
2155
+ /** 表示中ルート内容の終了マーカー(`@@wcs-route-end:<absolutePath>`) */
2156
+ const ROUTE_END_PREFIX = '@@wcs-route-end:';
2157
+
2158
+ const EMPTY_RECORD = Object.freeze({});
1598
2159
  /**
1599
2160
  * AppRoutes - Root component for @wcstack/router
1600
2161
  *
1601
2162
  * Container element that manages route definitions and navigation.
1602
2163
  */
1603
2164
  class Router extends HTMLElement {
2165
+ /**
2166
+ * @wcstack/server の待機プロトコル(docs/ssr-router-design.md §3.2)。
2167
+ * renderToString はこのフラグを持つ要素の connectedCallbackPromise を待って
2168
+ * からシリアライズする — 初期ルート適用の完了がサーバー出力に反映される。
2169
+ */
2170
+ static hasConnectedCallbackPromise = true;
1604
2171
  static wcBindable = {
1605
2172
  protocol: "wc-bindable",
1606
2173
  version: 1,
1607
2174
  properties: [
1608
2175
  { name: "navigateUrl", event: "wcs-router:navigate-url-changed", semantics: "state" },
2176
+ { name: "replaceUrl", event: "wcs-router:replace-url-changed", semantics: "state" },
1609
2177
  { name: "path", event: "wcs-router:path-changed", semantics: "state" },
2178
+ // 観測面(docs/router-state-contract-design.md §3.1)— output-only
2179
+ // (properties のみ・inputs に無い)。state 側の既存規範により authority は
2180
+ // element(attach 時に要素の現在値を読む)となり、state→element 書き込みは
2181
+ // 恒久ブロックされる。params-changed の detail は { params, typedParams }
2182
+ // なので、両プロパティとも getter で分派する。
2183
+ { name: "params", event: "wcs-router:params-changed", semantics: "state",
2184
+ getter: (e) => e.detail.params },
2185
+ { name: "typedParams", event: "wcs-router:params-changed", semantics: "state",
2186
+ getter: (e) => e.detail.typedParams },
2187
+ { name: "searchParams", event: "wcs-router:search-changed", semantics: "state" },
2188
+ { name: "routeName", event: "wcs-router:route-name-changed", semantics: "state" },
1610
2189
  ],
1611
2190
  // `navigateUrl` は observable output であると同時に settable な書き込み面でもある
1612
2191
  // (setter が navigate() を起動し、完了後に自分で null へ戻す)。properties にだけ
@@ -1616,9 +2195,11 @@ class Router extends HTMLElement {
1616
2195
  inputs: [
1617
2196
  { name: "basename", attribute: "basename" },
1618
2197
  { name: "navigateUrl" },
2198
+ { name: "replaceUrl" },
1619
2199
  ],
1620
2200
  commands: [
1621
2201
  { name: "navigate", async: true },
2202
+ { name: "replace", async: true },
1622
2203
  ],
1623
2204
  };
1624
2205
  _outlet = null;
@@ -1631,10 +2212,65 @@ class Router extends HTMLElement {
1631
2212
  _listeningPopState = false;
1632
2213
  _listeningNavigate = false;
1633
2214
  _navigateUrl = null;
2215
+ _replaceUrl = null;
1634
2216
  _disconnectedDuringInit = false;
1635
2217
  _initializing = false;
2218
+ _a11yRegion = null;
2219
+ // 観測面の内部値(docs/router-state-contract-design.md §3)。露出オブジェクトは
2220
+ // frozen スナップショット — params は router の所有物であり、消費側の変異は
2221
+ // silent corruption ではなく loud failure にする。
2222
+ _params = EMPTY_RECORD;
2223
+ _typedParams = EMPTY_RECORD;
2224
+ _searchParams = EMPTY_RECORD;
2225
+ _routeName = '';
2226
+ /** 最初の成功 commit を通過したか(§4.4 の初回ガード) */
2227
+ _hasCommitted = false;
2228
+ _connectedCallbackPromise;
2229
+ _resolveConnectedCallback = null;
2230
+ _rejectConnectedCallback = null;
1636
2231
  constructor() {
1637
2232
  super();
2233
+ this._connectedCallbackPromise = new Promise((resolve, reject) => {
2234
+ this._resolveConnectedCallback = resolve;
2235
+ this._rejectConnectedCallback = reject;
2236
+ });
2237
+ }
2238
+ get connectedCallbackPromise() {
2239
+ return this._connectedCallbackPromise;
2240
+ }
2241
+ get a11yRegion() {
2242
+ return this._a11yRegion;
2243
+ }
2244
+ get focusPolicy() {
2245
+ return this.getAttribute('focus');
2246
+ }
2247
+ get announcePolicy() {
2248
+ return this.getAttribute('announce');
2249
+ }
2250
+ /**
2251
+ * `announce=` 用 live region を <wcs-router> 直下に空のまま用意する
2252
+ * (docs/a11y-design.md §3-4)。
2253
+ * - 告知より**前**から DOM に居ないと SR に読まれないため、announce 時の
2254
+ * 遅延生成はできない。
2255
+ * - outlet 配下はナビゲーションごとに破棄され、オプトインで shadow root にも
2256
+ * なる。document.body 直下は router の寿命を超えて漏れ、マルチ router で
2257
+ * 競合する。よって配置は <wcs-router> 直下の一択。
2258
+ * - display:none は live region を殺すため、sr-only クリップで隠す。
2259
+ */
2260
+ _ensureA11yRegion() {
2261
+ if (this._a11yRegion !== null) {
2262
+ return;
2263
+ }
2264
+ const region = document.createElement('div');
2265
+ region.setAttribute('role', 'status');
2266
+ region.style.position = 'absolute';
2267
+ region.style.width = '1px';
2268
+ region.style.height = '1px';
2269
+ region.style.overflow = 'hidden';
2270
+ region.style.clipPath = 'inset(50%)';
2271
+ region.style.whiteSpace = 'nowrap';
2272
+ this.appendChild(region);
2273
+ this._a11yRegion = region;
1638
2274
  }
1639
2275
  /**
1640
2276
  * Normalize a URL pathname to a route path.
@@ -1728,6 +2364,92 @@ class Router extends HTMLElement {
1728
2364
  }));
1729
2365
  }
1730
2366
  }
2367
+ get params() {
2368
+ return this._params;
2369
+ }
2370
+ get typedParams() {
2371
+ return this._typedParams;
2372
+ }
2373
+ get searchParams() {
2374
+ return this._searchParams;
2375
+ }
2376
+ get routeName() {
2377
+ return this._routeName;
2378
+ }
2379
+ /**
2380
+ * same-match 判定(docs/router-state-contract-design.md §4.4)。
2381
+ *
2382
+ * 比較は **basename スライス後の path 同士**(`_path` はスライス後で保存済み)。
2383
+ * 判定が必要な地点は 2 箇所 — `_onNavigateFunc` の intercept オプション決定時と
2384
+ * `applyRoute` の入口分岐 — で、両方がこの単一実装を呼ぶ。
2385
+ *
2386
+ * 初回ガード: 最初の成功 commit より前には適用しない。初期 `_path = ""` が
2387
+ * 正規化後パス(常に `/` 始まり)と一致しないため偶然安全だが、
2388
+ * normalizePathname の実装詳細に依存させず規範として明示する。
2389
+ */
2390
+ isSameMatch(path) {
2391
+ if (!this._hasCommitted)
2392
+ return false;
2393
+ return this._path === path;
2394
+ }
2395
+ /**
2396
+ * 観測面のコミットと発火(docs/router-state-contract-design.md §3.4)。
2397
+ *
2398
+ * 全内部値を先にコミットし、その後で初めてイベントを発火する — どのイベントの
2399
+ * リスナーから要素プロパティを読んでも、遷移後スナップショットの一貫した値が
2400
+ * 見える。発火順序は params → route-name → search → path。`path` を最後に
2401
+ * 置くのは、既存例で `path` が「ナビゲーション完了」の信号として使われている
2402
+ * ため。各イベントは変化した commit のみ発火する。
2403
+ */
2404
+ commitNavigation(commit) {
2405
+ const nextParams = Object.freeze({ ...commit.params });
2406
+ const nextTypedParams = Object.freeze({ ...commit.typedParams });
2407
+ const nextSearchParams = parseSearchParams(commit.search);
2408
+ const paramsChanged = !shallowEqualRecords(this._params, nextParams);
2409
+ const routeNameChanged = this._routeName !== commit.routeName;
2410
+ const searchChanged = !shallowEqualRecords(this._searchParams, nextSearchParams);
2411
+ const pathChanged = this._path !== commit.path;
2412
+ // --- 先に全内部値をコミット ---
2413
+ // 変化した面だけ差し替える(ナビゲーションごとに新しいオブジェクトになるので
2414
+ // state の same-value guard を正しく通過する。不変の面は同一性を保つ)。
2415
+ if (paramsChanged) {
2416
+ this._params = nextParams;
2417
+ this._typedParams = nextTypedParams;
2418
+ }
2419
+ if (routeNameChanged) {
2420
+ this._routeName = commit.routeName;
2421
+ }
2422
+ if (searchChanged) {
2423
+ this._searchParams = nextSearchParams;
2424
+ }
2425
+ this._path = commit.path;
2426
+ this._hasCommitted = true;
2427
+ // --- その後で発火(順序規範: params → route-name → search → path) ---
2428
+ if (paramsChanged) {
2429
+ this.dispatchEvent(new CustomEvent("wcs-router:params-changed", {
2430
+ detail: { params: this._params, typedParams: this._typedParams },
2431
+ bubbles: true,
2432
+ }));
2433
+ }
2434
+ if (routeNameChanged) {
2435
+ this.dispatchEvent(new CustomEvent("wcs-router:route-name-changed", {
2436
+ detail: this._routeName,
2437
+ bubbles: true,
2438
+ }));
2439
+ }
2440
+ if (searchChanged) {
2441
+ this.dispatchEvent(new CustomEvent("wcs-router:search-changed", {
2442
+ detail: this._searchParams,
2443
+ bubbles: true,
2444
+ }));
2445
+ }
2446
+ if (pathChanged) {
2447
+ this.dispatchEvent(new CustomEvent("wcs-router:path-changed", {
2448
+ detail: commit.path,
2449
+ bubbles: true,
2450
+ }));
2451
+ }
2452
+ }
1731
2453
  get fallbackRoute() {
1732
2454
  return this._fallbackRoute;
1733
2455
  }
@@ -1757,21 +2479,85 @@ class Router extends HTMLElement {
1757
2479
  }));
1758
2480
  });
1759
2481
  }
2482
+ get replaceUrl() {
2483
+ return this._replaceUrl;
2484
+ }
2485
+ /**
2486
+ * navigateUrl と完全同型の null-idle transient(docs/router-state-contract-design.md §4.2)。
2487
+ * null は待機・書き込みで replace() を起動・完了で自己リセットして
2488
+ * `wcs-router:replace-url-changed`(detail: null)を発火する。
2489
+ */
2490
+ set replaceUrl(value) {
2491
+ if (value === null || value === undefined || value === "")
2492
+ return;
2493
+ // 既に同一 URL の replace 中なら再起動しない
2494
+ if (this._replaceUrl === value)
2495
+ return;
2496
+ this._replaceUrl = value;
2497
+ this.replace(value).catch((err) => {
2498
+ console.error(`${config.tagNames.router} replace failed:`, err);
2499
+ }).finally(() => {
2500
+ this._replaceUrl = null;
2501
+ this.dispatchEvent(new CustomEvent("wcs-router:replace-url-changed", {
2502
+ detail: null,
2503
+ bubbles: true,
2504
+ }));
2505
+ });
2506
+ }
1760
2507
  async navigate(path) {
1761
- const fullPath = this._joinInternalPath(this._basename, path);
2508
+ await this._performNavigation(path, false);
2509
+ }
2510
+ /**
2511
+ * navigateUrl(push)の対になる replace 遷移(docs/router-state-contract-design.md §4.2)。
2512
+ * Navigation API では `navigation.navigate(url, { history: "replace" })`、
2513
+ * フォールバックでは `history.replaceState` + applyRoute + 通知。
2514
+ */
2515
+ async replace(path) {
2516
+ await this._performNavigation(path, true);
2517
+ }
2518
+ async _performNavigation(path, replace) {
2519
+ // クエリ / ハッシュ込みターゲットの受理(docs/router-state-contract-design.md §4.1)。
2520
+ // normalizePathname / basename 結合は pathname にのみ適用し、search / hash は
2521
+ // 再結合して URL に渡す。pathname 空("?k=v" / "?" / "#x")は現在の pathname を
2522
+ // 維持する。search / hash まで空(navigate(""))は従来どおりルート扱い。
2523
+ const target = splitUrlTarget(path);
2524
+ const fullPath = target.pathname === "" && (target.search !== "" || target.hash !== "")
2525
+ ? window.location.pathname
2526
+ : this._joinInternalPath(this._basename, target.pathname);
2527
+ const url = fullPath + effectiveSearch(target.search) + target.hash;
1762
2528
  const navigation = getNavigation();
1763
2529
  if (navigation?.navigate) {
1764
2530
  // Navigation API は { committed, finished } を返す。
1765
2531
  // finished を await することで、navigate() の Promise が
1766
- // 実際のナビゲーション完了まで pending となり、_navigateUrl の
1767
- // 二重 navigate ガード (setter 内 `if (this._navigateUrl === value)`) が
1768
- // 適切な時間ウィンドウで機能するようになる。
2532
+ // 実際のナビゲーション完了まで pending となり、_navigateUrl / _replaceUrl の
2533
+ // 二重起動ガード (setter 内の同一値チェック) が適切な時間ウィンドウで機能する。
1769
2534
  // Polyfill や mock 環境で undefined / 戻り値なしのケースもあるため optional chaining。
1770
- await navigation.navigate(fullPath)?.finished;
2535
+ const result = replace
2536
+ ? navigation.navigate(url, { history: "replace" })
2537
+ : navigation.navigate(url);
2538
+ await result?.finished;
1771
2539
  }
1772
2540
  else {
1773
- history.pushState(null, '', fullPath);
1774
- await applyRoute(this, this.outlet, fullPath, this._path);
2541
+ if (replace) {
2542
+ history.replaceState(null, '', url);
2543
+ }
2544
+ else {
2545
+ history.pushState(null, '', url);
2546
+ }
2547
+ // セグメントマッチにはクエリ・ハッシュを渡さない(渡すと 404 に落ちる —
2548
+ // §1.1 欠陥 6 の修理)。search は明示引数で供給する(§3.6)。
2549
+ const normalizedFullPath = this._normalizePathname(fullPath);
2550
+ const sameMatch = this.isSameMatch(sliceBasename(normalizedFullPath, this._basename));
2551
+ const committed = await applyRoute(this, this.outlet, normalizedFullPath, this._path, effectiveSearch(target.search));
2552
+ // 修理・既定オン(docs/a11y-design.md §3-2): Navigation API 経路の仕様既定
2553
+ // (scroll: "after-transition" — push はトップへ)とフォールバック経路を揃える。
2554
+ // guard 拒否(committed === false)では動かさない。_onPopState 側は
2555
+ // history.scrollRestoration によるブラウザ復元が正解なので、決してスクロールしない。
2556
+ // same-match(クエリのみ遷移)でも動かさない — 1 打鍵ごとにトップへ戻る事故の
2557
+ // 防止(docs/router-state-contract-design.md §4.4)。
2558
+ if (committed && !sameMatch) {
2559
+ window.scrollTo(0, 0);
2560
+ }
1775
2561
  this._notifyLocationChange();
1776
2562
  }
1777
2563
  }
@@ -1795,17 +2581,37 @@ class Router extends HTMLElement {
1795
2581
  // basename 配下でない URL は無視(マルチ Router 対応)
1796
2582
  if (!this._isOwnPath(fullPath))
1797
2583
  return;
2584
+ // same-match 判定は applyRoute 内の分岐と同じ共有実装(§4.4: スライス後比較)。
2585
+ // intercept オプションは applyRoute 実行前に決める必要があるためここでも判定する。
2586
+ const sameMatch = this.isSameMatch(sliceBasename(fullPath, this._basename));
2587
+ // scroll は navigationType で分岐する: push / replace の same-match は "manual"
2588
+ // (検索ボックスにバインドした書き込みの 1 打鍵ごとにスクロールがトップへ戻る
2589
+ // 事故の防止)。traverse(戻る/進む)は仕様既定 = ブラウザのスクロール位置復元を
2590
+ // 維持する — ?page=2 から戻る操作でスクロールが固定される事故を防ぐ。
2591
+ const sameMatchScrollManual = sameMatch &&
2592
+ (navEvent.navigationType === "push" || navEvent.navigationType === "replace");
2593
+ const search = url.search;
1798
2594
  const routesNode = this;
1799
2595
  navEvent.intercept({
1800
2596
  handler: async () => {
1801
2597
  try {
1802
- await applyRoute(routesNode, routesNode.outlet, fullPath, routesNode.path);
2598
+ await applyRoute(routesNode, routesNode.outlet, fullPath, routesNode.path, search);
1803
2599
  }
1804
2600
  catch (err) {
1805
2601
  console.error(`${config.tagNames.router} applyRoute failed:`, err);
1806
2602
  throw err;
1807
2603
  }
1808
2604
  },
2605
+ // 仕様既定の明示(same-match 以外は挙動変更なし)。scroll: push はトップへ /
2606
+ // traverse はスクロール位置復元、focusReset: [autofocus] か body へ。この委譲が
2607
+ // router のアクセシビリティ契約であり、ここを "manual" に変える変更は
2608
+ // 契約の変更にあたる(docs/a11y-design.md §3-1)。same-match の扱いは
2609
+ // docs/router-state-contract-design.md §4.4 / D6b。
2610
+ scroll: sameMatchScrollManual ? "manual" : "after-transition",
2611
+ // focus= 指定時のみ manual。渡さないと router のフォーカス移動とブラウザの
2612
+ // after-transition リセットが二重処理になる(docs/a11y-design.md §3-5)。
2613
+ // same-match は常に manual — 1 打鍵ごとにフォーカスが body へ飛ぶ事故の防止。
2614
+ focusReset: sameMatch || routesNode.focusPolicy !== null ? "manual" : "after-transition",
1809
2615
  });
1810
2616
  }
1811
2617
  _onNavigate = this._onNavigateFunc.bind(this);
@@ -1815,12 +2621,14 @@ class Router extends HTMLElement {
1815
2621
  // basename 配下でない URL は無視(マルチ Router 対応)
1816
2622
  if (!this._isOwnPath(fullPath))
1817
2623
  return;
1818
- await applyRoute(this, this.outlet, fullPath, this._path);
2624
+ // search は明示引数で供給(§3.6)。mock / 特殊環境で欠ける場合は "" 扱い
2625
+ await applyRoute(this, this.outlet, fullPath, this._path, window.location.search || "");
1819
2626
  this._notifyLocationChange();
1820
2627
  };
1821
2628
  async _initialize() {
1822
2629
  this._initializing = true;
1823
2630
  try {
2631
+ const ssr = inSsr();
1824
2632
  this._basename = this._normalizeBasename(this.getAttribute("basename") || this._getBasename() || "");
1825
2633
  const hasBaseTag = document.querySelector('base[href]') !== null;
1826
2634
  const url = new URL(window.location.href);
@@ -1833,13 +2641,66 @@ class Router extends HTMLElement {
1833
2641
  if (!this._template) {
1834
2642
  raiseError(`${config.tagNames.router} should have a <template> child element.`);
1835
2643
  }
2644
+ // SSR: parse は template.content を破壊的に消費する(route 要素は中身を
2645
+ // 移された抜け殻になり、非 route ノードは fragment へ移動する)。
2646
+ // シリアライズ出力に完全なルート定義を残してクライアントが従来どおり
2647
+ // 起動できるよう、退避してパース後に復元する(docs/ssr-router-design.md §3.2)。
2648
+ const templateSnapshot = ssr ? this._template.content.cloneNode(true) : null;
1836
2649
  const fragment = await parse(this);
1837
- this._outlet.rootNode.appendChild(fragment);
2650
+ // クライアント側でサーバー描画済み outlet(data-wcs-ssr)を見つけたら
2651
+ // 採用(adoption)を試みる。fragment はまだ入れない — 採用が成立すれば
2652
+ // fresh クローンは不要で、失敗したときだけ従来どおり流し込む(§4)。
2653
+ const adoptable = !ssr &&
2654
+ this._outlet.hasAttribute(SSR_OUTLET_ATTR);
2655
+ if (!adoptable) {
2656
+ this._outlet.rootNode.appendChild(fragment);
2657
+ }
2658
+ if (templateSnapshot !== null) {
2659
+ const content = this._template.content;
2660
+ while (content.firstChild) {
2661
+ content.removeChild(content.firstChild);
2662
+ }
2663
+ content.appendChild(templateSnapshot);
2664
+ }
1838
2665
  if (this.routeChildNodes.length === 0) {
1839
2666
  raiseError(`${config.tagNames.router} has no route definitions.`);
1840
2667
  }
2668
+ if (!ssr) {
2669
+ // 最初のナビゲーションより十分前に accessibility tree へ載せておく。
2670
+ // サーバーでは作らない — 初回描画はアナウンスしない既存規則により不要で、
2671
+ // 作るとクライアントの初期化が二重生成する(docs/ssr-router-design.md §3.2)。
2672
+ this._ensureA11yRegion();
2673
+ }
1841
2674
  const fullPath = this._normalizePathname(window.location.pathname);
1842
- await applyRoute(this, this.outlet, fullPath, this._path);
2675
+ if (ssr) {
2676
+ await this._renderForSsr(fullPath);
2677
+ this._initialized = true;
2678
+ return;
2679
+ }
2680
+ if (adoptable) {
2681
+ const hydrated = await this._hydrateFromSsr(fullPath);
2682
+ if (hydrated) {
2683
+ this._notifyLocationChange();
2684
+ this._initialized = true;
2685
+ return;
2686
+ }
2687
+ // 採用不能: サーバー DOM を破棄して従来経路で描き直す。安全側は常に CSR
2688
+ // (state の hydrateBindings →失敗→ buildBindings と同じ二段構え、§2-3)。
2689
+ const outletEl = this._outlet;
2690
+ outletEl.removeAttribute(SSR_OUTLET_ATTR);
2691
+ const rootNode = this._outlet.rootNode;
2692
+ while (rootNode.firstChild) {
2693
+ rootNode.removeChild(rootNode.firstChild);
2694
+ }
2695
+ rootNode.appendChild(fragment);
2696
+ }
2697
+ await applyRoute(this, this.outlet, fullPath, this._path, window.location.search || "");
2698
+ if (adoptable) {
2699
+ // 描き直したノードを binder へ差し出す。state がサーバー DOM を既に
2700
+ // ハイドレート済み(初期走査完了後)の場合、破棄と同時にそのバインドは
2701
+ // 死んでおり、「初期描画は走査時に DOM に居る」前提も崩れているため。
2702
+ this._offerInitialContentToBinder();
2703
+ }
1843
2704
  this._notifyLocationChange();
1844
2705
  this._initialized = true;
1845
2706
  }
@@ -1847,18 +2708,325 @@ class Router extends HTMLElement {
1847
2708
  this._initializing = false;
1848
2709
  }
1849
2710
  }
2711
+ /**
2712
+ * サーバー描画済み outlet の採用(docs/ssr-router-design.md §4)。
2713
+ *
2714
+ * 検証(一意な absolutePath・マーカーの整合・現在 URL のマッチとの一致)を
2715
+ * **すべて DOM 変更の前に**行い、途中で断念しても半採用状態を残さない。
2716
+ * 成立すれば DOM 変更ゼロで「すでにナビゲート済み」の状態を確立する —
2717
+ * state がハイドレートしたバインディングは採用ノード上で生きたままになる。
2718
+ *
2719
+ * @returns 採用が成立した場合 true。false は呼び出し側(_initialize)が
2720
+ * サーバー DOM を破棄して従来描画にフォールバックする。
2721
+ */
2722
+ async _hydrateFromSsr(fullPath) {
2723
+ const outletRoot = this.outlet.rootNode;
2724
+ // --- 検証相 ---
2725
+ // absolutePath → route の一意対応。重複定義(parse が警告するケース)は
2726
+ // マーカーの突合キーが曖昧になるため採用不能
2727
+ const routesByPath = new Map();
2728
+ let duplicated = false;
2729
+ this._forEachRoute((route) => {
2730
+ if (routesByPath.has(route.absolutePath)) {
2731
+ duplicated = true;
2732
+ return;
2733
+ }
2734
+ routesByPath.set(route.absolutePath, route);
2735
+ });
2736
+ if (duplicated)
2737
+ return false;
2738
+ // layout は採用の初版スコープ外 — slot 投影の状態はマーカーだけでは
2739
+ // 再構築できない(§4)。検出したらフォールバック
2740
+ if (outletRoot.querySelector(config.tagNames.layoutOutlet) !== null) {
2741
+ return false;
2742
+ }
2743
+ // マーカー走査(文書順の添字も記録し、start < end と範囲交差の検証に使う)
2744
+ const phByPath = new Map();
2745
+ const startByPath = new Map();
2746
+ const endByPath = new Map();
2747
+ const walker = document.createTreeWalker(outletRoot, NodeFilter.SHOW_COMMENT);
2748
+ let index = 0;
2749
+ while (walker.nextNode()) {
2750
+ const comment = walker.currentNode;
2751
+ const data = comment.data;
2752
+ index++;
2753
+ if (data.startsWith(ROUTE_PH_PREFIX)) {
2754
+ const key = data.slice(ROUTE_PH_PREFIX.length);
2755
+ if (phByPath.has(key) || !routesByPath.has(key))
2756
+ return false;
2757
+ phByPath.set(key, comment);
2758
+ }
2759
+ else if (data.startsWith(ROUTE_START_PREFIX)) {
2760
+ const key = data.slice(ROUTE_START_PREFIX.length);
2761
+ if (startByPath.has(key) || !routesByPath.has(key))
2762
+ return false;
2763
+ startByPath.set(key, { comment, index });
2764
+ }
2765
+ else if (data.startsWith(ROUTE_END_PREFIX)) {
2766
+ const key = data.slice(ROUTE_END_PREFIX.length);
2767
+ if (endByPath.has(key) || !routesByPath.has(key))
2768
+ return false;
2769
+ endByPath.set(key, { comment, index });
2770
+ }
2771
+ }
2772
+ // start / end は対で、同じ親の下で start が先。範囲同士は交差しない
2773
+ // (交差していると内容収集の兄弟走査が他ルートの領域へはみ出す)
2774
+ if (startByPath.size !== endByPath.size)
2775
+ return false;
2776
+ const ranges = [];
2777
+ for (const [key, start] of startByPath) {
2778
+ const end = endByPath.get(key);
2779
+ if (!end)
2780
+ return false;
2781
+ if (end.comment.parentNode !== start.comment.parentNode)
2782
+ return false;
2783
+ if (end.index <= start.index)
2784
+ return false;
2785
+ ranges.push({ start: start.index, end: end.index });
2786
+ }
2787
+ for (const a of ranges) {
2788
+ for (const b of ranges) {
2789
+ if (a.start < b.start && b.start < a.end && a.end < b.end)
2790
+ return false;
2791
+ }
2792
+ }
2793
+ // 現在 URL のマッチとマーカー集合の一致検証。サーバーが描いた集合と
2794
+ // クライアントが今マッチする集合が違えば、URL か template が変わっている
2795
+ const path = sliceBasename(fullPath, this._basename);
2796
+ let matchResult = matchRoutes(this, path);
2797
+ if (!matchResult) {
2798
+ if (this._fallbackRoute === null)
2799
+ return false;
2800
+ matchResult = {
2801
+ routes: [this._fallbackRoute],
2802
+ params: {},
2803
+ typedParams: {},
2804
+ path,
2805
+ lastPath: this._path,
2806
+ };
2807
+ }
2808
+ matchResult.lastPath = this._path;
2809
+ if (matchResult.routes.length !== startByPath.size)
2810
+ return false;
2811
+ for (const route of matchResult.routes) {
2812
+ if (!startByPath.has(route.absolutePath))
2813
+ return false;
2814
+ }
2815
+ // placeholder の集合はサーバー出力の形と**完全一致**でなければならない。
2816
+ // serialize される placeholder = トップレベルルート + 各マッチルートの直接の子。
2817
+ // 不足を許すと、そのルートへの後続ナビゲーションが anchor を失って無言で
2818
+ // 空描画になる。過剰(非活性ルートの子孫の ph)を許すと、再設置がその
2819
+ // placeholder を fresh クローンの内容から奪い、当該ルートを到達不能にする
2820
+ const requiredPh = new Set();
2821
+ for (const route of this.routeChildNodes) {
2822
+ requiredPh.add(route.absolutePath);
2823
+ }
2824
+ for (const matched of matchResult.routes) {
2825
+ for (const child of matched.routeChildNodes) {
2826
+ requiredPh.add(child.absolutePath);
2827
+ }
2828
+ }
2829
+ if (phByPath.size !== requiredPh.size)
2830
+ return false;
2831
+ for (const key of requiredPh) {
2832
+ if (!phByPath.has(key))
2833
+ return false;
2834
+ }
2835
+ // --- 採用相(以後は成立が確定している) ---
2836
+ // placeholder をクライアント側インスタンスへ差し替える。以後のナビゲーションの
2837
+ // anchor がサーバーの位置にそのまま据わる
2838
+ for (const [key, comment] of phByPath) {
2839
+ const route = routesByPath.get(key);
2840
+ comment.parentNode.replaceChild(route.placeHolder, comment);
2841
+ }
2842
+ // 各マッチルートの内容 = 自分の start/end マーカー間の兄弟ノード。ただし:
2843
+ // - 子ルートの範囲(start〜end)は**丸ごと除外**する — CSR で親の childNodeArray に
2844
+ // 入るのは子の placeholder だけで、子の内容は子が所有する(hideRoute の重複
2845
+ // 除去と showRoute の誤再挿入を防ぐ)
2846
+ // - Link が所有する anchor も除外する — CSR では anchor は Link の cc が後から
2847
+ // 生成する Link の所有物で、childNodeArray には決して入らない。入れると
2848
+ // hide → show の往復で Link 自身の anchor 管理と二重になり anchor が重複する
2849
+ for (const route of matchResult.routes) {
2850
+ const start = startByPath.get(route.absolutePath).comment;
2851
+ const end = endByPath.get(route.absolutePath).comment;
2852
+ const nodes = [];
2853
+ const linkOwnedAnchors = new Set();
2854
+ let node = start.nextSibling;
2855
+ while (node !== null && node !== end) {
2856
+ if (node.nodeType === Node.COMMENT_NODE) {
2857
+ const data = node.data;
2858
+ if (data.startsWith(ROUTE_START_PREFIX)) {
2859
+ const childKey = data.slice(ROUTE_START_PREFIX.length);
2860
+ node = endByPath.get(childKey).comment.nextSibling;
2861
+ continue;
2862
+ }
2863
+ }
2864
+ if (linkOwnedAnchors.has(node)) {
2865
+ node = node.nextSibling;
2866
+ continue;
2867
+ }
2868
+ if (node.nodeType === Node.ELEMENT_NODE &&
2869
+ node.tagName.toLowerCase() === config.tagNames.link) {
2870
+ // anchor は host より後ろに居るので、先に登録してから host を収集する
2871
+ const anchor = node.anchorElement;
2872
+ if (anchor !== null) {
2873
+ linkOwnedAnchors.add(anchor);
2874
+ }
2875
+ }
2876
+ nodes.push(node);
2877
+ node = node.nextSibling;
2878
+ }
2879
+ route.adoptChildNodes(nodes);
2880
+ }
2881
+ // マーカー除去と目印の撤去
2882
+ for (const { comment } of startByPath.values())
2883
+ comment.remove();
2884
+ for (const { comment } of endByPath.values())
2885
+ comment.remove();
2886
+ this.outlet.removeAttribute(SSR_OUTLET_ATTR);
2887
+ // 表示済み状態の確立。内容は既に見えているので挿入はしない — パラメータ
2888
+ // 配送(setParams / data-bind / active イベント)だけ CSR と同じ規則で行う。
2889
+ // lastRoutes を guard より先に立てるのは、guard 拒否後の fallback 遷移が
2890
+ // 採用済み内容を hideRoute できるようにするため(CSR と異なり、内容は
2891
+ // guard の結果を待たずにサーバーが既に見せている)
2892
+ for (const route of matchResult.routes) {
2893
+ assignRouteParams(route, matchResult);
2894
+ }
2895
+ this.outlet.lastRoutes = matchResult.routes;
2896
+ // guard 相 — 採用はレンダリング最適化であって認可のスキップではない(§4-3)。
2897
+ // 自前のサーバー出力は guard 付きルートを描かない(§2-4)ため通常は素通り
2898
+ // するが、手書きや他システム由来の SSR HTML に対する防衛として実行する
2899
+ if (!(await runGuardPhase(this, matchResult))) {
2900
+ // fallback へのナビゲーションが microtask で予約済み。lastRoutes は
2901
+ // 立っているので、その遷移が採用済み内容を隠す。commit はしない
2902
+ //(拒否されたパスでの path-changed 発火を防ぐ — applyRoute と同じ規範)
2903
+ return true;
2904
+ }
2905
+ // 観測面の commit(applyRoute と同じ規範・同じ順序)。
2906
+ // routes はマッチ結果か [fallback] で必ず非空(検証相で確定済み)
2907
+ this.commitNavigation({
2908
+ params: matchResult.params,
2909
+ typedParams: matchResult.typedParams,
2910
+ routeName: matchResult.routes[matchResult.routes.length - 1].name,
2911
+ search: window.location.search || "",
2912
+ path,
2913
+ });
2914
+ return true;
2915
+ }
2916
+ /**
2917
+ * SSR モードの初期ルート描画(docs/ssr-router-design.md §3.2)。
2918
+ * 初回描画は transition arbiter に渡らない既存規則(showRouteContent)により
2919
+ * 常に同期適用される。navigate / popstate リスナ・a11y region・
2920
+ * `wcs:navigate` 通知はサーバーでは不要(connectedCallback 側で登録しない)。
2921
+ */
2922
+ async _renderForSsr(fullPath) {
2923
+ // guard バリア: guard 付きルートを含むマッチはサーバーで描かない(§2-4)。
2924
+ // guard は進入を守る認可点で、サーバーには判断材料(cookie 等)を渡す設計が
2925
+ // 無い。outlet を空・マーカー無しのまま返し、クライアントが従来どおり guard を
2926
+ // 実行して描く。ハンドラのロードは待たず属性の有無(hasGuard)で判定する。
2927
+ const path = sliceBasename(fullPath, this._basename);
2928
+ const matchResult = matchRoutes(this, path);
2929
+ const routes = matchResult?.routes
2930
+ ?? (this._fallbackRoute !== null ? [this._fallbackRoute] : []);
2931
+ if (routes.length === 0) {
2932
+ // マッチ無しかつ fallback 無し: クライアント(applyRoute)と同じ loud failure
2933
+ raiseError(`${config.tagNames.router} No route matched for path: ${path}`);
2934
+ }
2935
+ if (routes.some((route) => route.hasGuard)) {
2936
+ return;
2937
+ }
2938
+ await applyRoute(this, this.outlet, fullPath, this._path, window.location.search || "");
2939
+ // 表示済みルート内容を binder へ差し出す。クライアント初回描画の
2940
+ // 「state の走査時に既に document に居る」前提はサーバーでは成立しない —
2941
+ // state のロード方式(json 属性は I/O 無し・inline script は dynamic import)と
2942
+ // 文書順次第で、state の初回走査が router の挿入より先に完了し得る。binder は
2943
+ // 「未構築なら保留して構築末尾で引き取る/構築済みなら同期バインド」の両側を
2944
+ // 吸収する。bindRouteContent(showRouteContent 側)は使わない — binder 不在の
2945
+ // 警告はサーバーでは誤誘導になるため、warn 無しで直接差し出す。
2946
+ this._offerInitialContentToBinder();
2947
+ // ハイドレーションマーカー(Phase 2 の入力、§3.3)。キーは placeholder の
2948
+ // UUID ではなく absolutePath — UUID はパースごとに再生成されクライアントと
2949
+ // 一致しない。absolutePath は同一 template から決定的に導ける。
2950
+ // placeholder コメントも同じ理由で安定キーへ書き換える — クライアントの採用は
2951
+ // これを自分の placeholder(クライアント側インスタンス)と差し替える。
2952
+ this._forEachRoute((route) => {
2953
+ route.placeHolder.data = `${ROUTE_PH_PREFIX}${route.absolutePath}`;
2954
+ });
2955
+ this.outlet.setAttribute(SSR_OUTLET_ATTR, '');
2956
+ for (const route of this.outlet.lastRoutes) {
2957
+ // applyRoute 成功後の placeholder は必ず outlet 配下の DOM に居る
2958
+ const parentNode = route.placeHolder.parentNode;
2959
+ const contentNodes = route.childNodeArray;
2960
+ const start = document.createComment(`${ROUTE_START_PREFIX}${route.absolutePath}`);
2961
+ const end = document.createComment(`${ROUTE_END_PREFIX}${route.absolutePath}`);
2962
+ parentNode.insertBefore(start, contentNodes[0] ?? route.placeHolder.nextSibling);
2963
+ const last = contentNodes[contentNodes.length - 1];
2964
+ parentNode.insertBefore(end, last ? last.nextSibling : start.nextSibling);
2965
+ }
2966
+ }
2967
+ /**
2968
+ * 初期表示ルートの内容を binder プロトコルへ差し出す。SSR 描画(_renderForSsr)と
2969
+ * ハイドレーション不能時の描き直し(state が先にハイドレートを終えている可能性が
2970
+ * ある)の両方から呼ぶ。bind() は冪等なので余分に差し出しても壊れない。
2971
+ */
2972
+ _offerInitialContentToBinder() {
2973
+ for (const route of this.outlet.lastRoutes) {
2974
+ for (const node of route.childNodeArray) {
2975
+ if (node.nodeType === Node.ELEMENT_NODE) {
2976
+ bindSubtree(node);
2977
+ }
2978
+ }
2979
+ }
2980
+ }
2981
+ /** ルートツリー全体(ネスト含む)への走査 */
2982
+ _forEachRoute(callback, container = this) {
2983
+ for (const route of container.routeChildNodes) {
2984
+ callback(route);
2985
+ this._forEachRoute(callback, route);
2986
+ }
2987
+ }
1850
2988
  async connectedCallback() {
1851
2989
  // upgrade 前に代入された input を取り込み直す(doc 13 §1.2 / Phase A1)。
1852
2990
  // await より前に同期で行い、初期化が古い値を読まないようにする。
1853
2991
  upgradeProperties(this);
2992
+ // SSR モード(docs/ssr-router-design.md §3.2):
2993
+ // - enable-ssr 無し → サーバーでは一切初期化しない(クライアント専用 = 部分 CSR)
2994
+ // - enable-ssr あり → SSR 初期化のみ。navigate / popstate リスナは登録しない
2995
+ // どちらも connectedCallbackPromise を必ず決着させる — reject を配管しないと
2996
+ // renderToString が mutex を握ったまま無言ハングする(state 側と同じ理由)。
2997
+ if (inSsr()) {
2998
+ try {
2999
+ // happy-dom のパーサは開始タグの時点で connectedCallback を呼ぶため、
3000
+ // この時点では子(<template>)がまだパースされていない。パース自体は
3001
+ // 同期完了するので、1 microtask 譲れば子が揃う。クライアントでは
3002
+ // deferred な auto バンドルの upgrade 時に子が揃っているため不要
3003
+ // (サーバー専用の待避、docs/ssr-router-design.md §3.2)。
3004
+ await Promise.resolve();
3005
+ if (this.hasAttribute('enable-ssr') && !this._initialized) {
3006
+ await this._initialize();
3007
+ }
3008
+ }
3009
+ catch (error) {
3010
+ this._rejectConnectedCallback?.(error);
3011
+ throw error;
3012
+ }
3013
+ this._resolveConnectedCallback?.();
3014
+ return;
3015
+ }
1854
3016
  if (!this._initialized) {
1855
3017
  this._disconnectedDuringInit = false;
1856
3018
  await this._initialize();
1857
3019
  // 初期化中に disconnectedCallback が呼ばれた場合はイベントリスナを登録しない
1858
3020
  if (this._disconnectedDuringInit) {
3021
+ this._resolveConnectedCallback?.();
1859
3022
  return;
1860
3023
  }
1861
3024
  }
3025
+ // 再接続時は disconnect で撤去された live region を回復する
3026
+ // (初回接続では _initialize が生成済みなので no-op)
3027
+ if (this._initialized) {
3028
+ this._ensureA11yRegion();
3029
+ }
1862
3030
  const navigation = getNavigation();
1863
3031
  if (navigation && !this._listeningNavigate) {
1864
3032
  navigation.addEventListener("navigate", this._onNavigate);
@@ -1869,6 +3037,7 @@ class Router extends HTMLElement {
1869
3037
  window.addEventListener("popstate", this._onPopState);
1870
3038
  this._listeningPopState = true;
1871
3039
  }
3040
+ this._resolveConnectedCallback?.();
1872
3041
  }
1873
3042
  disconnectedCallback() {
1874
3043
  // _initialize 中(await 中)に呼ばれた場合はフラグを立ててリスナ登録をスキップさせる
@@ -1883,12 +3052,21 @@ class Router extends HTMLElement {
1883
3052
  window.removeEventListener("popstate", this._onPopState);
1884
3053
  this._listeningPopState = false;
1885
3054
  }
3055
+ // live region は router の寿命に同期して撤去する(body 直下に置かない理由と同根)
3056
+ if (this._a11yRegion !== null) {
3057
+ this._a11yRegion.remove();
3058
+ this._a11yRegion = null;
3059
+ }
1886
3060
  }
1887
3061
  }
1888
3062
 
3063
+ // 生成 anchor へミラーする固定属性(docs/a11y-design.md §5)。`aria-*` は開集合なので
3064
+ // observedAttributes には載せられず、anchor 生成時の一括コピーのみ(接続後の動的
3065
+ // aria-* 変更 — data-wcs バインド経由を含む — には追従しない。README の明記された制限)。
3066
+ const MIRRORED_ATTRIBUTES = ['title', 'rel', 'target', 'download', 'hreflang'];
1889
3067
  class Link extends HTMLElement {
1890
3068
  static get observedAttributes() {
1891
- return ['to'];
3069
+ return ['to', ...MIRRORED_ATTRIBUTES];
1892
3070
  }
1893
3071
  _childNodeArray = [];
1894
3072
  _uuid = getUUID();
@@ -1951,9 +3129,22 @@ class Link extends HTMLElement {
1951
3129
  return base + "/";
1952
3130
  return base + path;
1953
3131
  }
3132
+ /**
3133
+ * router が扱う内部ターゲットか。`/` 始まりに加え、`?` 始まり(クエリのみ遷移 —
3134
+ * docs/router-state-contract-design.md §4.1)も内部ターゲットとして受理する。
3135
+ */
3136
+ _isInternalTarget(path) {
3137
+ return path.startsWith('/') || path.startsWith('?');
3138
+ }
1954
3139
  _setAnchorHref(anchor, path) {
1955
- if (path.startsWith('/')) {
1956
- anchor.href = this._joinInternalPath(this.router.basename, path);
3140
+ if (this._isInternalTarget(path)) {
3141
+ // basename 結合・正規化は pathname にのみ適用し、search / hash は再結合する。
3142
+ // pathname 空(to="?k=v")は「現在 pathname + 指定クエリ」で組み立てる。
3143
+ const { pathname, search, hash } = splitUrlTarget(path);
3144
+ const joined = pathname === ""
3145
+ ? window.location.pathname
3146
+ : this._joinInternalPath(this.router.basename, pathname);
3147
+ anchor.href = joined + effectiveSearch(search) + hash;
1957
3148
  }
1958
3149
  else {
1959
3150
  try {
@@ -1965,6 +3156,32 @@ class Link extends HTMLElement {
1965
3156
  }
1966
3157
  }
1967
3158
  connectedCallback() {
3159
+ if (inSsr()) {
3160
+ // SSR(docs/ssr-router-design.md §3.2 / §4): happy-dom のパーサは開始タグ
3161
+ // 時点で cc を呼ぶため、同期のまま進めると静的 Link の子が空のまま
3162
+ // anchor 化される。パースは同期完了するので 1 microtask 譲る。
3163
+ // renderToString は待機プロトコル要素(state / router)の await で
3164
+ // microtask を消化するため、serialize より先にこの初期化は完了する。
3165
+ queueMicrotask(() => {
3166
+ if (this.isConnected) {
3167
+ this._connect();
3168
+ }
3169
+ });
3170
+ return;
3171
+ }
3172
+ this._connect();
3173
+ }
3174
+ /**
3175
+ * サーバーが生成した目印付き anchor(直後の兄弟)。クライアントの採用対象
3176
+ */
3177
+ _findSsrAnchor() {
3178
+ const next = this.nextElementSibling;
3179
+ if (next !== null && next.tagName === 'A' && next.hasAttribute(SSR_LINK_ATTR)) {
3180
+ return next;
3181
+ }
3182
+ return null;
3183
+ }
3184
+ _connect() {
1968
3185
  if (!this._initialized) {
1969
3186
  this._initialize();
1970
3187
  }
@@ -1973,25 +3190,54 @@ class Link extends HTMLElement {
1973
3190
  // should not happen if connected
1974
3191
  return;
1975
3192
  }
1976
- const nextSibling = this.nextSibling;
1977
- const link = document.createElement('a');
1978
- this._setAnchorHref(link, this._path);
1979
- for (const childNode of this._childNodeArray) {
1980
- link.appendChild(childNode);
1981
- }
1982
- if (nextSibling) {
1983
- parentNode.insertBefore(link, nextSibling);
3193
+ const ssrAnchor = this._findSsrAnchor();
3194
+ let link;
3195
+ if (ssrAnchor !== null) {
3196
+ // SSR 採用: サーバーの anchor をそのまま自分の anchor にする。
3197
+ // 生成経路(cc)でホストの子は anchor へ移動済みなので、子の正本は anchor 側
3198
+ link = ssrAnchor;
3199
+ link.removeAttribute(SSR_LINK_ATTR);
3200
+ this._childNodeArray = Array.from(link.childNodes);
3201
+ // href はクライアント側の解決で引き直す(basename / config の検算)
3202
+ this._setAnchorHref(link, this._path);
1984
3203
  }
1985
3204
  else {
1986
- parentNode.appendChild(link);
3205
+ const nextSibling = this.nextSibling;
3206
+ link = document.createElement('a');
3207
+ this._setAnchorHref(link, this._path);
3208
+ // ホスト属性の転送: `aria-*` prefix + 固定 5 名の一括コピー。
3209
+ // to / style / class は除外 — ホストは display:none であり、class は active 契約を持つ。
3210
+ for (const attr of Array.from(this.attributes)) {
3211
+ if (attr.name.startsWith('aria-') || MIRRORED_ATTRIBUTES.includes(attr.name)) {
3212
+ link.setAttribute(attr.name, attr.value);
3213
+ }
3214
+ }
3215
+ for (const childNode of this._childNodeArray) {
3216
+ link.appendChild(childNode);
3217
+ }
3218
+ if (nextSibling) {
3219
+ parentNode.insertBefore(link, nextSibling);
3220
+ }
3221
+ else {
3222
+ parentNode.appendChild(link);
3223
+ }
1987
3224
  }
1988
3225
  this._anchorElement = link;
3226
+ if (inSsr()) {
3227
+ // サーバー: リスナは登録しない(レンダリングウィンドウは serialize 後に
3228
+ // 閉じる)。active 状態は SSR 出力に載せ、クライアントの採用が引き取る
3229
+ // 目印を付ける
3230
+ this._updateActiveState();
3231
+ link.setAttribute(SSR_LINK_ATTR, '');
3232
+ return;
3233
+ }
1989
3234
  // ロケーション変更を監視
1990
3235
  getNavigation()?.addEventListener('currententrychange', this._updateActiveState);
1991
3236
  window.addEventListener('wcs:navigate', this._updateActiveState);
1992
3237
  window.addEventListener('popstate', this._updateActiveState);
1993
3238
  // Navigation API が無い場合は、クリックで router.navigate にフォールバック
1994
- if (this._path.startsWith('/') && !getNavigation()?.navigate) {
3239
+ // (`?` 始まりのクエリのみリンクも対象 — 素の href だとフルページ遷移になる)
3240
+ if (this._isInternalTarget(this._path) && !getNavigation()?.navigate) {
1995
3241
  this._onClick = async (e) => {
1996
3242
  // only left-click without modifiers
1997
3243
  if (e.defaultPrevented)
@@ -2001,7 +3247,7 @@ class Link extends HTMLElement {
2001
3247
  if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
2002
3248
  return;
2003
3249
  // 動的に外部URLに変わった場合はブラウザのデフォルト挙動に委ねる
2004
- if (!this._path.startsWith('/'))
3250
+ if (!this._isInternalTarget(this._path))
2005
3251
  return;
2006
3252
  e.preventDefault();
2007
3253
  await this.router.navigate(this._path);
@@ -2042,16 +3288,43 @@ class Link extends HTMLElement {
2042
3288
  this._updateActiveState();
2043
3289
  }
2044
3290
  }
3291
+ else if (MIRRORED_ATTRIBUTES.includes(name)) {
3292
+ // 固定名のみ接続後も追従する。anchor 生成前(upgrade 前発火)は何もしない。
3293
+ const anchor = this._anchorElement;
3294
+ if (anchor) {
3295
+ if (newValue === null) {
3296
+ anchor.removeAttribute(name);
3297
+ }
3298
+ else {
3299
+ anchor.setAttribute(name, newValue);
3300
+ }
3301
+ }
3302
+ }
2045
3303
  }
2046
3304
  _updateActiveState = () => {
3305
+ // クエリのみリンク(to="?k=v")の href は現在 pathname に依存するため、
3306
+ // active 判定と同じリスナー経路でロケーション変更に追従させる(§4.1)。
3307
+ if (this._path.startsWith('?') && this._anchorElement) {
3308
+ this._setAnchorHref(this._anchorElement, this._path);
3309
+ }
3310
+ // active 判定は pathname のみの比較(クエリ非感応 — §1.1 欠陥 7 の修理)。
2047
3311
  const currentPath = this._normalizePathname(new URL(window.location.href).pathname);
2048
- const linkPath = this._normalizePathname(this._path.startsWith('/') ? this._joinInternalPath(this.router.basename, this._path) : this._path);
3312
+ const { pathname } = splitUrlTarget(this._path);
3313
+ const linkPath = this._normalizePathname(this._isInternalTarget(this._path)
3314
+ ? (pathname === ""
3315
+ ? window.location.pathname
3316
+ : this._joinInternalPath(this.router.basename, pathname))
3317
+ : pathname);
2049
3318
  if (this._anchorElement) {
2050
3319
  if (currentPath === linkPath) {
2051
3320
  this._anchorElement.classList.add('active');
3321
+ // 修理・既定オン(docs/a11y-design.md §3-3): active class と同じ事実の ARIA 表現。
3322
+ // 鮮度保証は active class と同一(同じ分岐・同じ呼び出し経路)。
3323
+ this._anchorElement.setAttribute('aria-current', 'page');
2052
3324
  }
2053
3325
  else {
2054
3326
  this._anchorElement.classList.remove('active');
3327
+ this._anchorElement.removeAttribute('aria-current');
2055
3328
  }
2056
3329
  }
2057
3330
  };
@@ -2157,7 +3430,12 @@ class Head extends HTMLElement {
2157
3430
  const rel = el.getAttribute('rel') || '';
2158
3431
  const href = el.getAttribute('href') || '';
2159
3432
  const media = el.getAttribute('media') || '';
2160
- return `link:${rel}:${href}:${media}`;
3433
+ // hreflang もキーに含める。含めないと、代表ロケールと `x-default` を同じ
3434
+ // href で併記する `rel="alternate"` の組が同一キーになり、片方が落ちる。
3435
+ // これは i18n の標準的な書き方(x-default は既定言語版を指す)なので、
3436
+ // 「同じ href の link は 1 本」という仮定はここで破れる。
3437
+ const hreflang = el.getAttribute('hreflang') || '';
3438
+ return `link:${rel}:${href}:${media}:${hreflang}`;
2161
3439
  }
2162
3440
  if (tag === 'base') {
2163
3441
  return 'base';
@@ -2272,6 +3550,15 @@ class Head extends HTMLElement {
2272
3550
  }
2273
3551
  // map を新しい要素に更新(後続の同 key 処理に備える)
2274
3552
  headElementMap.set(key, targetElement);
3553
+ // head へ入れたのは cloneNode なので、元ノードのバインドは引き継がれない。
3554
+ // `<title data-wcs="…">` はページからタイトルを消すという、未翻訳より
3555
+ // 悪い形で失敗する。クローンを binder に渡して、そこで初めて束ねる。
3556
+ // 挿入後に呼ぶのは、`bind()` が初期値の適用まで同期で行うため。
3557
+ if (!bindSubtree(targetElement)) {
3558
+ warnUnboundMarkup(targetElement, `<${targetElement.tagName.toLowerCase()}> inside <${config.tagNames.head}>`, `<${config.tagNames.head}> reflects its children into <head> with cloneNode, ` +
3559
+ `so the clone is not the node that was bound. Load @wcstack/state on this ` +
3560
+ `page, or write the value statically here.`);
3561
+ }
2275
3562
  }
2276
3563
  else {
2277
3564
  // 初期値もスタックにもない場合は削除
@@ -2324,7 +3611,7 @@ function bootstrapRouter(config, registry) {
2324
3611
  registerComponents(registry);
2325
3612
  }
2326
3613
 
2327
- var version = "1.31.0";
3614
+ var version = "1.32.0";
2328
3615
  var pkg = {
2329
3616
  version: version};
2330
3617