From 230146e4ffa0c540d642342dd95c76459d9e0393 Mon Sep 17 00:00:00 2001 From: Mike Yumatov Date: Wed, 24 Jun 2026 07:07:59 +0300 Subject: [PATCH] feat(napi): add RwSite.listPages() enumerating pages with titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #576. Add a reload-aware napi method that enumerates every page in a site, each with its title and its (sectionRef, subpath) key — the same pair the comment system uses as a page's document_id. This lets a host (the Backstage doc-comment inbox) cache human-readable page titles in one cheap pass when a site is already loaded, instead of N+1 per-page renderPage() calls or repeated S3 site loads at serve time. Implemented as a direct three-layer mirror of the existing listSections(): - SiteState::list_pages() walks SiteState.pages, computes each page's (section_ref, subpath) via the existing section_location seam, reads the title, and sorts by (section_ref, subpath). New public PageEntry struct. - Site::list_pages() reloads-if-needed then delegates (mirrors list_sections). - napi listPages() wraps it in spawn_blocking; new PageEntryResponse type; regenerated index.d.ts -> listPages(): Promise>. Every page is included — the root page (empty subpath) and virtual directory pages — so any comment keyed to one still resolves a title. Fields are intentionally minimal (sectionRef, subpath, title); path/ancestors/order are omitted and can be added additively later. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + CLAUDE.md | 2 +- crates/rw-napi/src/lib.rs | 28 ++++- crates/rw-napi/src/types.rs | 12 ++ crates/rw-site/src/lib.rs | 2 +- crates/rw-site/src/site.rs | 43 ++++++- crates/rw-site/src/site_state.rs | 186 +++++++++++++++++++++++++++++++ packages/core/index.d.ts | 13 +++ 8 files changed, 280 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bdb0a9d..241ed4a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `@rwdocs/core` exposes `RwSite.listSections()`, which returns every documentation section in one call — flat, each with its canonical ref (`kind:namespace/name`), scope path, and full nearest-first ancestry (root last) — so a host no longer needs N+1 `getNavigation()` calls to walk nested sections (which deliberately hide sub-sections as childless leaves). +- `@rwdocs/core` exposes `RwSite.listPages()`, which enumerates every page in a site in one pass — each with its title and its `(sectionRef, subpath)` key (the same pair comments use as a page's `document_id`) — so a host can cache human-readable page titles (e.g. for a comment inbox) without an N+1 of per-page `renderPage()` calls. The site's root page and virtual directory pages are included. ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 8c7ceced..fe6a0b10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,7 +205,7 @@ crates/ │ ├── rw-napi/ # Node.js native addon (napi-rs bindings, excluded from workspace) │ └── src/ # Standalone crate: cdylib can't build for musl with cargo --workspace -│ ├── lib.rs # RwSite, create_site, render_page, get_navigation +│ ├── lib.rs # RwSite, create_site, render_page, get_navigation, list_sections, list_pages │ └── types.rs # Napi-compatible response types │ ├── rw-config/ # Configuration parsing diff --git a/crates/rw-napi/src/lib.rs b/crates/rw-napi/src/lib.rs index 14beb496..54ef826c 100644 --- a/crates/rw-napi/src/lib.rs +++ b/crates/rw-napi/src/lib.rs @@ -11,15 +11,15 @@ use napi_derive::napi; use rw_cache::{Cache, NullCache}; use rw_cache_s3::S3Cache; use rw_config::Config; -use rw_site::{NavItem, PageRendererConfig, ScopeInfo, SectionEntry, Site}; +use rw_site::{NavItem, PageEntry, PageRendererConfig, ScopeInfo, SectionEntry, Site}; use rw_storage::Storage; use rw_storage_fs::FsStorage; use rw_storage_s3::{S3Config, S3Storage}; use crate::types::{ - BreadcrumbResponse, DiagramsConfig, NavItemResponse, NavigationResponse, PageMetaResponse, - PageResponse, ScopeInfoResponse, SearchDocumentResponse, SectionEntryResponse, SectionResponse, - SiteConfig, TocEntryResponse, + BreadcrumbResponse, DiagramsConfig, NavItemResponse, NavigationResponse, PageEntryResponse, + PageMetaResponse, PageResponse, ScopeInfoResponse, SearchDocumentResponse, SectionEntryResponse, + SectionResponse, SiteConfig, TocEntryResponse, }; /// Shared tokio runtime for all S3-backed storage instances. @@ -224,6 +224,26 @@ impl RwSite { .map_err(|e| napi::Error::from_reason(e.to_string()))? } + #[napi(js_name = "listPages")] + pub async fn list_pages(&self) -> Result> { + let site = Arc::clone(&self.site); + tokio::task::spawn_blocking(move || { + let pages = site + .list_pages() + .map_err(|e| napi::Error::from_reason(e.display_chain()))?; + Ok(pages + .into_iter() + .map(|p: PageEntry| PageEntryResponse { + section_ref: p.section_ref, + subpath: p.subpath, + title: p.title, + }) + .collect()) + }) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))? + } + #[napi] pub async fn render_page(&self, path: String) -> Result { let site = Arc::clone(&self.site); diff --git a/crates/rw-napi/src/types.rs b/crates/rw-napi/src/types.rs index b4357fb6..4b7a1f00 100644 --- a/crates/rw-napi/src/types.rs +++ b/crates/rw-napi/src/types.rs @@ -75,6 +75,18 @@ pub struct SectionEntryResponse { pub ancestors: Vec, } +#[napi(object)] +pub struct PageEntryResponse { + /// Canonical section ref (`kind:namespace/name`). Named `sectionRef` in JS + /// to match `PageMeta.sectionRef`. + #[napi(js_name = "sectionRef")] + pub section_ref: String, + /// Page path relative to its section root (`""` for the section root). + pub subpath: String, + /// Display title. + pub title: String, +} + #[napi(object)] pub struct NavigationResponse { pub items: Vec, diff --git a/crates/rw-site/src/lib.rs b/crates/rw-site/src/lib.rs index 78d64531..c06c5b62 100644 --- a/crates/rw-site/src/lib.rs +++ b/crates/rw-site/src/lib.rs @@ -118,7 +118,7 @@ pub use rw_sections::SectionPath; pub use rw_sections::Sections; pub use site::Site; -pub use site_state::{NavItem, Navigation, ScopeInfo, SectionEntry}; +pub use site_state::{NavItem, Navigation, PageEntry, ScopeInfo, SectionEntry}; /// A heading entry for building a table-of-contents sidebar. /// diff --git a/crates/rw-site/src/site.rs b/crates/rw-site/src/site.rs index 9fd49286..ba353da3 100644 --- a/crates/rw-site/src/site.rs +++ b/crates/rw-site/src/site.rs @@ -13,7 +13,7 @@ use crate::page::{ BreadcrumbItem, Page, PageRenderResult, PageRenderer, PageRendererConfig, RenderContext, RenderError, SearchDocument, }; -use crate::site_state::{Navigation, SectionEntry, SiteState, SiteStateBuilder}; +use crate::site_state::{Navigation, PageEntry, SectionEntry, SiteState, SiteStateBuilder}; use rw_cache::{Cache, CacheBucket}; use rw_kroki::{EntityInfo, MetaIncludeSource}; use rw_renderer::TitleResolver; @@ -221,6 +221,20 @@ impl Site { Ok(self.reload_if_needed()?.state.list_sections()) } + /// Returns every document (page) in the site, each keyed by its + /// `(section_ref, subpath)` pair and carrying its title — the per-page + /// counterpart to [`list_sections`](Self::list_sections). See + /// [`SiteState::list_pages`]. + /// + /// # Errors + /// + /// Returns [`StorageError`] if the initial site load fails (storage + /// unreachable). Subsequent reload failures are logged and stale data is + /// returned instead. + pub fn list_pages(&self) -> Result, StorageError> { + Ok(self.reload_if_needed()?.state.list_pages()) + } + /// Returns the current [`Sections`] map for cross-section link resolution. /// /// # Errors @@ -1529,6 +1543,33 @@ mod tests { assert!(sections.iter().any(|s| s.path.is_empty())); } + #[test] + fn list_pages_returns_pages_through_site() { + let storage = MockStorage::new() + .with_document_and_kind("billing", "Billing", "domain") + .with_document("billing/overview", "Overview"); + let site = create_site_with_storage(storage); + + let pages = site.list_pages().expect("list_pages"); + + // The page inside the billing section keys under that section ref with a + // section-relative subpath. + let overview = pages + .iter() + .find(|p| p.title == "Overview") + .expect("overview page"); + assert_eq!(overview.section_ref, "domain:default/billing"); + assert_eq!(overview.subpath, "overview"); + + // The billing section's own root page is present, empty subpath. + let billing = pages + .iter() + .find(|p| p.title == "Billing") + .expect("billing page"); + assert_eq!(billing.section_ref, "domain:default/billing"); + assert_eq!(billing.subpath, ""); + } + #[test] fn test_navigation_ordered_by_pages_field() { let storage = MockStorage::new() diff --git a/crates/rw-site/src/site_state.rs b/crates/rw-site/src/site_state.rs index 2e17308d..c2ed0f8e 100644 --- a/crates/rw-site/src/site_state.rs +++ b/crates/rw-site/src/site_state.rs @@ -63,6 +63,23 @@ pub struct SectionEntry { pub ancestors: Vec, } +/// A single page in the site, as returned by [`SiteState::list_pages`]. +/// +/// Keyed by the same `(section_ref, subpath)` pair the comment system uses as a +/// page's `document_id` (`PageMeta.sectionRef` + `PageMeta.subpath`), so a +/// consumer can join these entries directly against stored comments. See +/// [`list_pages`](SiteState::list_pages) for which pages are included. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PageEntry { + /// Canonical section ref (`kind:namespace/name`) of the enclosing section. + pub section_ref: String, + /// Page path relative to its section root (empty for the section's own + /// root page; the full path for pages outside any explicit section). + pub subpath: String, + /// Display title (metadata `title`, first H1, or filename). + pub title: String, +} + /// Describes which [section](crate#sections-and-scoped-navigation) the /// navigation sidebar is currently showing. /// @@ -513,6 +530,43 @@ impl SiteState { entries } + /// Returns every page in the site, each keyed by its `(section_ref, + /// subpath)` pair and carrying its title — the per-page counterpart to + /// [`list_sections`](Self::list_sections). + /// + /// Includes **every** page: the root page (empty `subpath`) and virtual + /// pages (directory containers without an `index.md`, which have a title + /// but no renderable body) among them. The `(section_ref, subpath)` key + /// matches what [`section_location`](Self::section_location) produces, so + /// it is byte-identical to the `document_id` the comment system stores. + /// + /// Each key comes from a per-page [`section_location`](Self::section_location) + /// lookup (a linear scan of the small section map), so this is + /// O(pages × sections) — fine at the expected scale. Sorted by + /// `(section_ref, subpath)` for a deterministic order; the key is unique + /// per page, so the sort never has to break ties. + #[must_use] + pub fn list_pages(&self) -> Vec { + let mut entries: Vec = self + .pages + .iter() + .map(|page| { + let (section_ref, subpath) = self.section_location(&page.path); + PageEntry { + section_ref, + subpath, + title: page.title.clone(), + } + }) + .collect(); + entries.sort_unstable_by(|a, b| { + a.section_ref + .cmp(&b.section_ref) + .then_with(|| a.subpath.cmp(&b.subpath)) + }); + entries + } + /// Returns the ancestor section refs for the section at `path`, nearest-first /// with the root section last, excluding the section itself. /// @@ -2338,6 +2392,138 @@ mod tests { assert_eq!(billing.ancestors, vec!["component:default/root".to_owned()]); } + // list_pages tests + + #[test] + fn list_pages_includes_every_page_with_title_and_key() { + // root (Home) -> billing (domain) -> billing/payments (system) + // -> billing/payments/api (page, no kind) + let site = nested_sections_site(); + let pages = site.list_pages(); + + // One entry per page (4 pages: root, billing, payments, api). + assert_eq!(pages.len(), 4); + + // Root page: keyed under the root section ref with an empty subpath. + let root = pages.iter().find(|p| p.title == "Home").unwrap(); + assert_eq!(root.section_ref, "section:default/root"); + assert_eq!(root.subpath, ""); + + // A page that is itself a section root keys under its own section, + // empty subpath (matches section_location). + let billing = pages.iter().find(|p| p.title == "Billing").unwrap(); + assert_eq!(billing.section_ref, "domain:default/billing"); + assert_eq!(billing.subpath, ""); + + // A page nested inside a section keys under that section with a + // section-relative subpath. + let api = pages.iter().find(|p| p.title == "API").unwrap(); + assert_eq!(api.section_ref, "system:default/payments"); + assert_eq!(api.subpath, "api"); + } + + #[test] + fn list_pages_page_outside_any_section_keys_under_root_with_full_path() { + // root -> guide (no kind, not a section) + let mut builder = SiteStateBuilder::new(); + let root_idx = builder.add_page( + Page { + title: "Home".to_owned(), + path: String::new(), + has_content: true, + ..Default::default() + }, + None, + None, + Namespace::default(), + ); + builder.add_page( + Page { + title: "Guide".to_owned(), + path: "guide".to_owned(), + has_content: true, + ..Default::default() + }, + Some(root_idx), + None, + Namespace::default(), + ); + let site = builder.build(); + let pages = site.list_pages(); + + let guide = pages.iter().find(|p| p.title == "Guide").unwrap(); + assert_eq!(guide.section_ref, "section:default/root"); + assert_eq!(guide.subpath, "guide"); + } + + #[test] + fn list_pages_includes_virtual_pages() { + // A directory container with no index.md is a virtual page + // (has_content == false) but still a page with a title and key. + let mut builder = SiteStateBuilder::new(); + let dir_idx = builder.add_page( + Page { + title: "Guides".to_owned(), + path: "guides".to_owned(), + has_content: false, + ..Default::default() + }, + None, + None, + Namespace::default(), + ); + builder.add_page( + Page { + title: "Intro".to_owned(), + path: "guides/intro".to_owned(), + has_content: true, + ..Default::default() + }, + Some(dir_idx), + None, + Namespace::default(), + ); + let site = builder.build(); + let pages = site.list_pages(); + + let dir = pages.iter().find(|p| p.title == "Guides").unwrap(); + assert_eq!(dir.section_ref, "section:default/root"); + assert_eq!(dir.subpath, "guides"); + } + + #[test] + fn list_pages_keys_round_trip_through_page_path_for() { + // Every (section_ref, subpath) list_pages emits must reverse-map + // (via page_path_for) back to a real page path — i.e. it agrees with + // section_location / page_path_for. + let site = nested_sections_site(); + let pages = site.list_pages(); + + for page in &pages { + let path = site + .page_path_for(&page.section_ref, &page.subpath) + .unwrap_or_else(|| panic!("no path for {page:?}")); + assert!( + site.get_page(&path).is_some(), + "round-tripped path {path:?} is not a real page" + ); + } + } + + #[test] + fn list_pages_sorted_by_section_ref_then_subpath() { + let site = nested_sections_site(); + let pages = site.list_pages(); + + let keys: Vec<(String, String)> = pages + .iter() + .map(|p| (p.section_ref.clone(), p.subpath.clone())) + .collect(); + let mut sorted = keys.clone(); + sorted.sort(); + assert_eq!(keys, sorted); + } + #[test] fn test_section_location_multi_segment_page_not_in_section() { let mut builder = SiteStateBuilder::new(); diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index 17175af7..c823d46d 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -3,6 +3,7 @@ export declare class RwSite { getNavigation(sectionRef?: string | undefined | null): Promise listSections(): Promise> + listPages(): Promise> renderPage(path: string): Promise renderSearchDocument(path: string): Promise reload(force?: boolean | undefined | null): Promise @@ -34,6 +35,18 @@ export interface NavItemResponse { children?: Array } +export interface PageEntryResponse { + /** + * Canonical section ref (`kind:namespace/name`). Named `sectionRef` in JS + * to match `PageMeta.sectionRef`. + */ + sectionRef: string + /** Page path relative to its section root (`""` for the section root). */ + subpath: string + /** Display title. */ + title: string +} + export interface PageMetaResponse { title?: string path: string