Appearance
π Links & Dynamic Routes (WBLink) β
So far every page in this guide has defined routes up front β you write a route record, point its component at WBC, and give it an item. This page covers the other direction: links that live inside your content and create the right destination on the fly.
Whenever WBC renders a link β a piped string item, a [[ β¦ ]] block, or an item.options.to β it hands the target to one resolver, WBLink, which decides what that link is: an external <a>, navigation to an existing route, or a brand-new route registered on the spot that renders any file or component through WBC. No trip back to your router file required.
π§ The two ways to write a link β
A link can be expressed two ways. Both funnel through the same component (WBLink) and the same resolver.
String grammar β text | classes-or-options | link β
The pipe-delimited string item understood by WBC / WBHtml:
<text> | <classes or options> | <link> | <parsedAs?>html
<WBC item="Open the plan | pa-2 teal--text | ./plan.md" />
<!-- ββ text βββββββ ββ classes βββ ββ link βββ -->The 3rd field (<link>) is the to. The same grammar works inside WBHtml markup:
html
<WBC item="[[ Open the plan | pa-2 teal--text | ./plan.md ]]" />Object grammar β item.options.to β
When the item is an object, the link lives on options.to:
js
{ options: { html: "Open the plan", class: "pa-2 teal--text", to: "./plan.md" } }Both forms end up as:
js
h("WBLink", { props: { to: <link>, html: <rendered text/vnode> } })π§ The resolver β how a to is classified β
WBLink never guesses link types inline. A single function, resolveWbLinkTarget, decides β in strict order β what a to means:
js
resolveWbLinkTarget(router, to, currentRoutePath)
β { kind: "route", location } // render <RouterLink :to="location">
| { kind: "external" } // render plain <a href>
| { kind: "none" } // nothing navigable (renders <span>)Decision order (first match wins):
| # | Test on to | Result |
|---|---|---|
| 1 | object with __defineRoute: true | register the def verbatim β { name } |
| 2 | object with options.name / options.path | register a WBC item route that renders the descriptor β { name } |
| 3 | object with a top-level name / path | treat as a vue-router location, passed through as-is |
| 4 | any other object | compress the descriptor into the URL β navigate to WBCDynamicRouting |
| 5 | string matching ^(https?:|mailto:|tel:|sms:|ftp:|//|www.|#) | external <a> |
| 6 | string starting ./ or ../ | file ref β auto-register a WBC route (Case A) |
| 7 | string equal to an existing route name | { name: to } |
| 8 | string starting / matching an existing route path | { path: to } |
| 9 | anything else | external href (fallback) |
Order matters: external/anchor is checked before the string route lookups, so a #anchor or https://β¦ is never mistaken for a route. Among objects, the most specific shapes win first β a __defineRoute definition, then a self-describing descriptor (options.name/options.path), then a plain vue-router location, and finally β for any object with no route coordinates at all β a URL-carried dynamic render (see Object routing below).
π Case A β Dynamic file routes (./, ../) β
A relative file reference becomes a navigable, named route whose component is WBC and whose item is the original path string. WBC's existing file pipeline (renderString β renderFile) does the actual loading β exactly the same engine documented under File Routing, but the route is created for you the first time the link mounts.
Path & name algorithm β
For to = "./md/aboutMd/aboutMd.md" on a page whose route is /:
currentDir = dirname($route.path) = "/"
joined = posixJoin(currentDir, to) = "/md/aboutMd/aboutMd.md"
noExt = strip extension = "/md/aboutMd/aboutMd"
path = "/wbc" + noExt = "/wbc/md/aboutMd/aboutMd"
lastSeg = "aboutMd"
name = "WBC" + PascalCase(lastSeg) = "WBCAboutMd"- Path is resolved relative to the current route (collapses
../), so the same./x.mdon two different pages does not collide. - Name is deterministic from the resolved path. A module-level
Map<path, name>registry guarantees the same file always maps to the same name; an incrementing suffix (WBCAboutMd1) is appended only when two different paths collide on the same base name. - Registration is idempotent β re-mounting a link never duplicates a route.
Examples β
html
<!-- markdown file -->
<WBC item="md file route | pa-2 d-block | ./plan.md" />
<!-- vue component file -->
<WBC item="vue file route | pa-2 d-block | ./vue0.vue" />
<!-- nested path -->
<WBC item="nested md route | pa-2 d-block | ./md/aboutMd/aboutMd.md" />
<!-- with #hash (carried onto the route location) -->
<WBC item="md route + hash | pa-2 d-block | ./plan.md#intro" />Navigate programmatically once registered:
js
this.$router.push({ name: "WBCPlan" });
this.$router.push({ name: "WBCPlan", hash: "#intro" });?query and #hash suffixes are split off before path/name computation and re-attached to the resulting location ({ name, hash } / { name, query }).
π Case B β Existing named routes & paths β
If to is a bare string that matches a route name already registered (in your router file, autoRoutes, or a previously-registered dynamic route), the link navigates there. Same for a /path that an existing route matches.
html
<!-- existing named routes -->
<WBC item="go Home | pa-2 d-block | Home" />
<WBC item="go About | pa-2 d-block | AboutView" />
<WBC item="go Vuetify | pa-2 d-block | WBCVuetify" />
<!-- existing route by path -->
<WBC item="by path | pa-2 d-block | /about" />Gotcha
A bare name only resolves if that route actually exists. Name existence is checked with router.getRoutes() (vue-router 3.5+) β note hasRoute() is vue-router 4 only and is absent in v3. If the name isn't found and the string isn't a /path, it falls through to an external <a>.
π Case C β Non-route links (external / anchor / mailto / new-tab) β
html
<!-- external -->
<WBC item="Google | pa-2 d-block | https://google.com" />
<!-- open in a new tab: trailing '>' -->
<WBC item="Google (new tab) | pa-2 d-block | https://google.com>" />
<!-- in-page anchor -->
<WBC item="Jump to top | pa-2 d-block | #links--dynamic-routes-wblink" />
<!-- mailto / tel -->
<WBC item="Email me | pa-2 d-block | mailto:hello@wbc-ui.com" />
<WBC item="Call | pa-2 d-block | tel:+21600000000" />A trailing > on the href sets target="_blank" (and is stripped from the URL).
π§© Object routing β item.options.to β
The object grammar covers the same kinds, plus two object-only forms.
js
// A) dynamic file route (Case A)
{ options: { html: "plan", to: "./plan.md" } }
// B) existing named route, as a string
{ options: { html: "about", to: "AboutView" } }
// C) existing named route, as a vue-router LOCATION object
{ options: { html: "about", to: { name: "AboutView" } } }
// D) existing route, by PATH object
{ options: { html: "about", to: { path: "/about" } } }
// E) external
{ options: { html: "google", to: "https://google.com" } }TIP
A plain vue-router location ({ name } / { path } / { name, params } β¦) is still passed through untouched. Two newer object shapes are re-interpreted before that fallback β a self-describing descriptor and the URL-carried dynamic render below.
Self-describing descriptor β options.name / options.path β
When the to object is itself a WBC descriptor that carries route coordinates under options, WBLink registers (or reuses) a permanent named route whose body is that descriptor, then navigates to it. The page renders the object through WBC.
js
{
options: {
html: "open the dashboard",
to: {
// β this nested object is the page content AND its own route
options: {
name: "Dashboard", // route name β /wbc/Dashboard
path: "/dash", // optional explicit path (wins over /wbc/<name>)
meta: { requiresAuth: true }, // optional β becomes the route's `meta`
html: ["h1__Dashboard", "p__Live content rendered by WBC"],
},
},
},
}nameis primary;pathdefaults to/wbc/<name>. If onlypathis given, the name is derived from it (/wbc/team-pageβWBCTeamPage).- Registration is idempotent by name β re-mounting the link never duplicates the route β and the route is persisted (see Permanence across refresh).
Anything else β URL-carried dynamic render (WBCDynamicRouting) β
An object with no route coordinates (no __defineRoute, no top-level name/path, no options.name/options.path) is still navigable: WBLink compresses the whole descriptor into the URL with LZ-string and routes to the shared WBCDynamicRouting catch-all, which decompresses and renders it.
js
{ options: { html: "open this card as a page",
to: { comp: "VCard", options: { html: ["p__Hello|primary"] } } } }
// β { name: "WBCDynamicRouting",
// params: { item: compressToEncodedURIComponent(stringify(to)) } }Security
The WBCDynamicRouting route renders attacker-controllable content (anything in the URL). It mounts with untrusted: true, forcing WBC's AST-sandbox evaluator for that subtree β embedded handler strings can never run as new Function closures. Function values in the descriptor survive transport as inert source strings, evaluated only inside that sandbox.
Define a brand-new route on the fly β __defineRoute β
An object carrying the sentinel __defineRoute: true is a route definition (not a location). It is addRoute'd verbatim (sentinel stripped), then the link navigates to it by name. This lets your content create routes without ever editing the router file.
js
{
options: {
html: "create & open /wbc/on-the-fly",
to: {
__defineRoute: true,
path: "/wbc/on-the-fly",
name: "WBCOnTheFly", // optional; derived from path if omitted
component: { // any Vue component
render(h) {
return h("div", { class: "pa-6 teal--text" },
"Defined on the fly by a WBLink β not in the router file.");
},
},
// props, meta, β¦ any vue-router route-record field
},
},
}To render a WBC item as the route's body, point the component at WBC:
js
component: () => import("@wbc-ui2/core").then(m => m.WBC),
props: { item: "./plan.md" },πΌοΈ What WBLink renders β
After resolveWbLinkTarget:
kind: "route"β<RouterLink :to="location">(file refs, named routes, paths, defs, plain location objects).kind: "external"β<a href>(withtarget="_blank"if the>suffix is present).kind: "none"β<span>(no usableto, or text-only).
Resolution runs once in created() (off the render path) and re-runs via a watch if to changes. Failures (no router, bad definition) are swallowed and logged as #WBLinkRouteError; the link degrades to a plain <a>.
Missing target β
When a registered route renders a ./ file that doesn't exist, WBC's file pipeline throws #β¦FileError: <path> not found, which is caught by WBC's render try/catch and shown as a red error box that also prints:
Target not found: ./plan.mdπΎ Permanence across refresh β
router.addRoute is session-only β a route registered while the app runs is gone after a full page refresh. To make generated routes permanent, the core emits every newly-created named route to a host-provided sink, which the app persists (e.g. a Vuex store backed by localStorage) and rehydrates into the router on the next boot.
js
// core (storage-agnostic): emit a serializable descriptor on each new route
import { setWbcRouteSink } from "@wbc-ui2/core";
// app: upsert by name β here into a Vuex store + localStorage
setWbcRouteSink((descriptor) => store.commit("wbcAddRoute", descriptor));
// { name, path, item, meta? }js
// app router/index.js β rebuild persisted routes at construction
(store.state.wbcRoutes || []).forEach((d) => {
if (routes.some((r) => r.name === d.name || r.path === d.path)) return;
routes.push({ path: d.path, name: d.name, component: WBC,
// resolve item from the store BY NAME at navigation time, so an override shows up
props: () => ({ item: (store.state.wbcRoutes.find(r => r.name === d.name) || d).item }),
...(d.meta ? { meta: d.meta } : {}) });
});options.name is the unique key. The persisted wbc:dynamicRoutes object is keyed by route name: re-registering a name with a different descriptor overrides the stored one (old removed, new item/meta/path in its place); an identical re-mount is a no-op. So editing a named block's content and reloading replaces its stored route rather than stacking a duplicate.
Notes:
- The core never imports Vuex or touches storage β the sink is the only seam. Without a sink, behaviour is unchanged (routes stay session-only).
- Descriptors are made JSON-safe before they leave the core: function values are flattened to cleaned source strings so they survive
localStorage. - Override detection is two-layered: the core emits only when a name's descriptor changed (or is first-seen this session); the store mutation then upserts and skips identical writes. Net: real edits persist, redundant re-mounts don't thrash storage.
- vue-router 3.6 caveat: a named route can't be cleanly replaced live (no
removeRoute; a duplicate-nameaddRoutekeeps the old record winning). So a same-name override updates the store immediately, and the live view reflects it on the next navigation β rehydrated routes read theiritemfrom the store by name (above) β or on the next refresh (router rebuild). WBCDynamicRoutingneeds no persistence: its payload already lives in the URL, so a deep link survives refresh as long as that one (static) route exists.
π§± Self-registering WBC routes β options.name on a rendered item β
Routing is not only a link concern. When WBC renders an object item that declares its own route coordinates, it registers that route on mounted β so the mere act of displaying a named block makes it a permanent, deep-linkable page.
html
<WBC :item="{
comp: 'VCard',
options: {
name: 'Pricing', // β route /wbc/Pricing
path: '/pricing', // optional (wins over /wbc/<name>)
meta: { section: 'marketing' }, // optional β route meta
html: ['h1__Plans', 'p__Pick a tier'],
},
}" />After this WBC mounts, /pricing (named Pricing) exists, renders the same descriptor, and β through the sink above β survives a refresh. The trigger is options.name (primary) or options.path; both feed the exact same registerWbcItemRoute machinery as the link form.
When NOT to add a name
options.name is inert as a Vue attribute, but it is the trigger here: every rendered item that carries one becomes a route. Don't put options.name on items you only mean to display (form fields, repeated cards) unless you also want them addressable. The registration is browser-only and fail-safe β it never blocks a mount and is a no-op when no router is present.
π§ͺ Full example matrix β
A complete, runnable set of every link kind:
html
<h1 id="string-routing">String routing</h1>
<!-- A) Dynamic file routes (auto-registered) -->
<WBC item="md file route | pa-2 d-block | ./plan.md" />
<WBC item="vue file route | pa-2 d-block | ./vue0.vue" />
<WBC item="nested md route | pa-2 d-block | ./md/aboutMd/aboutMd.md" />
<WBC item="md route + hash | pa-2 d-block | ./plan.md#intro" />
<!-- B) Existing routes by name / path -->
<WBC item="named β Home | pa-2 d-block | Home" />
<WBC item="named β AboutView | pa-2 d-block | AboutView" />
<WBC item="named β WBCVuetify | pa-2 d-block | WBCVuetify" />
<WBC item="path β /about | pa-2 d-block | /about" />
<!-- C) Non-route links -->
<WBC item="external | pa-2 d-block | https://google.com" />
<WBC item="new tab | pa-2 d-block | https://google.com>" />
<WBC item="anchor | pa-2 d-block | #string-routing" />
<WBC item="mailto | pa-2 d-block | mailto:hello@wbc-ui.com" />
<!-- D) Same grammar inside WBHtml [[ ]] -->
<WBC item="[[ wbhtml md route | teal--text pa-2 d-block | ./plan.md ]]" />js
// Object routing β each item is { options: { html, class, to } }
objRoutes: [
{ options: { html: "to = ./plan.md", to: "./plan.md" } },
{ options: { html: "to = 'AboutView'", to: "AboutView" } },
{ options: { html: "to = { name:'AboutView' }", to: { name: "AboutView" } } },
{ options: { html: "to = { path:'/about' }", to: { path: "/about" } } },
{ options: { html: "to = external", to: "https://google.com" } },
{ options: { html: "to = __defineRoute", to: {
__defineRoute: true, path: "/wbc/on-the-fly", name: "WBCOnTheFly",
component: { render: h => h("div", { class: "pa-6" }, "on the fly") },
} } },
]html
<WBC v-for="(it, i) in objRoutes" :key="i" :item="it" />π§± Appendix β @wbc-ui2/press navigation reuses this treatment β
The @wbc-ui2/press mini-framework applies the same philosophy to the entries of its navigation.js content graph. Each entry is classified, then either kept or wrapped into a route β mirroring how resolveWbLinkTarget classifies a to:
Standard node β a plain object with a
path(and/orchildren):{ name, path, item, meta, label, icon, children? }. Used as-is.Non-standard item β a string, an array, or an object without
path/children(i.e. a WBC item like{ comp, options }). It is treated as a WBC item and run through a transformf:f(item) β { path: '/wbc/' + slug(cleanItem(item)), name: 'WBC' + PascalCase(lastSegment), component: WBC, // attached when building routes props: { item } } // the ORIGINAL item, rendered by WBCcleanItempicks the id source like the link engine does: a.//../link field if present, else the item's text /comp/options.html.
js
// navigation.js β first three are non-standard, last two standard
[
'text-|red|./content/home.md', // β WBCHome, /wbc/content/home
'static-text', // β WBCStaticText, /wbc/static-text
{ comp:'li', options:{ html:'text-|red|./content/home.md' } }, // β WBCLi, /wbc/li
{ name:'Home', path:'/', item:'./content/home.md', meta:{} }, // standard, as-is
{ name:'About', path:'/about', item:'./content/about.md' }, // standard, as-is
]In the shell, non-standard nodes render in the menu/sidebar as their original WBC item (so { comp:'li' } is an <li>, 'static-text' is text, a piped string is a styled WBC link), while standard nodes render as route links. Generated name/path collisions get a numeric suffix; authored names are never renamed.
β Summary β
to value | Kind | Renders | Route created? |
|---|---|---|---|
./file.md, ../x.vue | route | <RouterLink> | β auto (Case A) |
"Home" (existing name) | route | <RouterLink> | reuses existing |
/about (existing path) | route | <RouterLink> | reuses existing |
{ name } / { path } object | route | <RouterLink> | reuses existing |
{ options: { name / path, β¦ } } descriptor | route | <RouterLink> | β named WBC item route (persisted) |
{ __defineRoute: true, β¦ } | route | <RouterLink> | β on the fly |
| any other object (no route coords) | route | <RouterLink> | β WBCDynamicRouting (URL-carried) |
https://β¦, mailto:, #anchor | external | <a href> | β |
trailing > on a URL | external | <a target="_blank"> | β |
| nothing navigable / text-only | none | <span> | β |
Beyond links, a rendered WBC item whose options.name/options.path is set self-registers a permanent route on mount β see Self-registering WBC routes. Created routes are made permanent across refresh via the route sink β see Permanence across refresh.
Next: see Mixed Routing for composing these links inside larger array-described views, or Integration Guide to wire the engine into an existing app.