diff --git a/CHANGELOG.md b/CHANGELOG.md index 448be565..1d3e6d7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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). + ### Fixed - Opening an inline-comment deep link (`#comment-`) no longer leaves the comment thread pinned in the wrong vertical position. The thread in the right-margin column (and the narrow-screen comment popover) could land hundreds of pixels above its highlighted passage and stay there when content above the passage reflowed *after* the thread was positioned — e.g. a web-font swap on first load, or a late-loading image or diagram. Threads now re-align whenever their highlighted passage moves, not only when the article is resized, so they track the highlight through any late layout shift. A normal click was never affected (it happens after the page has settled). diff --git a/crates/rw-napi/src/lib.rs b/crates/rw-napi/src/lib.rs index 5667a252..14beb496 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, Site}; +use rw_site::{NavItem, 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, SectionResponse, SiteConfig, - TocEntryResponse, + PageResponse, ScopeInfoResponse, SearchDocumentResponse, SectionEntryResponse, SectionResponse, + SiteConfig, TocEntryResponse, }; /// Shared tokio runtime for all S3-backed storage instances. @@ -204,6 +204,26 @@ impl RwSite { .map_err(|e| napi::Error::from_reason(e.to_string()))? } + #[napi] + pub async fn list_sections(&self) -> Result> { + let site = Arc::clone(&self.site); + tokio::task::spawn_blocking(move || { + let sections = site + .list_sections() + .map_err(|e| napi::Error::from_reason(e.display_chain()))?; + Ok(sections + .into_iter() + .map(|s: SectionEntry| SectionEntryResponse { + section_ref: s.section_ref, + path: s.path, + ancestors: s.ancestors, + }) + .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 e02def90..b4357fb6 100644 --- a/crates/rw-napi/src/types.rs +++ b/crates/rw-napi/src/types.rs @@ -63,6 +63,18 @@ pub struct ScopeInfoResponse { pub section: SectionResponse, } +#[napi(object)] +pub struct SectionEntryResponse { + /// Canonical section ref (`kind:namespace/name`). Named `sectionRef` in JS + /// to match `PageMeta.sectionRef`. + #[napi(js_name = "sectionRef")] + pub section_ref: String, + /// Scope path, no leading slash (`""` for the root section). + pub path: String, + /// Ancestor section refs, nearest-first with the root last; excludes self. + pub ancestors: Vec, +} + #[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 eef3648d..78d64531 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}; +pub use site_state::{NavItem, Navigation, 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 153e26ee..4ca1a00a 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, SiteState, SiteStateBuilder}; +use crate::site_state::{Navigation, SectionEntry, SiteState, SiteStateBuilder}; use rw_cache::{Cache, CacheBucket}; use rw_kroki::{EntityInfo, MetaIncludeSource}; use rw_renderer::TitleResolver; @@ -208,6 +208,19 @@ impl Site { Ok(nav) } + /// Returns every section in the site as a flat list — the unscoped + /// counterpart to [`navigation`](Self::navigation). See + /// [`SiteState::list_sections`]. + /// + /// # 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_sections(&self) -> Result, StorageError> { + Ok(self.reload_if_needed()?.state.list_sections()) + } + /// Returns the current [`Sections`] map for cross-section link resolution. /// /// # Errors @@ -1496,6 +1509,25 @@ mod tests { ); } + #[test] + fn list_sections_returns_sections_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 sections = site.list_sections().expect("list_sections"); + + // The explicit billing section is present... + let billing = sections + .iter() + .find(|s| s.section_ref == "domain:default/billing") + .expect("billing section"); + assert_eq!(billing.path, "billing"); + // ...and the root section is always present. + assert!(sections.iter().any(|s| s.path.is_empty())); + } + #[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 cb8f0c50..e155af7d 100644 --- a/crates/rw-site/src/site_state.rs +++ b/crates/rw-site/src/site_state.rs @@ -45,6 +45,24 @@ pub struct NavItem { pub children: Vec, } +/// A single section in the flat hierarchy, as returned by +/// [`SiteState::list_sections`]. +/// +/// Unlike [`NavItem`], which is scoped (nested sections appear as childless +/// leaves), every section in the site appears here once — with its canonical +/// ref, scope path, title, and full ancestry — so a consumer can build a +/// nearest-ancestor roll-up in one pass without per-section round trips. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SectionEntry { + /// Canonical section ref (`kind:namespace/name`). + pub section_ref: String, + /// Scope path, no leading slash (`""` for the root section). + pub path: String, + /// Ancestor section refs, nearest-first with the root section last; + /// excludes the section itself. Empty for the root section. + pub ancestors: Vec, +} + /// Describes which [section](crate#sections-and-scoped-navigation) the /// navigation sidebar is currently showing. /// @@ -471,6 +489,62 @@ impl SiteState { ) } + /// Returns every section in the site as a flat list, sorted by scope path + /// (the root section's empty path sorts first). + /// + /// Includes the implicit or explicit root section. Each entry carries the + /// canonical ref, scope path, and full ancestry — see + /// [`SectionEntry`]. This is the unscoped counterpart to + /// [`navigation`](Self::navigation): it never hides nested sections behind a + /// scope, so the whole hierarchy is available in one call. + #[must_use] + pub fn list_sections(&self) -> Vec { + let mut entries: Vec = self + .sections + .iter() + .map(|(path, section)| SectionEntry { + section_ref: section.to_string(), + path: path.to_owned(), + ancestors: self.section_ancestors(path), + }) + .collect(); + // Scope paths are unique map keys, so stability is irrelevant. + entries.sort_unstable_by(|a, b| a.path.cmp(&b.path)); + entries + } + + /// Returns the ancestor section refs for the section at `path`, nearest-first + /// with the root section last, excluding the section itself. + /// + /// Walks parent paths (`rsplit_once('/')`) collecting any registered section, + /// then appends the universal root section (always present via + /// [`Sections::with_implicit_root`]). The root section itself has no + /// ancestors. Mirrors the prefix walk in + /// [`find_parent_section`](Self::find_parent_section), which returns only the + /// nearest parent. + fn section_ancestors(&self, path: &str) -> Vec { + if path.is_empty() { + return Vec::new(); + } + + let mut ancestors = Vec::new(); + let mut current = path; + while let Some((parent, _)) = current.rsplit_once('/') { + if let Some(section) = self.sections.get(parent) { + ancestors.push(section.to_string()); + } + current = parent; + } + + // The universal root ancestor. The loop above never yields `parent == ""` + // (a top-level path has no '/'), so this cannot double-count. + if let Some(root) = self.sections.get("") { + ancestors.push(root.to_string()); + } + + ancestors + } + /// Inverse of [`section_location`](Self::section_location): the page URL /// path for a `(section_ref, subpath)` pair, or `None` if no section has /// that ref. @@ -2041,6 +2115,228 @@ mod tests { assert_eq!(subpath, ""); } + // list_sections tests + + fn nested_sections_site() -> SiteState { + // root (no kind) -> billing (domain) -> payments (system) -> api (page) + 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(), + ); + let billing_idx = builder.add_page( + Page { + title: "Billing".to_owned(), + path: "billing".to_owned(), + has_content: true, + ..Default::default() + }, + Some(root_idx), + Some("domain"), + Namespace::default(), + ); + let payments_idx = builder.add_page( + Page { + title: "Payments".to_owned(), + path: "billing/payments".to_owned(), + has_content: true, + ..Default::default() + }, + Some(billing_idx), + Some("system"), + Namespace::default(), + ); + builder.add_page( + Page { + title: "API".to_owned(), + path: "billing/payments/api".to_owned(), + has_content: true, + ..Default::default() + }, + Some(payments_idx), + None, + Namespace::default(), + ); + builder.build() + } + + #[test] + fn list_sections_includes_root_and_all_sections_sorted_by_path() { + let site = nested_sections_site(); + let entries = site.list_sections(); + + // root + billing + payments + let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect(); + assert_eq!(paths, vec!["", "billing", "billing/payments"]); + + let refs: Vec<&str> = entries.iter().map(|e| e.section_ref.as_str()).collect(); + assert_eq!( + refs, + vec![ + "section:default/root", + "domain:default/billing", + "system:default/payments" + ] + ); + } + + #[test] + fn list_sections_root_entry_has_no_ancestors() { + let site = nested_sections_site(); + let entries = site.list_sections(); + let root = entries.iter().find(|e| e.path.is_empty()).unwrap(); + assert_eq!(root.section_ref, "section:default/root"); + assert!(root.ancestors.is_empty()); + } + + #[test] + fn list_sections_ancestors_are_nearest_first_root_last() { + let site = nested_sections_site(); + let entries = site.list_sections(); + + let billing = entries.iter().find(|e| e.path == "billing").unwrap(); + assert_eq!(billing.ancestors, vec!["section:default/root".to_owned()]); + + let payments = entries + .iter() + .find(|e| e.path == "billing/payments") + .unwrap(); + assert_eq!( + payments.ancestors, + vec![ + "domain:default/billing".to_owned(), + "section:default/root".to_owned() + ] + ); + } + + #[test] + fn list_sections_ancestry_skips_non_section_intermediate() { + // billing (domain) -> sub (plain directory, NOT a section) -> deep + // (system). deep's ancestry must skip the non-section "sub" and be + // [billing, root] — exercising the sections.get(parent) skip branch. + let mut builder = SiteStateBuilder::new(); + let billing_idx = builder.add_page( + Page { + title: "Billing".to_owned(), + path: "billing".to_owned(), + has_content: true, + ..Default::default() + }, + None, + Some("domain"), + Namespace::default(), + ); + let sub_idx = builder.add_page( + Page { + title: "Sub".to_owned(), + path: "billing/sub".to_owned(), + has_content: true, + ..Default::default() + }, + Some(billing_idx), + None, // no kind -> not a section + Namespace::default(), + ); + builder.add_page( + Page { + title: "Deep".to_owned(), + path: "billing/sub/deep".to_owned(), + has_content: true, + ..Default::default() + }, + Some(sub_idx), + Some("system"), + Namespace::default(), + ); + let site = builder.build(); + let entries = site.list_sections(); + + let deep = entries + .iter() + .find(|e| e.path == "billing/sub/deep") + .unwrap(); + assert_eq!(deep.section_ref, "system:default/deep"); + assert_eq!( + deep.ancestors, + vec![ + "domain:default/billing".to_owned(), + "section:default/root".to_owned() + ] + ); + } + + #[test] + fn list_sections_root_ref_honors_custom_root_namespace() { + // A site whose root declares `namespace: payments` yields + // `section:payments/root`, not a hardcoded-default `section:default/root`. + // list_sections and section_location must agree, so a consumer can key + // root-level pages off one canonical ref instead of a brittle constant. + // (issue #567 follow-up.) + let mut builder = SiteStateBuilder::new().root_namespace("payments".parse().unwrap()); + builder.add_page( + Page { + title: "Guide".to_owned(), + path: "guide".to_owned(), + has_content: true, + ..Default::default() + }, + None, + None, + "payments".parse().unwrap(), + ); + let site = builder.build(); + let entries = site.list_sections(); + + let root = entries.iter().find(|e| e.path.is_empty()).unwrap(); + assert_eq!(root.section_ref, "section:payments/root"); + // section_location of a root-level page resolves to the same custom-ns root. + assert_eq!(site.section_location("guide").0, "section:payments/root"); + } + + #[test] + fn list_sections_honors_explicit_root_kind() { + 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, + Some("component"), + Namespace::default(), + ); + builder.add_page( + Page { + title: "Billing".to_owned(), + path: "billing".to_owned(), + has_content: true, + ..Default::default() + }, + Some(root_idx), + Some("domain"), + Namespace::default(), + ); + let site = builder.build(); + let entries = site.list_sections(); + + let root = entries.iter().find(|e| e.path.is_empty()).unwrap(); + assert_eq!(root.section_ref, "component:default/root"); + + // The explicit root is still the universal ancestor of top-level sections. + let billing = entries.iter().find(|e| e.path == "billing").unwrap(); + assert_eq!(billing.ancestors, vec!["component:default/root".to_owned()]); + } + #[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 dcf1b7a4..17175af7 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -2,6 +2,7 @@ /* eslint-disable */ export declare class RwSite { getNavigation(sectionRef?: string | undefined | null): Promise + listSections(): Promise> renderPage(path: string): Promise renderSearchDocument(path: string): Promise reload(force?: boolean | undefined | null): Promise @@ -89,6 +90,18 @@ export interface SearchDocumentResponse { text: string } +export interface SectionEntryResponse { + /** + * Canonical section ref (`kind:namespace/name`). Named `sectionRef` in JS + * to match `PageMeta.sectionRef`. + */ + sectionRef: string + /** Scope path, no leading slash (`""` for the root section). */ + path: string + /** Ancestor section refs, nearest-first with the root last; excludes self. */ + ancestors: Array +} + export interface SectionResponse { kind: string namespace: string diff --git a/packages/core/test/list-sections.test.mjs b/packages/core/test/list-sections.test.mjs new file mode 100644 index 00000000..fbd0071f --- /dev/null +++ b/packages/core/test/list-sections.test.mjs @@ -0,0 +1,59 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +const require = createRequire(import.meta.url) +const { createSite } = require('../index.js') + +// Builds a temp project: root (no kind) -> billing (domain) -> payments +// (system) -> api (plain page). An rw.toml is required so `source_dir` +// resolves to `/docs` rather than the process cwd. +function fixtureSite() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rw-core-sections-')) + fs.writeFileSync(path.join(root, 'rw.toml'), '') + const docs = path.join(root, 'docs') + fs.mkdirSync(path.join(docs, 'billing', 'payments'), { recursive: true }) + fs.writeFileSync(path.join(docs, 'index.md'), '# Home\n') + fs.writeFileSync( + path.join(docs, 'billing', 'index.md'), + '---\nkind: domain\n---\n# Billing\n', + ) + fs.writeFileSync( + path.join(docs, 'billing', 'payments', 'index.md'), + '---\nkind: system\n---\n# Payments\n', + ) + fs.writeFileSync(path.join(docs, 'billing', 'payments', 'api.md'), '# API\n') + return { site: createSite({ projectDir: root }), root } +} + +test('listSections returns the full hierarchy flat with ancestry', async () => { + const { site, root } = fixtureSite() + try { + const sections = await site.listSections() + const byPath = Object.fromEntries(sections.map((s) => [s.path, s])) + + // root + billing + payments, sorted by path (root first). + assert.deepEqual( + sections.map((s) => s.path), + ['', 'billing', 'billing/payments'], + ) + + assert.equal(byPath['billing'].sectionRef, 'domain:default/billing') + assert.deepEqual(byPath['billing'].ancestors, ['section:default/root']) + + assert.equal(byPath['billing/payments'].sectionRef, 'system:default/payments') + assert.deepEqual(byPath['billing/payments'].ancestors, [ + 'domain:default/billing', + 'section:default/root', + ]) + + // The root section has no ancestors. + assert.equal(byPath[''].sectionRef, 'section:default/root') + assert.deepEqual(byPath[''].ancestors, []) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +})