diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 3d9f5a137..389c0359c 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -44,6 +44,8 @@ import { MetadataHmrReloader } from './components/MetadataHmrReloader'; import SharedRecordPage from './pages/SharedRecordPage'; import DocPage from './pages/DocPage'; import DocsIndex from './pages/DocsIndex'; +import DocsSlug from './pages/DocsSlug'; +import DocsLayout from './pages/DocsLayout'; import { LoginPage } from './pages/auth/LoginPage'; import { RegisterPage } from './pages/auth/RegisterPage'; import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage'; @@ -184,16 +186,22 @@ export function App() { * lists every installed `doc` (grouped by package), and one * viewer route renders any item; cross-references between docs * resolve to that same viewer route. Both are app-independent. */} + {/* Docs portal (ADR-0046 §6). The layout fetches book + doc once + * and shares it with every child route via context. Children: + * index → book index + * :slug → a book landing, or a flat-doc permalink that + * redirects to its canonical /docs// + * :slug/:name → in-book reader (doc identity stays single- + * coordinate; the book segment is derived nav). */} - + - } /> - - - - } /> + }> + } /> + } /> + } /> + diff --git a/apps/console/src/AppContent.tsx b/apps/console/src/AppContent.tsx index 3e036c62a..03f052aa7 100644 --- a/apps/console/src/AppContent.tsx +++ b/apps/console/src/AppContent.tsx @@ -35,6 +35,8 @@ const IntegrationsPage = lazy(() => import('./pages/developer/IntegrationsPage') // app-scoped index at /apps/:packageId/docs (AppDocsIndex). const DocPage = lazy(() => import('./pages/DocPage')); const AppDocsIndex = lazy(() => import('./pages/AppDocsIndex')); +const DocsSlug = lazy(() => import('./pages/DocsSlug')); +const DocsLayout = lazy(() => import('./pages/DocsLayout')); // Note: marketplace routes (`system/marketplace`, `system/marketplace/:packageId`) // are registered by DefaultAppContent in @object-ui/app-shell so they're @@ -91,8 +93,14 @@ const systemRoutes = ( }>} /> {/* ADR-0048 — package docs under the package container: app-scoped index (/apps/:packageId/docs) + single-doc viewer (/apps/:packageId/docs/:name) */} - }>} /> - }>} /> + {/* ADR-0046 §6 — the docs layout fetches book + doc once and shares it with + the app-scoped index, book landings, and reader (one segment resolves to + a book landing or redirects a flat doc to /docs//). */} + }>}> + }>} /> + }>} /> + }>} /> + {/* Legacy URL redirects → metadata-admin engine (zero-cost compatibility). */} } /> } /> diff --git a/apps/console/src/pages/AppDocsIndex.tsx b/apps/console/src/pages/AppDocsIndex.tsx index 7126eb530..7ad73c045 100644 --- a/apps/console/src/pages/AppDocsIndex.tsx +++ b/apps/console/src/pages/AppDocsIndex.tsx @@ -6,77 +6,33 @@ * LICENSE file in the root directory of this source tree. */ -import { useEffect, useState } from 'react'; -import { Link, useParams } from 'react-router-dom'; -import { AlertCircle, BookOpen, Loader2 } from 'lucide-react'; -import { useAdapter } from '@object-ui/app-shell'; +import { useMemo } from 'react'; +import { Link, Navigate, useParams } from 'react-router-dom'; +import { BookOpen, Loader2 } from 'lucide-react'; import { DocShell } from './DocShell'; - -interface AppDocItem { - name: string; - label?: string; - description?: string; -} +import { useBookData } from './use-book-data'; +import { buildBookCards, pkgOfBook, type Book } from './book-nav'; /** - * `/apps/:appName/docs` — the app-scoped documentation index (ADR-0046/0048). + * `/apps/:appName/docs` — the app-scoped documentation index (ADR-0046 §6 / + * ADR-0048). The package-scoped sibling of the platform `/docs` portal: it + * shows the books owned by the app whose container this route renders inside + * (`:appName` is the package-id segment). * - * The platform `/docs` portal lists *every* installed doc grouped by package; - * this is its package-scoped sibling: the docs owned by the app whose - * container this route renders inside (`:appName` is the package-id segment, - * matched against each doc's `_packageId`). It is the landing target for the - * header Help menu's "This app's docs" entry when an app ships more than one - * doc — a single-doc app deep-links straight to the viewer instead. - * - * Like the rest of the doc routes it degrades softly: an app with no docs - * shows an empty-state notice, never a hard error. + * Book-driven like the portal: a package always has at least its implicit book + * (§6.4). The common single-book case lands straight on that book's grouped + * reader; a package with several books lists them. Reads the shared book/doc + * data provided by the `/docs` layout — no separate fetch. */ export default function AppDocsIndex() { - // `appName` is the parent route's package-id segment (/apps/:appName/docs). const { appName } = useParams<{ appName?: string }>(); - const adapter = useAdapter(); - const [docs, setDocs] = useState([]); - const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading'); - const [errorMessage, setErrorMessage] = useState(''); + const { books, docs, state } = useBookData(); - useEffect(() => { - let cancelled = false; - async function load() { - if (!adapter) return; - const client: any = adapter.getClient(); - if (!client?.meta?.getItems) { - setErrorMessage('meta.getItems is not available on this client'); - setState('error'); - return; - } - setState('loading'); - try { - const result: any = await client.meta.getItems('doc'); - const items: any[] = Array.isArray(result) - ? result - : Array.isArray(result?.items) - ? result.items - : Array.isArray(result?.value) - ? result.value - : []; - if (cancelled) return; - const mine = items - .filter((it) => it && it.name && it._packageId === appName) - .map((it) => ({ name: it.name, label: it.label, description: it.description })) - .sort((a, b) => (a.label ?? a.name).localeCompare(b.label ?? b.name)); - setDocs(mine); - setState('ready'); - } catch (err: any) { - if (cancelled) return; - setErrorMessage(err?.message ?? 'Failed to load documentation'); - setState('error'); - } - } - void load(); - return () => { - cancelled = true; - }; - }, [adapter, appName]); + const myBooks = useMemo( + () => books.filter((b) => pkgOfBook(b) === appName), + [books, appName], + ); + const cards = useMemo(() => buildBookCards(myBooks, docs), [myBooks, docs]); if (state === 'loading') { return ( @@ -86,45 +42,45 @@ export default function AppDocsIndex() { ); } - if (state === 'error') { - return ( -
- -

Failed to load documentation

-

{errorMessage}

-
- ); + // One book (the usual case) → land directly on its grouped reader. + if (cards.length === 1) { + return ; } return (
- {docs.length === 0 ? ( + {cards.length === 0 ? (
-

- This app does not ship any documentation yet. -

+

This app does not ship any documentation yet.

Browse all documentation
) : ( -
    - {docs.map((doc) => ( -
  • +
      + {cards.map((card) => ( +
    • - +
      -
      {doc.label ?? doc.name}
      - {doc.description ? ( +
      {card.label}
      + {card.description ? (
      - {doc.description} + {card.description} +
      + ) : card.subtitle ? ( +
      + {card.subtitle}
      ) : null} +
      + {card.docCount} {card.docCount === 1 ? 'article' : 'articles'} +
    • diff --git a/apps/console/src/pages/BookPage.tsx b/apps/console/src/pages/BookPage.tsx new file mode 100644 index 000000000..950a5efe4 --- /dev/null +++ b/apps/console/src/pages/BookPage.tsx @@ -0,0 +1,68 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useMemo } from 'react'; +import { Navigate, useParams } from 'react-router-dom'; +import { BookOpen, FileQuestion, Loader2 } from 'lucide-react'; +import { DocShell } from './DocShell'; +import { useBookData } from './use-book-data'; +import { resolveBookTree, scopeDocsToBook, bookSlug, firstDoc } from './book-nav'; + +/** + * `/docs/:slug` — a book landing (ADR-0046 §6). Opening a book means starting + * to read it, so this resolves the book's spine and redirects to its overview + * doc (`*_index`, else the first doc in order); the reader then shows that doc + * with the book sidebar. This avoids duplicating the table of contents (the + * sidebar already is it). A book with no readable docs shows an empty state. + */ +export default function BookPage() { + const { slug, appName } = useParams<{ slug: string; appName?: string }>(); + const { books, docs, state } = useBookData(); + + const found = useMemo(() => books.find((b) => bookSlug(b) === slug), [books, slug]); + const opensTo = useMemo( + () => (found ? firstDoc(resolveBookTree(found, scopeDocsToBook(found, docs))) : undefined), + [found, docs], + ); + + if (state === 'loading') { + return ( +
      + +
      + ); + } + + if (found && opensTo) { + const base = appName ? `/apps/${appName}/docs` : '/docs'; + return ; + } + + // Unknown book, or a book with no readable docs yet. + return ( + +
      + {found ? ( + <> + +

      {found.label ?? slug}

      +

      This book has no documents yet.

      + + ) : ( + <> + +

      Book not found

      +

      + No book named {slug} is installed. +

      + + )} +
      +
      + ); +} diff --git a/apps/console/src/pages/BookSidebar.test.tsx b/apps/console/src/pages/BookSidebar.test.tsx new file mode 100644 index 000000000..5f73b6e63 --- /dev/null +++ b/apps/console/src/pages/BookSidebar.test.tsx @@ -0,0 +1,75 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { BookSidebar } from './BookSidebar'; +import type { ResolvedBook } from './book-nav'; + +afterEach(cleanup); + +const book: ResolvedBook = { + name: 'crm_manual', + label: 'CRM Manual', + groups: [ + { + key: 'start', + label: 'Getting started', + entries: [ + { doc: 'crm_intro', label: 'Intro' }, + { separator: true }, + { href: 'https://example.com', label: 'External' }, + ], + }, + { key: 'extra', label: 'Extra', synthetic: true, entries: [{ doc: 'misc_note', label: 'Note' }] }, + ], +}; + +function renderSidebar(activeDoc?: string) { + return render( + + `/docs/${n}`} /> + , + ); +} + +describe('BookSidebar', () => { + it('renders the book label and group headings', () => { + renderSidebar(); + expect(screen.getByText('CRM Manual')).toBeInTheDocument(); + expect(screen.getByText('Getting started')).toBeInTheDocument(); + expect(screen.getByText('Extra')).toBeInTheDocument(); + }); + + it('renders doc links to the resolved href', () => { + renderSidebar(); + const link = screen.getByRole('link', { name: 'Intro' }); + expect(link).toHaveAttribute('href', '/docs/crm_intro'); + }); + + it('marks the active doc with aria-current', () => { + renderSidebar('crm_intro'); + expect(screen.getByRole('link', { name: 'Intro' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('link', { name: 'Note' })).not.toHaveAttribute('aria-current'); + }); + + it('renders external links with target=_blank', () => { + renderSidebar(); + const ext = screen.getByRole('link', { name: /External/ }); + expect(ext).toHaveAttribute('href', 'https://example.com'); + expect(ext).toHaveAttribute('target', '_blank'); + }); + + it('renders a separator as a non-interactive divider', () => { + renderSidebar(); + // The "Getting started" group has 2 links (Intro + External) and 1 separator. + const startNav = screen.getByLabelText('CRM Manual'); + expect(within(startNav).getAllByRole('link').length).toBe(3); // Intro, External, Note + }); +}); diff --git a/apps/console/src/pages/BookSidebar.tsx b/apps/console/src/pages/BookSidebar.tsx new file mode 100644 index 000000000..cac988421 --- /dev/null +++ b/apps/console/src/pages/BookSidebar.tsx @@ -0,0 +1,87 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Link } from 'react-router-dom'; +import { ExternalLink } from 'lucide-react'; +import type { ResolvedBook } from './book-nav'; + +/** + * The persistent left navigation for a book (ADR-0046 §6) — the resolved + * spine rendered as grouped links. Used by the book landing page and shown + * alongside the single-doc reader so a reader can move within the book + * without losing their place. + * + * Routing is delegated: the parent supplies `docHref` so the same sidebar + * works under the top-level `/docs/:name` route and the package-scoped + * `/apps/:appName/docs/:name` route. + */ +export function BookSidebar({ + book, + activeDoc, + docHref, +}: { + book: ResolvedBook; + activeDoc?: string; + docHref: (docName: string) => string; +}) { + return ( + + ); +} diff --git a/apps/console/src/pages/DocPage.tsx b/apps/console/src/pages/DocPage.tsx index db7e14740..b7ecbc1fd 100644 --- a/apps/console/src/pages/DocPage.tsx +++ b/apps/console/src/pages/DocPage.tsx @@ -8,12 +8,15 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { AlertCircle, FileQuestion, Loader2 } from 'lucide-react'; +import { AlertCircle, ChevronDown, FileQuestion, Loader2, Menu } from 'lucide-react'; import { useAdapter } from '@object-ui/app-shell'; import { MarkdownRenderer, extractToc } from '@object-ui/plugin-markdown'; import { useObjectTranslation } from '@object-ui/i18n'; import { rewriteDocLinks } from './doc-links'; import { DocShell } from './DocShell'; +import { BookSidebar } from './BookSidebar'; +import { useBookData } from './use-book-data'; +import { resolveBookTree, scopeDocsToBook, bookSlug, type ResolvedBook } from './book-nav'; interface DocItem { name: string; @@ -35,9 +38,9 @@ interface DocItem { * "not found" notice — never an install-time or hard failure. */ export default function DocPage() { - // `appName` is the parent route's package-id segment - // (/apps/:appName/docs/:name); undefined on the legacy top-level /docs/:name. - const { name, appName } = useParams<{ name: string; appName?: string }>(); + // Route is `/docs/:slug/:name` (`:slug` = book, `:name` = doc). `appName` is + // the parent route's package-id segment under `/apps/:appName/docs/:slug/:name`. + const { slug, name, appName } = useParams<{ slug?: string; name: string; appName?: string }>(); const navigate = useNavigate(); const adapter = useAdapter(); const { t } = useObjectTranslation(); @@ -104,6 +107,18 @@ export default function DocPage() { const toc = useMemo(() => extractToc(doc?.content ?? ''), [doc?.content]); const tocLabel = t('help.onThisPage', { defaultValue: 'On this page' }); + // Book context (ADR-0046 §6): the `:slug` segment names the book we're + // reading in, so its spine renders as a persistent left nav. Resolved from + // the portal book set; degrades to no sidebar if the slug is unknown (e.g. + // the backend serves no books yet). + const { books, docs: allDocs } = useBookData(); + const base = appName ? `/apps/${appName}/docs` : '/docs'; + const resolvedBook = useMemo(() => { + const book = books.find((b) => bookSlug(b) === slug); + return book ? resolveBookTree(book, scopeDocsToBook(book, allDocs)) : null; + }, [books, allDocs, slug]); + const docHref = useCallback((docName: string) => `${base}/${slug}/${docName}`, [base, slug]); + if (state === 'loading') { return (
      @@ -141,7 +156,28 @@ export default function DocPage() { return ( -
      + {/* Narrow screens: the persistent left sidebar is hidden, so the book + * nav collapses into a disclosure above the content instead of vanishing. */} + {resolvedBook ? ( +
      + + + {resolvedBook.label ?? 'Contents'} + + +
      + +
      +
      + ) : null} +
      + {resolvedBook ? ( + + ) : null}
      `. - * The viewer route is app-independent, so this portal is the canonical - * place to discover docs regardless of which app (if any) is active. - * An app may additionally surface a contextual link into a specific - * `/docs/`, but discovery lives here. + * The portal is organised entirely by `book`: authored books plus an implicit + * per-package book for every package that has docs but no authored one (§6.4 — + * there is no "flat vs book" fork; a flat package is just its implicit book). + * Each card opens that book's grouped reader at `/docs/`; the single-doc + * reader lives at `/docs//`. */ export default function DocsIndex() { - const adapter = useAdapter(); - const [groups, setGroups] = useState([]); - const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading'); - const [errorMessage, setErrorMessage] = useState(''); + const { books, docs, state, error } = useBookData(); - useEffect(() => { - let cancelled = false; - async function load() { - if (!adapter) return; - const client: any = adapter.getClient(); - if (!client?.meta?.getItems) { - setErrorMessage('meta.getItems is not available on this client'); - setState('error'); - return; - } - setState('loading'); - try { - const result: any = await client.meta.getItems('doc'); - const items: any[] = Array.isArray(result) - ? result - : Array.isArray(result?.items) - ? result.items - : Array.isArray(result?.value) - ? result.value - : []; - if (cancelled) return; - setGroups( - groupDocsByPackage( - items.map((it) => ({ name: it?.name, label: it?.label, description: it?.description, packageId: it?._packageId })), - ), - ); - setState('ready'); - } catch (err: any) { - if (cancelled) return; - setErrorMessage(err?.message ?? 'Failed to load documentation'); - setState('error'); - } - } - void load(); - return () => { - cancelled = true; - }; - }, [adapter]); + // Cards carry the book's url slug so links match the `/docs/:slug` route. + const cards = useMemo(() => buildBookCards(books, docs), [books, docs]); if (state === 'loading') { return ( @@ -81,54 +41,58 @@ export default function DocsIndex() {

      Failed to load documentation

      -

      {errorMessage}

      +

      {error}

      ); } return ( -
      - {groups.length === 0 ? ( -
      - -

      - No documentation is installed. Packages ship docs as flat - src/docs/*.md - files (ADR-0046). +

      +
      +

      Documentation

      +

      + Books and guides from your installed packages.

      -
      - ) : ( -
      - {groups.map((group) => ( -
      -

      - {group.pkg} -

      -
        - {group.docs.map((doc) => ( -
      • - - -
        -
        {doc.label ?? doc.name}
        - {doc.description ? ( -
        - {doc.description} -
        - ) : null} + + {cards.length === 0 ? ( +
        + +

        + No documentation is installed. Packages ship docs as flat + src/docs/*.md + files, organised by a book (ADR-0046). +

        +
        + ) : ( +
          + {cards.map((card) => ( +
        • + + +
          +
          {card.label}
          + {card.description ? ( +
          + {card.description}
          - -
        • - ))} -
        -
      - ))} -
      - )} + ) : card.subtitle ? ( +
      + {card.subtitle} +
      + ) : null} +
      + {card.docCount} {card.docCount === 1 ? 'article' : 'articles'} +
      +
      + + + ))} +
    + )}
); diff --git a/apps/console/src/pages/DocsLayout.tsx b/apps/console/src/pages/DocsLayout.tsx new file mode 100644 index 000000000..d51ce2027 --- /dev/null +++ b/apps/console/src/pages/DocsLayout.tsx @@ -0,0 +1,25 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Outlet } from 'react-router-dom'; +import { BookDataContext, useBookDataFetch } from './use-book-data'; + +/** + * Layout route for the docs portal (`/docs/*`). Fetches the book + doc + * metadata ONCE and shares it with every child route (index, book landing, + * reader) via context, so navigating within the section doesn't re-fetch and a + * single page never issues the same request from several components. + */ +export default function DocsLayout() { + const data = useBookDataFetch(); + return ( + + + + ); +} diff --git a/apps/console/src/pages/DocsSlug.tsx b/apps/console/src/pages/DocsSlug.tsx new file mode 100644 index 000000000..b78532cd4 --- /dev/null +++ b/apps/console/src/pages/DocsSlug.tsx @@ -0,0 +1,63 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Navigate, useParams } from 'react-router-dom'; +import { FileQuestion, Loader2 } from 'lucide-react'; +import { DocShell } from './DocShell'; +import BookPage from './BookPage'; +import { useBookData } from './use-book-data'; +import { bookSlug, homeBook } from './book-nav'; + +/** + * `/docs/:slug` — resolves a single segment under the portal to either a book + * landing or a legacy doc permalink (ADR-0046). + * + * Books and docs share the `/docs/` space deliberately: a doc's + * identity stays single-coordinate (``, ADR §4) while a book occupies a + * `slug` portal segment (ADR §6). When the segment is a book slug we render its + * landing; otherwise we treat it as a flat doc name and redirect to its + * canonical in-book URL `/docs//` — every doc has a home book + * (its package's authored or implicit book, §6.4), so this resolves for any + * installed doc. An unknown segment degrades to a "not found" notice. + */ +export default function DocsSlug() { + const { slug, appName } = useParams<{ slug: string; appName?: string }>(); + const { books, docs, state } = useBookData(); + + if (state === 'loading') { + return ( +
+ +
+ ); + } + + // A book slug → render its landing (BookPage reads the same `:slug` param). + if (slug && books.some((b) => bookSlug(b) === slug)) { + return ; + } + + // Otherwise a flat doc permalink → redirect to its canonical in-book URL. + const base = appName ? `/apps/${appName}/docs` : '/docs'; + const hb = slug ? homeBook(slug, books, docs) : null; + if (hb && slug) { + return ; + } + + return ( + +
+ +

Documentation not found

+

+ No book or document named {slug} is installed. +

+
+
+ ); +} diff --git a/apps/console/src/pages/book-nav.test.ts b/apps/console/src/pages/book-nav.test.ts new file mode 100644 index 000000000..803dba767 --- /dev/null +++ b/apps/console/src/pages/book-nav.test.ts @@ -0,0 +1,254 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect } from 'vitest'; +import { + resolveBookTree, + deriveImplicitPackageBook, + buildBookCards, + buildPortalBooks, + firstDoc, + homeBook, + bookSlug, + pkgOf, + findBookContainingDoc, + sortBooks, + countEntries, + type Book, + type ResolverDoc, +} from './book-nav'; + +const docs: ResolverDoc[] = [ + { name: 'crm_intro', label: 'Intro', order: 1 }, + { name: 'crm_guide_lead', label: 'Leads', order: 2 }, + { name: 'crm_guide_deal', label: 'Deals', order: 1 }, + { name: 'crm_ref_api', label: 'API' }, + { name: 'misc_note' }, // matched by no rule → Uncategorized +]; + +describe('resolveBookTree (ADR-0046 §6)', () => { + it('derives membership from glob include and sorts by order then label', () => { + const book: Book = { + name: 'crm_manual', + label: 'CRM Manual', + groups: [ + { key: 'start', label: 'Getting started', order: 1, include: 'crm_intro' }, + { key: 'guides', label: 'Guides', order: 2, include: 'crm_guide_*' }, + ], + }; + const r = resolveBookTree(book, docs); + expect(r.groups.map((g) => g.key)).toEqual(['start', 'guides', 'uncategorized']); + // guides sorted by order: deal(1) before lead(2) + expect(r.groups[1].entries.map((e) => e.doc)).toEqual(['crm_guide_deal', 'crm_guide_lead']); + }); + + it('drops nothing — unmatched docs fall into Uncategorized last', () => { + const book: Book = { name: 'b', groups: [{ key: 'g', label: 'G', include: 'crm_guide_*' }] }; + const r = resolveBookTree(book, docs); + const uncategorized = r.groups.find((g) => g.key === 'uncategorized'); + expect(uncategorized?.entries.map((e) => e.doc)).toContain('misc_note'); + expect(uncategorized?.entries.map((e) => e.doc)).toContain('crm_intro'); + }); + + it('first matching group wins — a doc is never duplicated across groups', () => { + const book: Book = { + name: 'b', + groups: [ + { key: 'all', label: 'All', order: 1, include: 'crm_*' }, + { key: 'guides', label: 'Guides', order: 2, include: 'crm_guide_*' }, + ], + }; + const r = resolveBookTree(book, docs); + expect(r.groups[1].entries).toHaveLength(0); // everything already claimed by 'all' + }); + + it('honours a tag include rule', () => { + const tagged: ResolverDoc[] = [{ name: 'd1', tags: ['start'] }, { name: 'd2' }]; + const book: Book = { name: 'b', groups: [{ key: 'g', label: 'G', include: { tag: 'start' } }] }; + const r = resolveBookTree(book, tagged); + expect(r.groups[0].entries.map((e) => e.doc)).toEqual(['d1']); + }); + + it('explicit pages override: verbatim order, --- separator, badge, external link, ... rest', () => { + const book: Book = { + name: 'b', + groups: [ + { + key: 'curated', + label: 'Curated', + include: 'crm_guide_*', + pages: [ + 'crm_intro', + '---', + { doc: 'crm_guide_lead', label: 'Lead mgmt', badge: 'new' }, + { href: 'https://example.com', label: 'External' }, + '...', // expands to the remaining crm_guide_* (i.e. crm_guide_deal) + ], + }, + ], + }; + const r = resolveBookTree(book, docs); + const e = r.groups[0].entries; + expect(e[0].doc).toBe('crm_intro'); + expect(e[1].separator).toBe(true); + expect(e[2]).toMatchObject({ doc: 'crm_guide_lead', label: 'Lead mgmt', badge: 'new' }); + expect(e[3]).toMatchObject({ href: 'https://example.com', label: 'External' }); + expect(e[4].doc).toBe('crm_guide_deal'); // the rest + }); + + it('scopes include to a package when group.package / bookPackage is set', () => { + const mixed: ResolverDoc[] = [ + { name: 'a_x', packageId: 'a' }, + { name: 'b_x', packageId: 'b' }, + ]; + const book: Book = { name: 'bk', groups: [{ key: 'g', label: 'G', include: '*', package: 'a' }] }; + const r = resolveBookTree(book, mixed); + expect(r.groups[0].entries.map((e) => e.doc)).toEqual(['a_x']); + // b_x is orphaned, not in group g + expect(r.groups.find((g) => g.key === 'uncategorized')?.entries.map((e) => e.doc)).toEqual(['b_x']); + }); +}); + +describe('deriveImplicitPackageBook', () => { + it("includes only the package's own docs (scoped by name prefix when unstamped)", () => { + const book = deriveImplicitPackageBook('crm', 'CRM'); + const r = resolveBookTree(book, docs); + expect(r.groups[0].key).toBe('all'); + const inAll = r.groups[0].entries.map((e) => e.doc); + // The four crm_* docs land in the implicit crm book... + expect(inAll).toHaveLength(4); + expect(inAll).toEqual(expect.arrayContaining(['crm_intro', 'crm_guide_lead', 'crm_guide_deal', 'crm_ref_api'])); + // ...and misc_note (package 'misc') does not. + expect(inAll).not.toContain('misc_note'); + }); +}); + +describe('buildPortalBooks (ADR-0046 §6.4 — no flat/book fork)', () => { + it('synthesizes an implicit per-package book for packages without an authored one', () => { + const authored: Book[] = [ + { name: 'crm_manual', label: 'CRM Manual', groups: [{ key: 'g', label: 'G', include: 'crm_guide_*' }] }, + ]; + const portal = buildPortalBooks(authored, docs); + // crm has an authored book (pkg 'crm'); misc only has the implicit one. + expect(portal.map((b) => b.name)).toContain('crm_manual'); + expect(portal.map((b) => b.name)).toContain('misc'); // implicit book keyed by packageId + // no duplicate implicit 'crm' book, since crm_manual already owns 'crm' + expect(portal.filter((b) => b.name === 'crm').length).toBe(0); + // authored books lead, implicit ones follow + expect(portal[0].name).toBe('crm_manual'); + expect(portal[portal.length - 1].name).toBe('misc'); + // implicit label is humanized, not the raw package id + const misc = portal.find((b) => b.name === 'misc'); + expect(misc?.label).toBe('Misc'); + expect(misc?.implicit).toBe(true); // synthetic books are flagged + }); + + it('cards surface the package id as a subtitle for implicit books only', () => { + const authored: Book[] = [ + { name: 'crm_manual', label: 'CRM Manual', description: 'd', groups: [{ key: 'g', label: 'G', include: 'crm_*' }] }, + ]; + const cards = buildBookCards(buildPortalBooks(authored, docs), docs); + expect(cards.find((c) => c.name === 'crm_manual')?.subtitle).toBeUndefined(); // authored: has a description + expect(cards.find((c) => c.name === 'misc')?.subtitle).toBe('misc'); // implicit: package id + }); + + it('with no authored books, every package becomes its own implicit book', () => { + const portal = buildPortalBooks([], docs); + expect(portal.map((b) => b.name).sort()).toEqual(['crm', 'misc']); + }); +}); + +describe('homeBook + bookSlug', () => { + it('resolves a doc to its package book and exposes its url slug', () => { + const portal = buildPortalBooks([], docs); + const hb = homeBook('crm_intro', portal, docs); + expect(hb?.name).toBe('crm'); + expect(bookSlug(hb!)).toBe('crm'); + }); + + it('prefers an authored book over the implicit one as a doc\'s home', () => { + const authored: Book[] = [ + { name: 'crm_manual', slug: 'manual', label: 'CRM Manual', groups: [{ key: 'g', label: 'G', include: 'crm_*' }] }, + ]; + const portal = buildPortalBooks(authored, docs); + const hb = homeBook('crm_intro', portal, docs); + expect(hb?.name).toBe('crm_manual'); + expect(bookSlug(hb!)).toBe('manual'); // explicit slug wins over name + }); + + it('pkgOf falls back to the name prefix when unstamped', () => { + expect(pkgOf({ name: 'crm_intro' })).toBe('crm'); + expect(pkgOf({ name: 'crm_intro', packageId: 'override' })).toBe('override'); + }); +}); + +describe('portal helpers', () => { + it('sortBooks orders by order then label then index', () => { + const books: Book[] = [ + { name: 'z', label: 'Z', order: 2 }, + { name: 'a', label: 'A', order: 1 }, + { name: 'b', label: 'B', order: 1 }, + ]; + expect(sortBooks(books).map((b) => b.name)).toEqual(['a', 'b', 'z']); + }); + + it('buildBookCards reports a deduped doc count', () => { + const books: Book[] = [ + { name: 'm', label: 'Manual', groups: [{ key: 'g', label: 'G', include: 'crm_*' }] }, + ]; + const [card] = buildBookCards(books, docs); + expect(card.label).toBe('Manual'); + expect(card.slug).toBe('m'); // no explicit slug → falls back to the book name + expect(card.docCount).toBe(4); // the four crm_* docs + }); + + it('findBookContainingDoc returns the first book whose tree holds the doc', () => { + const books: Book[] = [ + { name: 'guides', label: 'Guides', order: 1, groups: [{ key: 'g', label: 'G', include: 'crm_guide_*' }] }, + { name: 'all', label: 'All', order: 2, groups: [{ key: 'a', label: 'A', include: 'crm_*' }] }, + ]; + const hit = findBookContainingDoc(books, docs, 'crm_guide_lead'); + expect(hit?.book.name).toBe('guides'); + expect(findBookContainingDoc(books, docs, 'nonexistent')).toBeNull(); + }); + + it('a doc that only lands in a book\'s synthetic Uncategorized is NOT owned by it', () => { + // 'misc_note' matches neither book's rule → only ever in Uncategorized. + const books: Book[] = [ + { name: 'guides', label: 'Guides', groups: [{ key: 'g', label: 'G', include: 'crm_guide_*' }] }, + ]; + expect(findBookContainingDoc(books, docs, 'misc_note')).toBeNull(); + }); + + it('firstDoc prefers an *_index doc, else the first in reading order', () => { + const withIndex = resolveBookTree( + { name: 'b', groups: [{ key: 'g', label: 'G', include: 'crm_*' }] }, + [ + { name: 'crm_guide_lead', order: 1 }, + { name: 'crm_index', order: 9 }, // later in order, but the index wins + ], + ); + expect(firstDoc(withIndex)).toBe('crm_index'); + + const noIndex = resolveBookTree( + { name: 'b', groups: [{ key: 'g', label: 'G', include: 'crm_*' }] }, + [{ name: 'crm_b', order: 2 }, { name: 'crm_a', order: 1 }], + ); + expect(firstDoc(noIndex)).toBe('crm_a'); // first by order + + // A book with no readable docs → undefined. + expect(firstDoc({ name: 'b', label: 'B', groups: [] })).toBeUndefined(); + }); + + it('countEntries ignores separators', () => { + const groups = [ + { key: 'g', label: 'G', entries: [{ doc: 'a' }, { separator: true }, { href: 'x' }] }, + ]; + expect(countEntries(groups)).toBe(2); + }); +}); diff --git a/apps/console/src/pages/book-nav.ts b/apps/console/src/pages/book-nav.ts new file mode 100644 index 000000000..f73ebdeff --- /dev/null +++ b/apps/console/src/pages/book-nav.ts @@ -0,0 +1,432 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Book-driven documentation navigation (ADR-0046 §6). + * + * A `book` is the *spine* of a table of contents: an ordered set of groups + * (sections) plus identity and access. It deliberately does NOT store its + * members — membership is **derived** at render time from a rule on each + * group (`include` glob/tag) plus an optional per-doc `order`/`group`. + * + * The portal resolves that spine against the docs that exist *now*. This is + * a faithful local port of the framework's `resolveBookTree` (book.zod.ts) + * so the portal works the moment the backend serves `book` + `doc` through + * the ordinary metadata API — without depending on a particular published + * `@objectstack/spec` version or the `/meta/book/:name/tree` endpoint. + */ + +// ── Authored spine (a subset of the framework `Book` shape) ──────────────── + +export type BookInclude = string | { tag: string }; + +export type BookNode = + | string // a doc name, or the literals '---' (separator) / '...' (rest) + | { doc?: string; href?: string; label?: string; badge?: string; icon?: string }; + +export interface BookGroup { + key: string; + label: string; + order?: number; + include?: BookInclude; + /** Scope the rule to a package id (default: the book's package). */ + package?: string; + /** Explicit override — a hand-pinned curated order; wins over `include`. */ + pages?: BookNode[]; +} + +export type BookAudience = 'org' | 'public' | { profile: string }; + +export interface Book { + name: string; + label?: string; + description?: string; + slug?: string; + icon?: string; + order?: number; + audience?: BookAudience; + groups?: BookGroup[]; + /** Owning package id (`_packageId`), stamped by the metadata API. */ + packageId?: string; + /** True for a synthetic implicit per-package book (ADR-0046 §6.4). */ + implicit?: boolean; +} + +/** Minimal doc header the resolver needs. */ +export interface ResolverDoc { + name: string; + label?: string; + description?: string; + order?: number; + /** Explicit placement: the `key` of the group this doc belongs to. */ + group?: string; + /** Tags for `include: { tag }` matching. */ + tags?: string[]; + /** Owning package id (`_packageId`); used to scope `include`. */ + packageId?: string; +} + +// ── Resolved tree (what the UI renders) ──────────────────────────────────── + +export interface ResolvedEntry { + /** Doc name, or undefined for an external link / separator. */ + doc?: string; + href?: string; + label?: string; + description?: string; + badge?: string; + icon?: string; + /** True for a `---` separator node. */ + separator?: boolean; +} + +export interface ResolvedGroup { + key: string; + label: string; + entries: ResolvedEntry[]; + /** + * True for the synthetic "Uncategorized" catch-all. It appears in EVERY + * book's resolution (it absorbs whatever the book's own groups didn't + * claim), so it must be excluded from authored-membership questions like + * "how many docs does this book organize?" or "which book owns this doc?". + */ + synthetic?: boolean; +} + +export interface ResolvedBook { + name: string; + label?: string; + description?: string; + groups: ResolvedGroup[]; +} + +const UNCATEGORIZED_KEY = 'uncategorized'; + +/** The namespace prefix of a metadata name (everything before the first `_`). */ +export function namePrefix(name: string): string { + const i = name.indexOf('_'); + return i > 0 ? name.slice(0, i) : name; +} + +/** + * The package a doc belongs to. Prefer the server-stamped `_packageId`; fall + * back to the name's namespace prefix (ADR-0046 names are prefix-scoped by + * build convention), so package scoping still works before the backend stamps + * provenance. + */ +export function pkgOf(doc: ResolverDoc): string { + return doc.packageId ?? namePrefix(doc.name); +} + +/** The package a book belongs to — same precedence as {@link pkgOf}. */ +export function pkgOfBook(book: Book): string { + return book.packageId ?? namePrefix(book.name); +} + +/** The portal URL segment for a book (ADR-0046 §6: `slug`, default the name). */ +export function bookSlug(book: Book): string { + return book.slug ?? book.name; +} + +/** Compile a `*`-glob over doc names to a RegExp anchored on the whole name. */ +function globToRegExp(glob: string): RegExp { + const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + return new RegExp(`^${escaped}$`); +} + +function matchesInclude(doc: ResolverDoc, include: BookInclude, scopePackage?: string): boolean { + if (scopePackage && pkgOf(doc) !== scopePackage) return false; + if (typeof include === 'string') return globToRegExp(include).test(doc.name); + return Array.isArray(doc.tags) && doc.tags.includes(include.tag); +} + +function byOrderThenLabel(a: ResolverDoc, b: ResolverDoc): number { + return (a.order ?? 0) - (b.order ?? 0) || (a.label ?? a.name).localeCompare(b.label ?? b.name); +} + +function entryFromDoc(doc: ResolverDoc): ResolvedEntry { + return { doc: doc.name, label: doc.label, description: doc.description }; +} + +/** + * Resolve a book spine against the current doc set into a rendered tree. + * Faithful port of ADR-0046 §6.2.1 — see book-nav.ts header. + */ +export function resolveBookTree(book: Book, docs: ResolverDoc[], bookPackage?: string): ResolvedBook { + const scopeDefault = bookPackage ?? book.packageId; + const groupsSorted = [...(book.groups ?? [])] + .map((g, i) => ({ g, i })) + .sort((a, b) => (a.g.order ?? 0) - (b.g.order ?? 0) || a.i - b.i) + .map((x) => x.g); + + const claimed = new Set(); + const byName = new Map(docs.map((d) => [d.name, d] as const)); + + // First pass: rule/explicit membership for groups WITHOUT an explicit + // `pages` override, so a `...` in an override group can later draw from rest. + const derivedMembers = new Map(); + for (const group of groupsSorted) { + if (group.pages) continue; + const scope = group.package ?? scopeDefault; + const members = docs.filter((d) => { + if (claimed.has(d.name)) return false; + return ( + (group.include != null && matchesInclude(d, group.include, scope)) || + (d.group != null && d.group === group.key) + ); + }); + members.sort(byOrderThenLabel); + members.forEach((d) => claimed.add(d.name)); + derivedMembers.set(group.key, members); + } + + const resolvedGroups: ResolvedGroup[] = []; + for (const group of groupsSorted) { + let entries: ResolvedEntry[]; + if (group.pages) { + const scope = group.package ?? scopeDefault; + entries = []; + const pinned = new Set( + group.pages.filter((n): n is string => typeof n === 'string' && n !== '...' && n !== '---'), + ); + for (const node of group.pages) { + if (node === '---') { + entries.push({ separator: true }); + } else if (node === '...') { + const rest = docs.filter( + (d) => + !claimed.has(d.name) && + !pinned.has(d.name) && + ((group.include != null && matchesInclude(d, group.include, scope)) || + (d.group != null && d.group === group.key)), + ); + rest.sort(byOrderThenLabel); + rest.forEach((d) => { + claimed.add(d.name); + entries.push(entryFromDoc(d)); + }); + } else if (typeof node === 'string') { + const d = byName.get(node); + claimed.add(node); + entries.push(d ? entryFromDoc(d) : { doc: node }); // missing doc → renderer shows "not found" + } else if (node.doc) { + const d = byName.get(node.doc); + claimed.add(node.doc); + entries.push({ + ...(d ? entryFromDoc(d) : { doc: node.doc }), + label: node.label ?? d?.label, + badge: node.badge, + icon: node.icon, + }); + } else if (node.href) { + entries.push({ href: node.href, label: node.label, badge: node.badge, icon: node.icon }); + } + } + } else { + entries = (derivedMembers.get(group.key) ?? []).map(entryFromDoc); + } + resolvedGroups.push({ key: group.key, label: group.label, entries }); + } + + // Orphans: docs claimed by no group fall into a synthetic Uncategorized + // group appended last — nothing is ever dropped. + const orphans = docs.filter((d) => !claimed.has(d.name)).sort(byOrderThenLabel); + if (orphans.length) { + resolvedGroups.push({ + key: UNCATEGORIZED_KEY, + label: 'Uncategorized', + entries: orphans.map(entryFromDoc), + synthetic: true, + }); + } + + return { name: book.name, label: book.label, description: book.description, groups: resolvedGroups }; +} + +/** + * Synthesize the implicit per-package book (ADR-0046 §6.4): no authored book ⇒ + * one book keyed by the package id, a single group including every doc. + */ +export function deriveImplicitPackageBook(packageId: string, label?: string): Book { + return { + name: packageId, + label: label ?? packageId, + audience: 'org', + groups: [{ key: 'all', label: label ?? 'Documentation', include: '*', package: packageId }], + }; +} + +// ── Portal helpers (multi-book) ──────────────────────────────────────────── + +export interface BookCard { + name: string; + /** Portal URL segment (`/docs/`). */ + slug: string; + label: string; + description?: string; + /** Context line for cards without a description — the owning package id. */ + subtitle?: string; + icon?: string; + packageId?: string; + /** Number of docs the book currently resolves to (excludes separators/links). */ + docCount: number; +} + +/** The set of packages a book draws from: its own plus any group overrides. */ +function bookPackages(book: Book): Set { + const pkgs = new Set(); + if (book.packageId) pkgs.add(book.packageId); + for (const g of book.groups ?? []) if (g.package) pkgs.add(g.package); + return pkgs; +} + +/** + * Narrow the doc set to the packages a book draws from before resolving, so the + * synthetic Uncategorized group stays scoped to the book instead of vacuuming + * up every other package's docs. When a book declares no package (its own or a + * group override) we can't scope safely, so all docs are kept and membership + * falls to each group's `include` glob. + */ +export function scopeDocsToBook(book: Book, docs: ResolverDoc[]): ResolverDoc[] { + const pkgs = bookPackages(book); + if (pkgs.size === 0) return docs; + return docs.filter((d) => pkgs.has(pkgOf(d))); +} + +/** Sort books for the index: by `order`, then label, then name (stable). */ +export function sortBooks(books: Book[]): Book[] { + return [...books] + .map((b, i) => ({ b, i })) + .sort( + (a, b) => + (a.b.order ?? 0) - (b.b.order ?? 0) || + (a.b.label ?? a.b.name).localeCompare(b.b.label ?? b.b.name) || + a.i - b.i, + ) + .map((x) => x.b); +} + +/** + * Count the docs a book *organizes* — i.e. those landing in an authored group, + * excluding the synthetic Uncategorized catch-all (see {@link ResolvedGroup}). + */ +export function countBookDocs(book: Book, docs: ResolverDoc[]): number { + const resolved = resolveBookTree(book, scopeDocsToBook(book, docs)); + const seen = new Set(); + for (const g of resolved.groups) { + if (g.synthetic) continue; + for (const e of g.entries) { + if (e.doc && !e.separator) seen.add(e.doc); + } + } + return seen.size; +} + +/** + * Build the index cards for the portal landing. Input order is preserved — + * callers pass {@link buildPortalBooks} output, which already orders authored + * books (by `order`) ahead of the synthetic per-package ones; re-sorting here + * would undo that. + */ +export function buildBookCards(books: Book[], docs: ResolverDoc[]): BookCard[] { + return books.map((b) => ({ + name: b.name, + slug: bookSlug(b), + label: b.label ?? b.name, + description: b.description, + // Implicit per-package books have no authored description; surface the + // package id so the card still says where the docs come from. + subtitle: b.implicit && !b.description ? b.packageId : undefined, + icon: b.icon, + packageId: b.packageId, + docCount: countBookDocs(b, docs), + })); +} + +/** + * The portal's full book set (ADR-0046 §6.4): every authored book, plus a + * synthetic implicit book for any package that has docs but no authored book + * of its own. There is no "flat vs book" fork — a package without an authored + * book is still browsed as its implicit per-package book (keyed by packageId, + * one group including every doc). Returned in display order. + */ +export function buildPortalBooks(authored: Book[], docs: ResolverDoc[]): Book[] { + const ownPkgs = new Set(authored.map(pkgOfBook)); + const docPkgs = new Set(docs.map(pkgOf)); + const implicit: Book[] = [...docPkgs] + .filter((p) => !ownPkgs.has(p)) + // Humanize the package id for the visible label (the slug/name stays the + // full id so it remains unique and reversible); keep the group as the + // generic "Documentation". + .map((p) => ({ ...deriveImplicitPackageBook(p), label: humanizePackageId(p), packageId: p, implicit: true })); + // Authored books lead (curated, primary) in their own order; the synthetic + // per-package books follow, alphabetically — so an authored book with an + // explicit `order` is never pushed below a fallback. + return [...sortBooks(authored), ...sortBooks(implicit)]; +} + +/** "com.objectstack.setup" → "Setup" — a readable label for an implicit book. */ +function humanizePackageId(pkg: string): string { + const seg = pkg.split('.').pop() || pkg; + return seg.charAt(0).toUpperCase() + seg.slice(1); +} + +/** + * The canonical ("home") book for a doc — the authored or implicit book of the + * doc's own package. Used for the legacy `/docs/:name` permalink redirect and + * as the default reading context. A doc curated into a cross-package authored + * book is still reachable there, but its canonical URL is its home book. + */ +export function homeBook(docName: string, portalBooks: Book[], docs: ResolverDoc[]): Book | null { + const doc = docs.find((d) => d.name === docName); + const pkg = doc ? pkgOf(doc) : namePrefix(docName); + return sortBooks(portalBooks).find((b) => pkgOfBook(b) === pkg) ?? null; +} + +/** + * Find the first book (in display order) whose resolved tree contains `docName` + * — used by the single-doc reader to show the surrounding book's nav. Returns + * the resolved book and the matching book record, or null when the doc belongs + * to no authored book. + */ +export function findBookContainingDoc( + books: Book[], + docs: ResolverDoc[], + docName: string, +): { book: Book; resolved: ResolvedBook } | null { + for (const book of sortBooks(books)) { + const resolved = resolveBookTree(book, scopeDocsToBook(book, docs)); + // Authored membership only — a doc that merely lands in this book's + // synthetic Uncategorized group is not "owned" by it (every book has one). + const has = resolved.groups.some( + (g) => !g.synthetic && g.entries.some((e) => e.doc === docName && !e.separator), + ); + if (has) return { book, resolved }; + } + return null; +} + +/** + * The doc a book opens to — its overview. Prefers an `*_index` doc, else the + * first doc in reading order. Returns undefined for a book with no readable + * docs (only external links / separators). + */ +export function firstDoc(resolved: ResolvedBook): string | undefined { + const names: string[] = []; + for (const g of resolved.groups) { + for (const e of g.entries) if (e.doc && !e.separator) names.push(e.doc); + } + return names.find((n) => /(^|_)index$/.test(n)) ?? names[0]; +} + +/** Total docs an entry list covers — handy for "N articles" labels. */ +export function countEntries(groups: ResolvedGroup[]): number { + let n = 0; + for (const g of groups) for (const e of g.entries) if ((e.doc || e.href) && !e.separator) n += 1; + return n; +} diff --git a/apps/console/src/pages/docs-portal.test.tsx b/apps/console/src/pages/docs-portal.test.tsx new file mode 100644 index 000000000..7cf913345 --- /dev/null +++ b/apps/console/src/pages/docs-portal.test.tsx @@ -0,0 +1,137 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Integration test for the book-driven docs portal (ADR-0046 §6): mounts the + * REAL DocsIndex / DocsSlug / BookPage / DocPage under the REAL route table, + * backed by a mocked metadata adapter (no authored books — so the implicit + * per-package books are exercised, §6.4). Verifies the full reader flow that + * the unit tests can't: routing, the /docs/:slug dispatcher, the book landing, + * the in-book reader + sidebar, and the legacy /docs/:name redirect. + * + * This is a jsdom integration test, not a real browser — it needs no backend. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +// ── Sample metadata the mocked adapter serves (two packages, no books) ────── +// Defined via vi.hoisted so the hoisted vi.mock factory can close over them, +// and so the adapter is a STABLE singleton — the real useAdapter() returns a +// memoized instance, so a fresh object per render would loop the fetch effects. +const { ADAPTER } = vi.hoisted(() => { + const DOCS = [ + { name: 'crm_intro', label: 'CRM Intro', _packageId: 'crm', order: 1 }, + { name: 'crm_guide_lead', label: 'Leads', _packageId: 'crm', order: 2 }, + { name: 'ops_setup', label: 'Setup', _packageId: 'ops' }, + ]; + const CONTENT: Record = { + crm_intro: 'Welcome to the CRM', + crm_guide_lead: 'Managing leads', + ops_setup: 'Operations setup', + }; + const ADAPTER = { + getClient: () => ({ + meta: { + getItems: async (type: string) => (type === 'doc' ? DOCS : []), // no authored books + getItem: async (_type: string, name: string) => ({ item: { name, content: CONTENT[name] } }), + }, + }), + }; + return { ADAPTER }; +}); + +vi.mock('@object-ui/app-shell', () => ({ useAdapter: () => ADAPTER })); + +vi.mock('@object-ui/plugin-markdown', () => ({ + MarkdownRenderer: ({ schema }: { schema: { content?: string } }) => ( +
{schema.content}
+ ), + extractToc: () => [], +})); + +vi.mock('@object-ui/i18n', () => ({ + useObjectTranslation: () => ({ t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k }), +})); + +// Imported AFTER the mocks so the pages pick up the mocked modules. +import DocsLayout from './DocsLayout'; +import DocsIndex from './DocsIndex'; +import DocsSlug from './DocsSlug'; +import DocPage from './DocPage'; + +// Mirrors the real route table: the layout fetches once and shares the data. +function Harness({ entry }: { entry: string }) { + return ( + + + }> + } /> + } /> + } /> + + + + ); +} + +afterEach(cleanup); + +describe('book-driven docs portal (integration)', () => { + it('/docs lists an implicit per-package book for every package with docs', async () => { + render(); + // Implicit books keyed by packageId: crm (2 docs) + ops (1 doc). + const crm = await screen.findByRole('link', { name: /crm/i }); + expect(crm).toHaveAttribute('href', '/docs/crm'); + expect(screen.getByRole('link', { name: /ops/i })).toHaveAttribute('href', '/docs/ops'); + expect(screen.getByText('2 articles')).toBeInTheDocument(); + expect(screen.getByText('1 article')).toBeInTheDocument(); + }); + + it('/docs/:slug opens the book to its overview doc (no duplicated TOC)', async () => { + render(); + // The landing redirects into the reader of the first doc; content + sidebar. + expect(await screen.findByTestId('doc-content')).toHaveTextContent('Welcome to the CRM'); + const intro = screen.getAllByRole('link', { name: 'CRM Intro' }); + expect(intro.every((l) => l.getAttribute('aria-current') === 'page')).toBe(true); + }); + + it('/docs/:slug/:name renders the doc content with the book sidebar (active)', async () => { + render(); + expect(await screen.findByTestId('doc-content')).toHaveTextContent('Welcome to the CRM'); + // The sidebar is rendered twice (persistent on wide, a disclosure on narrow); + // both mark the current doc active. + const active = screen.getAllByRole('link', { name: 'CRM Intro' }); + expect(active.length).toBeGreaterThanOrEqual(1); + expect(active.every((l) => l.getAttribute('aria-current') === 'page')).toBe(true); + // A sibling doc is reachable from the sidebar. + const leads = screen.getAllByRole('link', { name: 'Leads' }); + expect(leads.some((l) => l.getAttribute('href') === '/docs/crm/crm_guide_lead')).toBe(true); + }); + + it('legacy /docs/:name redirects to the doc\'s canonical in-book URL', async () => { + render(); + // 'crm_intro' is not a book slug → dispatcher redirects to /docs/crm/crm_intro. + expect(await screen.findByTestId('doc-content')).toHaveTextContent('Welcome to the CRM'); + const active = screen.getAllByRole('link', { name: 'CRM Intro' }); + expect(active.every((l) => l.getAttribute('aria-current') === 'page')).toBe(true); + }); + + it('an unknown segment degrades to a not-found notice', async () => { + render(); + expect(await screen.findByText('Documentation not found')).toBeInTheDocument(); + }); + + it('clicking a book card opens it (real router flow)', async () => { + render(); + fireEvent.click(await screen.findByRole('link', { name: /ops/i })); + // /docs/ops opens the ops book → reader of its single doc. + expect(await screen.findByTestId('doc-content')).toHaveTextContent('Operations setup'); + }); +}); diff --git a/apps/console/src/pages/use-book-data.ts b/apps/console/src/pages/use-book-data.ts new file mode 100644 index 000000000..bbb1e1911 --- /dev/null +++ b/apps/console/src/pages/use-book-data.ts @@ -0,0 +1,149 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import { useAdapter } from '@object-ui/app-shell'; +import { buildPortalBooks, type Book, type ResolverDoc } from './book-nav'; + +/** + * Fetch the `book` + `doc` metadata that drives the portal navigation + * (ADR-0046 §6). Both are loaded through the ordinary metadata API the doc + * pages already use (`meta.getItems`), so this works the moment the backend + * serves these types — no new endpoint required. + * + * Degrades softly: a backend that doesn't serve `book` yet (or returns an + * error for it) yields `books: []`, letting callers fall back to the legacy + * package-grouped view rather than failing the whole page. + */ +export interface BookData { + /** Authored books exactly as published. */ + authoredBooks: Book[]; + /** + * The portal's full book set: authored books plus a synthetic implicit book + * for any package that has docs but no authored book (ADR-0046 §6.4). This + * is what the portal navigates — there is no "flat vs book" fork. + */ + books: Book[]; + docs: ResolverDoc[]; + state: 'loading' | 'ready' | 'error'; + error?: string; +} + +function unwrapList(result: unknown): any[] { + if (Array.isArray(result)) return result; + const r = result as any; + if (Array.isArray(r?.items)) return r.items; + if (Array.isArray(r?.value)) return r.value; + return []; +} + +function toBook(it: any): Book | null { + if (!it || typeof it.name !== 'string' || !it.name) return null; + return { + name: it.name, + label: it.label, + description: it.description, + slug: it.slug, + icon: it.icon, + order: typeof it.order === 'number' ? it.order : undefined, + audience: it.audience, + groups: Array.isArray(it.groups) ? it.groups : [], + packageId: it._packageId ?? it.packageId, + }; +} + +function toDoc(it: any): ResolverDoc | null { + if (!it || typeof it.name !== 'string' || !it.name) return null; + return { + name: it.name, + label: it.label, + description: it.description, + order: typeof it.order === 'number' ? it.order : undefined, + group: typeof it.group === 'string' ? it.group : undefined, + tags: Array.isArray(it.tags) ? it.tags : undefined, + packageId: it._packageId ?? it.packageId, + }; +} + +interface RawState { + authoredBooks: Book[]; + docs: ResolverDoc[]; + state: 'loading' | 'ready' | 'error'; + error?: string; +} + +/** + * Fetch the book + doc metadata once. Used by {@link BookDataProvider} at the + * `/docs` layout route so the whole docs section shares a single fetch, rather + * than each page (index, book landing, reader) re-fetching independently. + */ +function useBookDataFetch(): BookData { + const adapter = useAdapter(); + const [data, setData] = useState({ authoredBooks: [], docs: [], state: 'loading' }); + + useEffect(() => { + let cancelled = false; + async function load() { + if (!adapter) return; + const client: any = adapter.getClient(); + if (!client?.meta?.getItems) { + setData({ authoredBooks: [], docs: [], state: 'error', error: 'meta.getItems is not available' }); + return; + } + setData((d) => ({ ...d, state: 'loading' })); + try { + // Docs are required; books are optional (the type may not be served + // yet) — so tolerate a book fetch failure without failing the page. + const docsRaw = await client.meta.getItems('doc'); + let booksRaw: unknown = []; + try { + booksRaw = await client.meta.getItems('book'); + } catch { + booksRaw = []; + } + if (cancelled) return; + const docs = unwrapList(docsRaw).map(toDoc).filter((d): d is ResolverDoc => d !== null); + const authoredBooks = unwrapList(booksRaw).map(toBook).filter((b): b is Book => b !== null); + setData({ authoredBooks, docs, state: 'ready' }); + } catch (err: any) { + if (cancelled) return; + setData({ authoredBooks: [], docs: [], state: 'error', error: err?.message ?? 'Failed to load documentation' }); + } + } + void load(); + return () => { + cancelled = true; + }; + }, [adapter]); + + // Synthesize the portal's implicit per-package books on top of the authored + // ones, so every package with docs is browsable as a book (ADR-0046 §6.4). + const books = useMemo( + () => buildPortalBooks(data.authoredBooks, data.docs), + [data.authoredBooks, data.docs], + ); + + return { ...data, books }; +} + +const LOADING: BookData = { authoredBooks: [], books: [], docs: [], state: 'loading' }; + +/** + * Shared book/doc data, provided once at the `/docs` layout route (see + * DocsLayout) and consumed by {@link useBookData}. Exported so the layout's + * provider — which lives in a `.tsx` file — can supply it. + */ +export const BookDataContext = createContext(null); + +/** The one-time fetcher the layout drives. Internal to the docs section. */ +export { useBookDataFetch }; + +/** Read the shared book/doc data. Returns a loading state outside the provider. */ +export function useBookData(): BookData { + return useContext(BookDataContext) ?? LOADING; +}