Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 24 additions & 4 deletions crates/rw-napi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Vec<PageEntryResponse>> {
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<PageResponse> {
let site = Arc::clone(&self.site);
Expand Down
12 changes: 12 additions & 0 deletions crates/rw-napi/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ pub struct SectionEntryResponse {
pub ancestors: Vec<String>,
}

#[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<NavItemResponse>,
Expand Down
2 changes: 1 addition & 1 deletion crates/rw-site/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
43 changes: 42 additions & 1 deletion crates/rw-site/src/site.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Vec<PageEntry>, StorageError> {
Ok(self.reload_if_needed()?.state.list_pages())
}

/// Returns the current [`Sections`] map for cross-section link resolution.
///
/// # Errors
Expand Down Expand Up @@ -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()
Expand Down
186 changes: 186 additions & 0 deletions crates/rw-site/src/site_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@ pub struct SectionEntry {
pub ancestors: Vec<String>,
}

/// 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.
///
Expand Down Expand Up @@ -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<PageEntry> {
let mut entries: Vec<PageEntry> = 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.
///
Expand Down Expand Up @@ -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();
Expand Down
13 changes: 13 additions & 0 deletions packages/core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
export declare class RwSite {
getNavigation(sectionRef?: string | undefined | null): Promise<NavigationResponse>
listSections(): Promise<Array<SectionEntryResponse>>
listPages(): Promise<Array<PageEntryResponse>>
renderPage(path: string): Promise<PageResponse>
renderSearchDocument(path: string): Promise<SearchDocumentResponse | null>
reload(force?: boolean | undefined | null): Promise<boolean>
Expand Down Expand Up @@ -34,6 +35,18 @@ export interface NavItemResponse {
children?: Array<NavItemResponse>
}

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
Expand Down
Loading