Skip to content

Commit c04b47a

Browse files
os-zhuangclaude
andcommitted
feat(console): book-driven documentation portal (ADR-0046 §6)
The `/docs` portal already rendered docs, but its navigation was a namespace heuristic (group by the doc-name prefix). ADR-0046 §6 makes `book` the authored navigation spine — ordered groups whose membership over docs is derived from each group's include rule (glob/tag) with an optional explicit `pages` override. This wires the portal to that spine. - book-nav.ts: a faithful local port of the framework's `resolveBookTree` (glob/tag/order/explicit-pages resolution) plus portal helpers (sortBooks, buildBookCards, findBookContainingDoc, scopeDocsToBook). The synthetic Uncategorized catch-all is flagged so it's excluded from authored-membership questions (it appears in every book's resolution). No dependency on a published @objectstack/spec version or a /tree endpoint — resolves client-side from the ordinary metadata API. - use-book-data.ts: fetch book + doc via meta.getItems; book is optional (a backend not yet serving it degrades to books: []). - BookSidebar.tsx: the resolved spine as a persistent grouped left nav. - BookPage.tsx (/docs/book/:book): a book landing — sidebar + grouped TOC. - DocsIndex: lead with Books when present; keep the package-grouped flat list below as a complete index and the no-book fallback. - DocPage: when the current doc belongs to a book, show that book's sidebar so a reader can move within the book. - Routes: /docs/book/:book (top-level + app-scoped), before /docs/:name. Tests: book-nav (12) + BookSidebar (5); existing doc tests unchanged. console type-check + eslint clean. Note: book/doc must be served by the backend for real data; all states degrade gracefully (current published spec 9.5.1 serves neither yet). Making /docs anonymously readable (ADR-0046 §6.7 public audience) is left as a separate change — these routes stay behind ProtectedRoute as before. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 712cbd0 commit c04b47a

10 files changed

Lines changed: 1032 additions & 93 deletions

File tree

apps/console/src/App.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { MetadataHmrReloader } from './components/MetadataHmrReloader';
4444
import SharedRecordPage from './pages/SharedRecordPage';
4545
import DocPage from './pages/DocPage';
4646
import DocsIndex from './pages/DocsIndex';
47+
import BookPage from './pages/BookPage';
4748
import { LoginPage } from './pages/auth/LoginPage';
4849
import { RegisterPage } from './pages/auth/RegisterPage';
4950
import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage';
@@ -189,6 +190,12 @@ export function App() {
189190
<DocsIndex />
190191
</ProtectedRoute>
191192
} />
193+
{/* Book landing (ADR-0046 §6): the authored navigation spine. */}
194+
<Route path="/docs/book/:book" element={
195+
<ProtectedRoute>
196+
<BookPage />
197+
</ProtectedRoute>
198+
} />
192199
<Route path="/docs/:name" element={
193200
<ProtectedRoute>
194201
<DocPage />

apps/console/src/AppContent.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const IntegrationsPage = lazy(() => import('./pages/developer/IntegrationsPage')
3535
// app-scoped index at /apps/:packageId/docs (AppDocsIndex).
3636
const DocPage = lazy(() => import('./pages/DocPage'));
3737
const AppDocsIndex = lazy(() => import('./pages/AppDocsIndex'));
38+
const BookPage = lazy(() => import('./pages/BookPage'));
3839

3940
// Note: marketplace routes (`system/marketplace`, `system/marketplace/:packageId`)
4041
// are registered by DefaultAppContent in @object-ui/app-shell so they're
@@ -92,6 +93,8 @@ const systemRoutes = (
9293
{/* ADR-0048 — package docs under the package container: app-scoped index
9394
(/apps/:packageId/docs) + single-doc viewer (/apps/:packageId/docs/:name) */}
9495
<Route path="docs" element={<Suspense fallback={<LoadingScreen />}><AppDocsIndex /></Suspense>} />
96+
{/* Book landing (ADR-0046 §6) — before docs/:name so 'book' isn't read as a doc name. */}
97+
<Route path="docs/book/:book" element={<Suspense fallback={<LoadingScreen />}><BookPage /></Suspense>} />
9598
<Route path="docs/:name" element={<Suspense fallback={<LoadingScreen />}><DocPage /></Suspense>} />
9699
{/* Legacy URL redirects → metadata-admin engine (zero-cost compatibility). */}
97100
<Route path="system/objects" element={<ObjectRedirect />} />
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { useMemo } from 'react';
10+
import { Link, useParams } from 'react-router-dom';
11+
import { FileText, FileQuestion, Loader2 } from 'lucide-react';
12+
import { DocShell } from './DocShell';
13+
import { BookSidebar } from './BookSidebar';
14+
import { useBookData } from './use-book-data';
15+
import { resolveBookTree, scopeDocsToBook } from './book-nav';
16+
17+
/**
18+
* `/docs/book/:book` — a book landing page (ADR-0046 §6). Resolves the book's
19+
* spine against the current docs and shows the grouped table of contents: the
20+
* persistent nav sidebar plus an overview that lists every section's docs.
21+
* Each doc links to the single-doc reader, which keeps this sidebar in view.
22+
*/
23+
export default function BookPage() {
24+
const { book: bookName, appName } = useParams<{ book: string; appName?: string }>();
25+
const { books, docs, state, error } = useBookData();
26+
27+
const found = useMemo(() => books.find((b) => b.name === bookName), [books, bookName]);
28+
const resolved = useMemo(
29+
() => (found ? resolveBookTree(found, scopeDocsToBook(found, docs)) : null),
30+
[found, docs],
31+
);
32+
33+
const docHref = (name: string) =>
34+
appName ? `/apps/${appName}/docs/${name}` : `/docs/${name}`;
35+
36+
if (state === 'loading') {
37+
return (
38+
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
39+
<Loader2 className="h-5 w-5 animate-spin" aria-label="Loading documentation" />
40+
</div>
41+
);
42+
}
43+
44+
if (state === 'error' || !found || !resolved) {
45+
return (
46+
<DocShell breadcrumb={bookName}>
47+
<div className="mx-auto flex max-w-3xl flex-col items-center gap-3 p-10 text-center">
48+
<FileQuestion className="h-10 w-10 text-muted-foreground" />
49+
<h1 className="text-lg font-semibold">
50+
{state === 'error' ? 'Failed to load documentation' : 'Book not found'}
51+
</h1>
52+
<p className="text-sm text-muted-foreground">
53+
{error ?? (
54+
<>
55+
No book named <code className="rounded bg-muted px-1 py-0.5">{bookName}</code> is installed.
56+
</>
57+
)}
58+
</p>
59+
</div>
60+
</DocShell>
61+
);
62+
}
63+
64+
return (
65+
<DocShell breadcrumb={resolved.label ?? bookName}>
66+
<div className="mx-auto flex max-w-5xl gap-8 p-4 sm:p-6">
67+
<aside className="hidden w-60 shrink-0 lg:block">
68+
<div className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-auto pr-1">
69+
<BookSidebar book={resolved} docHref={docHref} />
70+
</div>
71+
</aside>
72+
73+
<div className="min-w-0 max-w-3xl flex-1">
74+
<h1 className="text-2xl font-bold">{resolved.label ?? bookName}</h1>
75+
{resolved.description ? (
76+
<p className="mt-2 text-sm text-muted-foreground">{resolved.description}</p>
77+
) : null}
78+
79+
<div className="mt-6 space-y-8">
80+
{resolved.groups.map((group) => (
81+
<section key={group.key}>
82+
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
83+
{group.label}
84+
</h2>
85+
<ul className="divide-y divide-border rounded-md border border-border">
86+
{group.entries
87+
.filter((e) => !e.separator)
88+
.map((entry, i) =>
89+
entry.href ? (
90+
<li key={`ext-${i}`}>
91+
<a
92+
href={entry.href}
93+
target="_blank"
94+
rel="noreferrer"
95+
className="flex items-start gap-3 px-3 py-3 hover:bg-muted/50"
96+
>
97+
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
98+
<span className="text-sm font-medium">{entry.label ?? entry.href}</span>
99+
</a>
100+
</li>
101+
) : entry.doc ? (
102+
<li key={entry.doc}>
103+
<Link
104+
to={docHref(entry.doc)}
105+
className="flex items-start gap-3 px-3 py-3 hover:bg-muted/50"
106+
>
107+
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
108+
<div className="min-w-0">
109+
<div className="text-sm font-medium">{entry.label ?? entry.doc}</div>
110+
{entry.description ? (
111+
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
112+
{entry.description}
113+
</div>
114+
) : null}
115+
</div>
116+
</Link>
117+
</li>
118+
) : null,
119+
)}
120+
</ul>
121+
</section>
122+
))}
123+
</div>
124+
</div>
125+
</div>
126+
</DocShell>
127+
);
128+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect, afterEach } from 'vitest';
10+
import { render, screen, cleanup, within } from '@testing-library/react';
11+
import { MemoryRouter } from 'react-router-dom';
12+
import { BookSidebar } from './BookSidebar';
13+
import type { ResolvedBook } from './book-nav';
14+
15+
afterEach(cleanup);
16+
17+
const book: ResolvedBook = {
18+
name: 'crm_manual',
19+
label: 'CRM Manual',
20+
groups: [
21+
{
22+
key: 'start',
23+
label: 'Getting started',
24+
entries: [
25+
{ doc: 'crm_intro', label: 'Intro' },
26+
{ separator: true },
27+
{ href: 'https://example.com', label: 'External' },
28+
],
29+
},
30+
{ key: 'extra', label: 'Extra', synthetic: true, entries: [{ doc: 'misc_note', label: 'Note' }] },
31+
],
32+
};
33+
34+
function renderSidebar(activeDoc?: string) {
35+
return render(
36+
<MemoryRouter>
37+
<BookSidebar book={book} activeDoc={activeDoc} docHref={(n) => `/docs/${n}`} />
38+
</MemoryRouter>,
39+
);
40+
}
41+
42+
describe('BookSidebar', () => {
43+
it('renders the book label and group headings', () => {
44+
renderSidebar();
45+
expect(screen.getByText('CRM Manual')).toBeInTheDocument();
46+
expect(screen.getByText('Getting started')).toBeInTheDocument();
47+
expect(screen.getByText('Extra')).toBeInTheDocument();
48+
});
49+
50+
it('renders doc links to the resolved href', () => {
51+
renderSidebar();
52+
const link = screen.getByRole('link', { name: 'Intro' });
53+
expect(link).toHaveAttribute('href', '/docs/crm_intro');
54+
});
55+
56+
it('marks the active doc with aria-current', () => {
57+
renderSidebar('crm_intro');
58+
expect(screen.getByRole('link', { name: 'Intro' })).toHaveAttribute('aria-current', 'page');
59+
expect(screen.getByRole('link', { name: 'Note' })).not.toHaveAttribute('aria-current');
60+
});
61+
62+
it('renders external links with target=_blank', () => {
63+
renderSidebar();
64+
const ext = screen.getByRole('link', { name: /External/ });
65+
expect(ext).toHaveAttribute('href', 'https://example.com');
66+
expect(ext).toHaveAttribute('target', '_blank');
67+
});
68+
69+
it('renders a separator as a non-interactive divider', () => {
70+
renderSidebar();
71+
// The "Getting started" group has 2 links (Intro + External) and 1 separator.
72+
const startNav = screen.getByLabelText('CRM Manual');
73+
expect(within(startNav).getAllByRole('link').length).toBe(3); // Intro, External, Note
74+
});
75+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { Link } from 'react-router-dom';
10+
import { ExternalLink } from 'lucide-react';
11+
import type { ResolvedBook } from './book-nav';
12+
13+
/**
14+
* The persistent left navigation for a book (ADR-0046 §6) — the resolved
15+
* spine rendered as grouped links. Used by the book landing page and shown
16+
* alongside the single-doc reader so a reader can move within the book
17+
* without losing their place.
18+
*
19+
* Routing is delegated: the parent supplies `docHref` so the same sidebar
20+
* works under the top-level `/docs/:name` route and the package-scoped
21+
* `/apps/:appName/docs/:name` route.
22+
*/
23+
export function BookSidebar({
24+
book,
25+
activeDoc,
26+
docHref,
27+
}: {
28+
book: ResolvedBook;
29+
activeDoc?: string;
30+
docHref: (docName: string) => string;
31+
}) {
32+
return (
33+
<nav aria-label={book.label ?? book.name} className="text-sm">
34+
{book.label ? (
35+
<div className="mb-3 px-2 text-sm font-semibold text-foreground">{book.label}</div>
36+
) : null}
37+
<div className="space-y-5">
38+
{book.groups.map((group) => (
39+
<div key={group.key}>
40+
<div className="mb-1 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
41+
{group.label}
42+
</div>
43+
<ul className="space-y-0.5">
44+
{group.entries.map((entry, i) => {
45+
if (entry.separator) {
46+
return <li key={`sep-${i}`} className="my-1.5 border-t border-border/60" aria-hidden />;
47+
}
48+
if (entry.href) {
49+
return (
50+
<li key={`ext-${i}`}>
51+
<a
52+
href={entry.href}
53+
target="_blank"
54+
rel="noreferrer"
55+
className="flex items-center gap-1.5 rounded px-2 py-1 text-muted-foreground hover:bg-muted/50 hover:text-foreground"
56+
>
57+
<span className="truncate">{entry.label ?? entry.href}</span>
58+
<ExternalLink className="h-3 w-3 shrink-0 opacity-60" />
59+
</a>
60+
</li>
61+
);
62+
}
63+
if (!entry.doc) return null;
64+
const active = entry.doc === activeDoc;
65+
return (
66+
<li key={entry.doc}>
67+
<Link
68+
to={docHref(entry.doc)}
69+
aria-current={active ? 'page' : undefined}
70+
className={`block truncate rounded px-2 py-1 transition ${
71+
active
72+
? 'bg-primary/10 font-medium text-foreground'
73+
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
74+
}`}
75+
>
76+
{entry.label ?? entry.doc}
77+
</Link>
78+
</li>
79+
);
80+
})}
81+
</ul>
82+
</div>
83+
))}
84+
</div>
85+
</nav>
86+
);
87+
}

apps/console/src/pages/DocPage.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import { MarkdownRenderer, extractToc } from '@object-ui/plugin-markdown';
1414
import { useObjectTranslation } from '@object-ui/i18n';
1515
import { rewriteDocLinks } from './doc-links';
1616
import { DocShell } from './DocShell';
17+
import { BookSidebar } from './BookSidebar';
18+
import { useBookData } from './use-book-data';
19+
import { findBookContainingDoc } from './book-nav';
1720

1821
interface DocItem {
1922
name: string;
@@ -104,6 +107,20 @@ export default function DocPage() {
104107
const toc = useMemo(() => extractToc(doc?.content ?? ''), [doc?.content]);
105108
const tocLabel = t('help.onThisPage', { defaultValue: 'On this page' });
106109

110+
// Book context (ADR-0046 §6): if this doc belongs to an authored book, show
111+
// that book's spine as a persistent left nav so the reader can move within
112+
// the book. Independent of the single-doc fetch above — degrades to no
113+
// sidebar when no book owns the doc (or the backend serves no books yet).
114+
const { books, docs: allDocs } = useBookData();
115+
const bookCtx = useMemo(
116+
() => (name ? findBookContainingDoc(books, allDocs, name) : null),
117+
[books, allDocs, name],
118+
);
119+
const docHref = useCallback(
120+
(docName: string) => (appName ? `/apps/${appName}/docs/${docName}` : `/docs/${docName}`),
121+
[appName],
122+
);
123+
107124
if (state === 'loading') {
108125
return (
109126
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
@@ -141,7 +158,14 @@ export default function DocPage() {
141158

142159
return (
143160
<DocShell breadcrumb={doc?.label ?? name}>
144-
<div className="mx-auto flex max-w-5xl gap-8 p-4 sm:p-6">
161+
<div className={`mx-auto flex gap-8 p-4 sm:p-6 ${bookCtx ? 'max-w-6xl' : 'max-w-5xl'}`}>
162+
{bookCtx ? (
163+
<aside className="hidden w-56 shrink-0 lg:block">
164+
<nav className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-auto pr-1">
165+
<BookSidebar book={bookCtx.resolved} activeDoc={name} docHref={docHref} />
166+
</nav>
167+
</aside>
168+
) : null}
145169
<article
146170
className="min-w-0 max-w-3xl flex-1 [&_h1]:scroll-mt-24 [&_h2]:scroll-mt-24 [&_h3]:scroll-mt-24"
147171
onClick={onContentClick}

0 commit comments

Comments
 (0)