SPEC-069
ID:SPEC-069Status:draft

Plugin-contributed routes & declarative entity routing

A mechanism for getting content into a refrakt site from sources that don't live in the content tree — local plan files, external CMSes, databases, issue trackers. It operates on two orthogonal axes:

claude/spec-plugin-contributed-routes View source
Branches 1
claude/spec-plugin-contributed-routes current draft
main draft
History 13
  1. 6c8f01f
    Content editedby bjornolofandersson
  2. 2536de4
    Content editedby bjornolofandersson
  3. eab222f
    Content editedby bjornolofandersson
  4. ab36a82
    Content editedby bjornolofandersson
  5. da37061
    Content editedby bjornolofandersson
  6. acdbc7e
    Content editedby bjornolofandersson
  7. 1754cc7
    Content editedby bjornolofandersson
  8. 1fe8107
    Content editedby bjornolofandersson
  9. 8987c08
    Content editedby bjornolofandersson
  10. e8ff5d0
    Content editedby bjornolofandersson
  11. 15301ca
    Content editedby bjornolofandersson
  12. 939a9ab
    Content editedby bjornolofandersson
  13. b3d9b0d
    Created (draft)by bjornolofandersson
  • Entity contribution (the fundamental axis) — register entities from any source so they're referenceable ({% ref %}), embeddable ({% expand %}), and listable ({% backlog %} / {% blog %}) on pages the author already wrote. No routes required. An entity can carry its own embeddable content via an embed() function, so it works whether or not a file backs it.
  • Page contribution (a layer on top) — turn entities (or raw external data) into their own routes. Expressed declaratively as entityRoutes rules in refrakt.config.json (mirroring xrefPatterns / fileRoots), or programmatically via a contributePages plugin hook for data that isn't entity-shaped.

The axes compose: display an external issue tracker's tickets inline with entity contribution alone (no routes); build a full plan site or CMS-backed blog with entity + page contribution (a route per item). Page contribution usually builds on entity contribution but doesn't have to — a raw contributePages returning marketing-page HTML never registers an entity.

Both page surfaces compile down to the same primitive: a list of virtual pages the content loader treats indistinguishably from file-backed pages. Config rules are sugar over the hook; the hook is the real page-axis mechanism. The entity axis sits beneath both.

Problem

Today, every page on a refrakt site corresponds to a file in the site's content tree, and every registry entity is scanned out of a rendered page. The pipeline is keyed off "what's in content/". This is fine for hand-authored sites, but it leaves several real use cases unaddressed:

1. Registered entities that should also be pages, without file duplication.

The plan plugin's unconditional scan (SPEC-064) registers entities from plan/ files outside the content tree, with sourceFile + extract set so the SPEC-066 expand rune can inline them. But those entities have no URL of their own — they don't appear as /specs/SPEC-001/ or any other route. Users wanting "per-spec pages" today must either mirror plan/ into content/ (gives URLs but loses peer-of-content semantics) or hand-author route files (loses single-source-of-truth).

2. Static plan-site replacement.

The plan CLI's plan serve / plan build give users a zero-config browseable site of their plan content. That's a parallel rendering path to the regular site one, with its own maintenance burden. If declarative route rules existed, a create-refrakt --template=plan-site scaffold could express the same site in ~40 lines of config, sharing one rendering path with every other refrakt site. Removing plan serve's separate implementation requires exactly this primitive.

3. External data — inline, with or without routes.

Two distinct shapes here, and conflating them was the original framing mistake this spec corrects:

  • Inline, no route — pull tickets from an issue tracker (Jira, Linear, GitHub Issues, Trace), convert each to a refrakt entity, and display it inline via {% expand %} / {% backlog %} on an existing page. The ticket's canonical home stays in the tracker; the site shows a build-time snapshot. No new route is created. This is pure entity contribution and needs no page machinery at all.
  • Route per item — the JAMstack pattern: build a static site where each CMS document / DB row gets its own page (Astro content collections, Eleventy data files, Next.js getStaticProps, Hugo data templates). This is entity contribution plus a page layer.

4. The current registry contract assumes a backing file.

