@wcstack/router
v1.3.17
Published
Declarative SPA routing for Web Components with Navigation API support. Zero dependencies, buildless.
Maintainers
Readme
@wcstack/router
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 tonumbertype. - 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-bindattribute (supportsprops,states,attr, and direct property modes). - Declarative
<head>Management: Declaratively switchtitleandmetatags for each page using<wcs-head>.
Usage
<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>
- 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. Only one can exist in a document. A direct child template tag is required. Outputs according to definitions to <wcs-outlet>.
| 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: function (toPath: string, fromPath: string): boolean | Promise
Typed Parameters
By specifying types for path parameters, you can perform value validation and automatic conversion.
Syntax: :paramName(typeName)
<!-- 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:
// 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.
<!-- 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.
<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>byname/property/http-equiv,<link>byrel/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).
/* 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) |
<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():
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
- The
basenameattribute on<wcs-router basename="/app"> - If
<base href="/app/">exists, derive fromnew URL(document.baseURI).pathname - 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.htmlor.../*.htmlas 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 initialpathname !== "/", it is an errorIf 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
*.htmlwhen 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, theninternalPath = "/" - If
pathnamestarts withbasename + "/", theninternalPath = pathname.slice(basename.length) - Otherwise
internalPath = pathname - If the slice result is
"", theninternalPath = "/"
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
- Example: parent
In implementation, paths are converted to absolute during parsing.
3.2 Matching rules
- Exact match by segment
- Parameter
:idmatches a single segment - Catch-all
*matches the remaining path (accessible viaparams['*'])
3.3 Priority (longest match definition)
If multiple candidates exist, pick the higher priority:
- More segments
- If same, more static segments (
"users">":id">"*") - 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
/productsand/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.
<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
hrefis created by joiningbasename + 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)
