Skip to content

πŸ”— 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.


A link can be expressed two ways. Both funnel through the same component (WBLink) and the same resolver.

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 toResult
1object with __defineRoute: trueregister the def verbatim β†’ { name }
2object with options.name / options.pathregister a WBC item route that renders the descriptor β†’ { name }
3object with a top-level name / pathtreat as a vue-router location, passed through as-is
4any other objectcompress the descriptor into the URL β†’ navigate to WBCDynamicRouting
5string matching ^(https?:|mailto:|tel:|sms:|ftp:|//|www.|#)external <a>
6string starting ./ or ../file ref β†’ auto-register a WBC route (Case A)
7string equal to an existing route name{ name: to }
8string starting / matching an existing route path{ path: to }
9anything elseexternal 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.md on 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>.


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"],
      },
    },
  },
}
  • name is primary; path defaults to /wbc/<name>. If only path is 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" },

After resolveWbLinkTarget:

  • kind: "route" β†’ <RouterLink :to="location"> (file refs, named routes, paths, defs, plain location objects).
  • kind: "external" β†’ <a href> (with target="_blank" if the > suffix is present).
  • kind: "none" β†’ <span> (no usable to, 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-name addRoute keeps 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 their item from the store by name (above) β€” or on the next refresh (router rebuild).
  • WBCDynamicRouting needs 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/or children): { name, path, item, meta, label, icon, children? }. Used as-is.

  • Non-standard item β€” a string, an array, or an object withoutpath/children (i.e. a WBC item like { comp, options }). It is treated as a WBC item and run through a transform f:

    f(item) β†’ { path: '/wbc/' + slug(cleanItem(item)),
                name: 'WBC' + PascalCase(lastSegment),
                component: WBC,                 // attached when building routes
                props: { item } }               // the ORIGINAL item, rendered by WBC

    cleanItem picks 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 valueKindRendersRoute created?
./file.md, ../x.vueroute<RouterLink>βœ… auto (Case A)
"Home" (existing name)route<RouterLink>reuses existing
/about (existing path)route<RouterLink>reuses existing
{ name } / { path } objectroute<RouterLink>reuses existing
{ options: { name / path, … } } descriptorroute<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:, #anchorexternal<a href>❌
trailing > on a URLexternal<a target="_blank">❌
nothing navigable / text-onlynone<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.