SPEC-066's embeddability contract is sourceFile + extract — read the file, run the extractor. An entity sourced from an external API has no file; its content is an in-memory payload converted to a Markdoc subtree. Today {% expand %} on such an entity fails with "does not support embedding". The contract needs to generalize from "file + extractor" to "anything that can produce a subtree on demand".

Today's options for any of these:

  • Mirror data into the content tree as a build step. Ugly: requires a pre-build script, leaves stale files when source changes, awkward git story.
  • Fork the content loader. Tied to internals; brittle across upgrades.
  • Build an adapter-specific data layer. Loses everything refrakt's pipeline does (xref resolution, rune transforms, themes, search indexing) and ties you to one adapter.

What's missing is two clean extension points: plugins can register entities from any source (entity axis), and plugins / config can declare additional routes (page axis).

Design Principles

Entity contribution is the fundamental axis; pages are a layer on top. The unit of contribution is the entity, not the page. "Make it a page" is one optional thing you can do with an entity — most external-data use cases (inline issue display, dashboards) never need routes. Designing entity-first keeps the no-route case first-class instead of something that falls out by accident.

One page-mechanism, two surfaces. On the page axis, the underlying primitive is "plugin contributes pages". Config rules (entityRoutes) are a built-in adapter that interprets the config as page contributions; the contributePages hook is the same mechanism for data that isn't entity-shaped. Don't ship two parallel page systems.

Symmetric with existing config patterns. Route rules look and feel like xrefPatterns and fileRoots. Same {name} substitution syntax, same per-site scoping, same file (refrakt.config.json). Authors who learned xref patterns recognize the shape; no new mental model.

User owns the route shape; plugin owns the entity. One site can put plan at /plan/specs/X/, another at /SPEC-X/. The plugin registers the entity and (optionally) its external canonical URL; the user's config decides the on-site route. When a route rule creates a page for an entity, it back-fills the entity's on-site URL so refs resolve there (see URL ownership below).

Plugin-contributed pages go through the normal pipeline. A virtual page contributed by a plugin is parsed, transformed, registered, aggregated, and post-processed exactly like a file-backed page. Embedded runes execute; xrefs inside the contributed content resolve via the host's xref pass; the page can be linked to by other refs; it appears in the sitemap, search index, and nav-auto graph. No exceptions — anything special about being virtual lives at the contribution boundary, not in the rest of the pipeline.

Build-time only. Contribution runs once per build, returns a fixed list. Pages aren't created lazily on request; the static-prerender enumeration sees the full set at build start. Rules out runtime data fetching by construction — keeps the deterministic-build property refrakt depends on.

Asynchronous by default. External-data plugins need to fetch over the network; both the entity-fetch (via the existing async configure hook) and the page hook (contributePages) support promises. Sync return is allowed for the config-rules adapter (no IO) and other in-memory cases.

No secrets system. Plugins that need API keys read from process.env like any Node code. Refrakt doesn't invent a secrets layer or a credentials store. This is a deliberate non-feature; we expect users to use standard .env workflows (dotenv, host env vars, etc.).

Caching is plugin-owned, not core. A hook that hits an HTTP API on every build is the plugin's problem to optimize. Plugins decide whether to cache responses, use ETags, key off content hashes, etc. Refrakt provides the hook timing; it doesn't try to be a build cache. This keeps the contract narrow.

Authoring Surface

Entity axis — registering entities from any source

The fundamental surface. A plugin fetches data (in the async configure hook, which already exists and runs once per build) and registers entities in its register hook. Entities sourced externally provide their embeddable content via an embed() function rather than a sourceFile:

// @refrakt-md/jira — display tracker tickets inline, no routes
export const jiraPlugin: Plugin = {
  name: '@refrakt-md/jira',
  async configure(opts) {
    // Fetch once per build; stash for the register hook.
    this._issues = await fetchJiraIssues(process.env.JIRA_TOKEN!);
  },
  pipeline: {
    register(_pages, registry) {
      for (const issue of this._issues) {
        registry.register({
          type: 'spec',                       // masquerades as a plan type so {% backlog %} lists it
          id: issue.key,                      // "PROJ-123"
          canonicalUrl: issue.browseUrl,      // links {% ref %} out to Jira
          data: { title: issue.summary, status: issue.status },
          embed: () => jiraToMarkdoc(issue),  // file-less embeddable content
        });
      }
    },
  },
};