Skip to content

napi: expose pagePathFor(sectionRef, subpath) so pages are addressable by their canonical identity #650

Description

@yumike

Problem

rw's canonical page identity is (sectionRef, subpath) — it's what listPages() returns, what PageMeta exposes, and what comments are keyed by. But every read API on the napi surface is keyed by full site path: renderPage(path), renderSearchDocument(path), getPageMarkdown(path).

So any JS consumer holding a page identity has to turn it into a path before it can read the page. Today that means re-deriving the section's scope path (listSections() → find section → join) — i.e. reimplementing, in TypeScript, a resolver rw already owns.

Because rw already has the resolver:

// crates/rw-site/src/site.rs
pub fn page_path_for(&self, section_ref: &str, subpath: &str) -> Option<String>

It's the exact inverse of SiteState::section_location (the function list_pages() uses to produce the identity), it reloads internally so cold sites resolve correctly, and rw-comments already uses it for precisely this purpose — mapping a sectionRef#subpath key to a page (crates/rw-comments/src/creation.rs).

It just isn't exposed through napi.

Proposal

Expose it:

RwSite.pagePathFor(sectionRef: string, subpath: string): Promise<string | null>

Purely additive — no existing signature or type changes. It wraps Site::page_path_for directly; Nonenull.

Why the primitive rather than re-keying a method

The alternative is re-keying the read methods to take (sectionRef, subpath). That solves it one method at a time and churns public signatures. Exposing the resolver once makes every path-keyed API — renderPage, renderSearchDocument, getPageMarkdown — addressable by page identity, and keeps rw the single owner of identity→path resolution instead of having each embedding host grow its own copy in another language.

Cost is one extra napi call per read (pagePathFor then getPageMarkdown), both an atomic snapshot load plus a lookup — negligible next to rendering.

Note page_path_for returns Option, collapsing "no such section ref" and "no such page" into one null. That's fine for the caller I have in mind (a 404 either way), but worth stating.

What it unblocks

rwdocs/backstage-plugins#102 — the Backstage search collator emits documents that can't be resolved back to the page they describe (location strips the section scope prefix and isn't round-trippable). The fix is for the collator to emit rw's own page identity (siteRef + sectionRef + subpath), and for the rw-backend plugin to serve a page's Markdown from that identity:

GET /site/:ns/:kind/:name/markdown?sectionRef=…&subpath=…
  → site.pagePathFor(sectionRef, subpath)   // null → 404
  → site.getPageMarkdown(path)

With pagePathFor, that route is a thin composition. Without it, the plugin has to carry a TypeScript reimplementation of page_path_for that must track rw's section semantics.

Together with getPageMarkdown (#648) this makes an MCP docs-retrieval tool straightforward: search hit → identity → Markdown, with no path arithmetic anywhere in the consumer.

Happy to send the PR if the shape looks right.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions