# @wcstack/router

> 🤖 **AI coding agents**: This README is a package-level reference, not the primary entry point for building a wcstack application. If you have not already done so, first read the repository [README](https://github.com/wcstack/wcstack#readme) and [AGENTS.md](https://github.com/wcstack/wcstack/blob/main/AGENTS.md), then use the [wcstack-app skill](https://github.com/wcstack/wcstack-skill).

**What if routing was just HTML tags?**

Imagine a future where you define your app's navigation structure in markup — nested routes, layouts, typed parameters — all as native HTML elements. No router config objects, no JavaScript ceremony. Just tags that describe where things go.

That's what `<wcs-router>`, `<wcs-route>`, and friends explore. One CDN import, zero dependencies, pure HTML syntax.

## Features

### Basic Features
* **Declarative Routing**: Simply list `<wcs-route>` tags within an HTML `<template>`. No JS configuration object required.
* **Nested Route Definitions**: Intuitively express nested structures like `/products/:id`.
* **Parameter Support**: Supports path parameters (`:id`).
* **Fallback (404)**: Handle undefined paths with `<wcs-route fallback>`.
* **Navigation API Based**: Built on the modern standard Navigation API, offering high affinity with native browser behavior.
* **Zero Config / Buildless**: Works directly in the browser without bundling.

### Unique Features
* **Light DOM Layout System**: Defines layout templates in normal DOM (Light DOM) without forcing Shadow DOM. Makes global CSS application and `<slot>` insertion easy.
* **Typed Parameters**: Specify type constraints like `:id(int)`. Automatically converts values to `number` type.
* **Mixed Layouts & Routes**: Freely nest `<wcs-layout>` within the routing tree, managing layout switching per area purely through HTML structure.
* **Auto-Binding**: Automatically injects URL parameters into components using `data-bind` attribute (supports `props`, `states`, `attr`, and direct property modes).
* **Declarative `<head>` Management**: Declaratively switch `title` and `meta` tags for each page using `<wcs-head>`.

## Usage

```html
<wcs-router>
	<template>
		<!-- When path is "/" -->
		<wcs-route path="/">
			<!-- Apply the "main-layout" layout -->
			<wcs-layout layout="main-layout">
				<main-header slot="header"></main-header>
				<main-body>
					<!-- When path is "/" -->
					<wcs-route index>
						<wcs-head>
							<title>Main Page</title>
						</wcs-head>
						<main-dashboard></main-dashboard>
					</wcs-route>

					<!-- When path is "/products" (relative paths below top-level) -->
					<wcs-route path="products">
						<wcs-head>
							<title>Product Page</title>
						</wcs-head>
						<!-- When path is "/products" -->
						<wcs-route index>
							<product-list></product-list>
						</wcs-route>
						<!-- When path is "/products/:productId" -->
						<wcs-route path=":productId">
							<!-- productItem.props.productId = productId -->
							<product-item data-bind="props"></product-item>
						</wcs-route>
					</wcs-route>
				</main-body>
			</wcs-layout>
		</wcs-route>

		<!-- When path is "/admin" -->
		<wcs-route path="/admin">
			<!-- Apply the "admin-layout" layout -->
			<wcs-layout layout="admin-layout">
				<wcs-head>
					<title>Admin Page</title>
				</wcs-head>
				<admin-header slot="header"></admin-header>
				<admin-body></admin-body>
			</wcs-layout>
		</wcs-route>

		<!-- When no path matches -->
		<wcs-route fallback>
			<error-404></error-404>
		</wcs-route>
	</template>
</wcs-router>

<wcs-outlet>
	<!-- Build a DOM tree according to the route path and layout and render it here -->
</wcs-outlet>

<!-- "main-layout" layout -->
<template id="main-layout">
	<section>
		<h1> Main </h1>
		<slot name="header"></slot>
	</section>
	<section>
		<slot></slot>
	</section>
</template>

<!-- "admin-layout" layout -->
<template id="admin-layout">
	<section>
		<h1> Admin Main </h1>
		<slot name="header"></slot>
	</section>
	<section>
		<slot></slot>
	</section>
</template>

```

* <main-header><main-body><main-dashboard><product-list><product-item><admin-header><admin-body><error-404> are custom components in your app.
* The custom elements above must be defined separately (via an autoloader or manual registration).

## Reference

### Router (wcs-router)

Define routes and layout slots inside a child template tag. A direct child template tag is required. Outputs according to definitions to `<wcs-outlet>`. Multiple routers can coexist in the same document when each has a distinct `basename`.

| Attribute | Description |
|------|------|
| `basename` | When routing in a subfolder URL, specify the subfolder. Not required if you don’t run in a subfolder. |

### Route (wcs-route)

Displays children when the route path matches. Match priority is static paths over parameters.

| Attribute | Description |
|------|------|
| `path` | For top-level routes, specify an absolute path starting with `/`. Otherwise, specify a relative path. For parameters, use `:paramName`. For catch-all, use `*`. Top-level routes cannot use relative paths. |
| `index` | Inherits the upper path. |
| `fallback` | Displayed when no route matches the path. |
| `fullpath` | Path including parent routes (read-only). |
| `name` | Identifier. |
| `guard` | Enables guard handling. Specify the full path to navigate to on guard cancellation. |

| Property | Description |
|------|------|
| `params` | Matched parameters (strings). |
| `typedParams` | Matched parameters (converted types). |
| `guardHandler` | Sets the guard decision function. |

Guard decision function type:
`(toPath: string, fromPath: string) => boolean | Promise<boolean>`

#### GuardHandler (wcs-guard-handler)

Place as a child of `<wcs-route>` to declaratively define a guard decision function. Export the function as the `default export` from a `<script type="module">`. The `<wcs-guard-handler>` element itself is removed from the DOM after parsing.

```html
<wcs-route path="/dashboard" guard="/login">
  <wcs-guard-handler>
    <script type="module">
      export default function(toPath, fromPath) {
        return document.cookie.includes('session=');
      }
    </script>
  </wcs-guard-handler>
  <dashboard-page></dashboard-page>
</wcs-route>
```

- The `guard` attribute value is the redirect path when the guard cancels navigation
- If the function returns `false`, navigation is cancelled and the user is redirected to the `guard` path
- The function can return `Promise<boolean>` for async checks
- `<wcs-guard-handler>` placed outside a `<wcs-route>` is ignored
- If no `<script type="module">` is present, `guardHandler` is not set

#### Typed Parameters

By specifying types for path parameters, you can perform value validation and automatic conversion.

**Syntax**: `:paramName(typeName)`

```html
<!-- Integer parameter -->
<wcs-route path="/users/:userId(int)">
  <user-detail></user-detail>
</wcs-route>

<!-- Complex parameters -->
<wcs-route path="/posts/:date(isoDate)/:slug(slug)">
  <post-detail></post-detail>
</wcs-route>
```

**Built-in Types**:

| Type Name | Description | Example | Converted Type |
|------|------|------|------|
| `int` | Integer | `123`, `-45` | `number` |
| `float` | Floating point number | `3.14`, `-2.5` | `number` |
| `bool` | Boolean | `true`, `false`, `0`, `1` | `boolean` |
| `uuid` | UUID v1-5 | `550e8400-e29b-41d4-a716-446655440000` | `string` |
| `slug` | Slug (lowercase alphanumeric and hyphens) | `my-post-title` | `string` |
| `isoDate` | ISO 8601 Date | `2024-01-23` | `Date` |
| `any` | Any string (default) | Any | `string` |

**Retrieving Values**:

```javascript
// Get from the route element
const route = document.querySelector('wcs-route[path="/users/:userId(int)"]');

// Get as string
console.log(route.params.userId);       // "123"

// Get as typed value
console.log(route.typedParams.userId);  // 123 (number)
```

**Behavior**:
- If the value does not match the type, the route will not match (it does not result in an error).
- If no type is specified, it is treated as `any` (same as previous behavior).
- Specifying an unknown type name also falls back to `any`.

### Layout (wcs-layout)

Loads a template, inserts children into `<slot>`, and writes to `<wcs-layout-outlet>`. Light DOM supported. External file supported.

| Attribute | Description |
|------|------|
| `layout` | The id attribute of the template tag used as the template. |
| `src` | URL of an external template file. |
| `name` | Identifier passed to `wcs-layout-outlet`. |
| `enable-shadow-root` | Use Shadow DOM in `<wcs-layout-outlet>`. |
| `disable-shadow-root` | Use Light DOM in `<wcs-layout-outlet>`. |

### Outlet (wcs-outlet)

Displays a DOM tree according to the routing and layout settings. Define it in HTML, or if missing it is created by `<wcs-router>`.

### LayoutOutlet (wcs-layout-outlet)

Displays a DOM tree into `<wcs-outlet>` according to the layout (`<wcs-layout>`) settings. Inherits the name attribute from `<wcs-layout>`. Use the name attribute to identify styling targets.

| Attribute | Description |
|------|------|
| `name` | The name attribute of `<wcs-layout>`. Use it to identify styling targets. |

#### Light DOM Limitations

When utilizing `disable-shadow-root` (Light DOM), slot replacement targets **only direct children** of `<wcs-layout>`. Elements with `slot` attributes inside `<wcs-route>` will not be placed in the slot.

```html
<!-- NG: <div slot="header"> is not a direct child of wcs-layout, so it doesn't go into the slot -->
<wcs-layout layout="main" disable-shadow-root>
  <wcs-route path="/page">
    <div slot="header">Header Content</div>
  </wcs-route>
</wcs-layout>

<!-- OK: Make the element with slot attribute a direct child of wcs-layout -->
<wcs-layout layout="main" disable-shadow-root>
  <div slot="header">Header Content</div>
  <wcs-route path="/page">
    <!-- Page content -->
  </wcs-route>
</wcs-layout>
```

In the case of `enable-shadow-root` (Shadow DOM), this limitation does not apply because the native `<slot>` function is used.

### Head (wcs-head)

Manages document `<head>` elements per route. Uses a stack-based system where the most recently connected Head is prioritized.

```html
<wcs-route path="/about">
  <wcs-head>
    <title>About Us</title>
    <meta name="description" content="About our company">
  </wcs-head>
  <about-page></about-page>
</wcs-route>
```

**Supported elements**: `<title>`, `<meta>`, `<link>`, `<base>`, `<script>`, `<style>`

**Behavior**:
- Captures the initial `<head>` state on first connection
- When multiple `<wcs-head>` elements are active, the last connected one takes priority
- When all `<wcs-head>` elements disconnect, the initial state is restored
- Elements are identified by key (e.g., `<meta>` by `name`/`property`/`http-equiv`, `<link>` by `rel`/`href`)

### Link (wcs-link)

Link. Converted to an `<a>`, and the route path in the `to` attribute is converted to a URL. When the link path matches the current URL, the `active` CSS class is automatically added to the generated `<a>` element.

| Attribute | Description |
|------|------|
| `to` | Destination path or URL. Paths starting with `/` are treated as internal paths (basename is prepended). Other values are treated as external URLs. |

**Active state**: The generated `<a>` receives the `active` class when its path matches the current location. Tracking is updated on navigation events (`currententrychange`, `wcs:navigate`, `popstate`).

```css
/* Style active links */
a.active { font-weight: bold; color: blue; }
```

## Auto-Binding (`data-bind`)

Elements with the `data-bind` attribute automatically receive matched route parameters. Four binding modes are available:

| `data-bind` value | Target | Description |
|------|------|------|
| `"props"` | `element.props` | Merges params into the `props` property |
| `"states"` | `element.states` | Merges params into the `states` property |
| `"attr"` | HTML attributes | Sets params as HTML attributes via `setAttribute()` |
| `""` (empty) | Direct properties | Sets params directly on the element (e.g., `element.id = value`) |

```html
<wcs-route path="/users/:userId(int)">
  <!-- element.props = { userId: 123 } -->
  <user-detail data-bind="props"></user-detail>

  <!-- element.setAttribute("userId", 123) -->
  <div data-bind="attr"></div>
</wcs-route>
```

Parameters are assigned before `connectedCallback` fires. For custom elements that are not yet defined, assignment is deferred until `customElements.whenDefined()` resolves.

## Configuration

Initialize the router with optional configuration via `bootstrapRouter()`:

```javascript
import { bootstrapRouter } from '@wcstack/router';

bootstrapRouter({
  // Custom tag names (all optional)
  tagNames: {
    router: 'wcs-router',       // default
    route: 'wcs-route',         // default
    outlet: 'wcs-outlet',       // default
    layout: 'wcs-layout',       // default
    layoutOutlet: 'wcs-layout-outlet', // default
    link: 'wcs-link',           // default
    head: 'wcs-head'            // default
  },
  // Use Shadow DOM for outlets (default: false)
  enableShadowRoot: false,
  // File extensions stripped from basename (default: [".html"])
  basenameFileExtensions: [".html"]
});
```

## Path Specification (Router / Route / Link)

### Terminology

* **URL Pathname**: `location.pathname` (e.g. `/app/products/42`)
* **basename**: The app mount path (e.g. `/app`)
* **internalPath**: The routing path inside the app after removing basename (e.g. `/products/42`)

---

## 1) basename specification

### 1.1 basename resolution order

1. The `basename` attribute on `<wcs-router basename="/app">`
2. If `<base href="/app/">` exists, derive from `new URL(document.baseURI).pathname`
3. If neither exists, use **empty string** `""` (assumes running at root)

### 1.2 basename normalization (important)

basename is always normalized as follows:

* Add leading `/` (except empty string)
* Collapse multiple slashes into one
* Remove trailing `/` (except `/` itself, which is treated as empty)
* Treat `.../index.html` or `.../*.html` as files and remove them
* If the result is `/`, basename becomes `""`

Examples:

* `"/"` → `""`
* `"/app/"` → `"/app"`
* `"/app/index.html"` → `"/app"`

### 1.3 basename and direct links

* If basename is `""`, no `<base>` exists, and the initial `pathname !== "/"`, it is **an error**
* If basename is `"/app"`:

	* `"/app"` and `"/app/"` are **the same** (app root)
	* `"/app"` matches only `"/app"` or `"/app/..."` (does not match `"/appX"`)

---

## 2) internalPath specification

### 2.1 internalPath normalization

internalPath is always treated as an **absolute path**.

* Add leading `/`
* Collapse multiple slashes
* Remove trailing `/` (except root `/`)
* If empty, become `/`
* In Router normalization, remove trailing `*.html` when present

Examples:

* `""` → `/`
* `"products"` → `/products`
* `"/products/"` → `/products`
* `"///a//b/"` → `/a/b`

### 2.2 Get internalPath from URL

Obtain `internalPath` by matching `URL Pathname` with `basename`.

* If `pathname === basename`, then `internalPath = "/"`
* If `pathname` starts with `basename + "/"`, then `internalPath = pathname.slice(basename.length)`
* Otherwise `internalPath = pathname`
* If the slice result is `""`, then `internalPath = "/"`

Examples (basename=`/app`):

* pathname=`/app` → internalPath=`/`
* pathname=`/app/` → internalPath=`/`
* pathname=`/app/products/42` → internalPath=`/products/42`

---

## 3) `<wcs-route path="...">` specification

### 3.1 path notation

`path` follows **internalPath rules**.

* Root (top-level) is `"/"`
* Child routes allow **relative** paths (recommended)

	* Example: parent `/products`, child `":id"` → `/products/:id`

> In implementation, paths are converted to absolute during parsing.

### 3.2 Matching rules

* **Exact match** by segment
* Parameter `:id` matches a single segment
* Catch-all `*` matches the remaining path (accessible via `params['*']`)

### 3.3 Priority (longest match definition)

If multiple candidates exist, pick the higher priority:

1. **More segments**
2. If same, **more static segments** (`"users"` > `":id"` > `"*"`)
3. If still same, **definition order**

> Catch-all `*` has the lowest priority, so more specific routes always take precedence.

Example:

* `/admin/users/:id` (static2 + param1)
* `/admin/users/profile` (static3)
	→ latter wins

### 3.4 Trailing slash

* Matching is done after internal normalization, so

	* `/products` and `/products/` are treated the same (either URL is OK)

### 3.5 Catch-all (`*`)

Specify `*` at the end of a path to match the entire remaining path.

```html
<wcs-route path="/admin/profile"></wcs-route>  <!-- Priority -->
<wcs-route path="/admin/*"></wcs-route>        <!-- Fallback for /admin/xxx -->
<wcs-route path="/*"></wcs-route>              <!-- Last resort -->
```

| Path | Match | Reason |
|------|-------|--------|
| `/admin/profile` | `/admin/profile` | More segments |
| `/admin/setting` | `/admin/*` | `*` matches `setting` |
| `/admin/a/b/c` | `/admin/*` | `*` matches `a/b/c` |
| `/other` | `/*` | Top-level catch-all |

The matched remaining path is accessible via `params['*']`.

---

## 4) `<wcs-link to="...">` specification

### 4.1 When `to` starts with `/`

`to` is treated as **internalPath**.

* The actual `href` is created by joining `basename + internalPath`
* Join: `"/app" + "/products"` → `"/app/products"` (no `//`)

### 4.2 When `to` does not start with `/`

Treated as an external URL (`new URL(to)` is expected to succeed).

* Example: `https://example.com/`

---

## 5) “Drop HTML files” is limited

Dropping `.html` only applies when the pathname **actually looks like a file**.

* `"/app/index.html"` → `"/app"` (OK)
* `"/products"` → `"/"` is **NG** (do not drop segments)
