Skip to content

Commit dd8fc4d

Browse files
os-zhuangclaude
andcommitted
feat(console): book-driven documentation portal (ADR-0046 §6)
The `/docs` portal rendered docs but its navigation was a namespace heuristic (group by 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. §6.4: there is no "flat vs book" fork — a package with no authored book is browsed as its implicit per-package book (keyed by packageId). This wires the portal to that model. URL scheme (doc identity stays single-coordinate per §4; the book segment is *derived* navigation, not authored path-coupling): /docs book index (authored + implicit per-package) /docs/:slug book landing — or a flat-doc permalink that 302s to its canonical /docs/<homeBook>/<name> /docs/:slug/:name in-book reader (book sidebar + breadcrumb) - book-nav.ts: a local port of the framework's resolveBookTree plus portal helpers — buildPortalBooks (authored + implicit), homeBook (a doc's canonical book), bookSlug, sortBooks, buildBookCards, scopeDocsToBook, findBookContainingDoc. Package scoping uses _packageId or, when unstamped, the name prefix, so it works before the backend stamps provenance. The synthetic Uncategorized group is flagged and excluded from authored membership. No dependency on a published spec version or a /tree endpoint. - use-book-data.ts: fetch book + doc via meta.getItems and synthesize the portal's implicit books; book is optional (degrades to implicit-only). - DocsSlug.tsx: the /docs/:slug dispatcher (book landing vs doc redirect). - BookSidebar.tsx / BookPage.tsx: the resolved spine as nav + a book landing. - DocsIndex: a book index (no flat/book fork). DocPage: in-book reader with the book's sidebar, keyed by the :slug segment. - Routes added top-level (App.tsx) and app-scoped (AppContent.tsx); old /docs/:name and /apps/:app/docs/:name links redirect via the dispatcher. Tests: book-nav (17) + BookSidebar (5); existing doc tests unchanged (41 in pages). console type-check + eslint clean. Note: book/doc must be served by the backend for real data; all states degrade gracefully (published spec 9.5.1 serves neither yet). Anonymous `audience:public` access (§6.7) and a book-aware AppDocsIndex are follow-ups; `doc-groups.ts` is now unused by the portal (left in place, not deleted). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7cda1ad commit dd8fc4d

11 files changed

Lines changed: 1182 additions & 98 deletions

File tree

apps/console/src/App.tsx

Lines changed: 11 additions & 1 deletion
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 DocsSlug from './pages/DocsSlug';
4748
import { LoginPage } from './pages/auth/LoginPage';
4849
import { RegisterPage } from './pages/auth/RegisterPage';
4950
import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage';
@@ -189,7 +190,16 @@ export function App() {
189190
<DocsIndex />
190191
</ProtectedRoute>
191192
} />
192-
<Route path="/docs/:name" element={
193+
{/* One portal segment: a book landing (slug) or a flat-doc permalink
194+
* that redirects to its canonical /docs/<book>/<name> (ADR-0046 §6). */}
195+
<Route path="/docs/:slug" element={
196+
<ProtectedRoute>
197+
<DocsSlug />
198+
</ProtectedRoute>
199+
} />
200+
{/* In-book reader: <slug> = book, <name> = doc (doc identity stays
201+
* single-coordinate; the book segment is derived navigation). */}
202+
<Route path="/docs/:slug/:name" element={
193203
<ProtectedRoute>
194204
<DocPage />
195205
</ProtectedRoute>

apps/console/src/AppContent.tsx

Lines changed: 5 additions & 1 deletion
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 DocsSlug = lazy(() => import('./pages/DocsSlug'));
3839

3940
// Note: marketplace routes (`system/marketplace`, `system/marketplace/:packageId`)
4041
// are registered by DefaultAppContent in @object-ui/app-shell so they're
@@ -92,7 +93,10 @@ 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>} />
95-
<Route path="docs/:name" element={<Suspense fallback={<LoadingScreen />}><DocPage /></Suspense>} />
96+
{/* ADR-0046 §6 — one segment resolves to a book landing or redirects a flat
97+
doc to its canonical /docs/<book>/<name>; then the in-book reader. */}
98+
<Route path="docs/:slug" element={<Suspense fallback={<LoadingScreen />}><DocsSlug /></Suspense>} />
99+
<Route path="docs/:slug/:name" element={<Suspense fallback={<LoadingScreen />}><DocPage /></Suspense>} />
96100
{/* Legacy URL redirects → metadata-admin engine (zero-cost compatibility). */}
97101
<Route path="system/objects" element={<ObjectRedirect />} />
98102
<Route path="system/objects/:objectName" element={<ObjectRedirect />} />
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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, bookSlug } from './book-nav';
16+
17+
/**
18+
* `/docs/:slug` — a book landing page (ADR-0046 §6). Resolves the book's spine
19+
* 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. Each
21+
* doc links to the in-book reader (`/docs/:slug/:name`), which keeps this
22+
* sidebar in view. `:slug` is the book's `slug` (an implicit per-package book
23+
* uses its packageId).
24+
*/
25+
export default function BookPage() {
26+
const { slug, appName } = useParams<{ slug: string; appName?: string }>();
27+
const { books, docs, state, error } = useBookData();
28+
29+
const found = useMemo(() => books.find((b) => bookSlug(b) === slug), [books, slug]);
30+
const resolved = useMemo(
31+
() => (found ? resolveBookTree(found, scopeDocsToBook(found, docs)) : null),
32+
[found, docs],
33+
);
34+
35+
const base = appName ? `/apps/${appName}/docs` : '/docs';
36+
const docHref = (name: string) => `${base}/${slug}/${name}`;
37+
38+
if (state === 'loading') {
39+
return (
40+
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
41+
<Loader2 className="h-5 w-5 animate-spin" aria-label="Loading documentation" />
42+
</div>
43+
);
44+
}
45+
46+
if (state === 'error' || !found || !resolved) {
47+
return (
48+
<DocShell breadcrumb={slug}>
49+
<div className="mx-auto flex max-w-3xl flex-col items-center gap-3 p-10 text-center">
50+
<FileQuestion className="h-10 w-10 text-muted-foreground" />
51+
<h1 className="text-lg font-semibold">
52+
{state === 'error' ? 'Failed to load documentation' : 'Book not found'}
53+
</h1>
54+
<p className="text-sm text-muted-foreground">
55+
{error ?? (
56+
<>
57+
No book named <code className="rounded bg-muted px-1 py-0.5">{slug}</code> is installed.
58+
</>
59+
)}
60+
</p>
61+
</div>
62+
</DocShell>
63+
);
64+
}
65+
66+
return (
67+
<DocShell breadcrumb={resolved.label ?? slug}>
68+
<div className="mx-auto flex max-w-5xl gap-8 p-4 sm:p-6">
69+
<aside className="hidden w-60 shrink-0 lg:block">
70+
<div className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-auto pr-1">
71+
<BookSidebar book={resolved} docHref={docHref} />
72+
</div>
73+
</aside>
74+
75+
<div className="min-w-0 max-w-3xl flex-1">
76+
<h1 className="text-2xl font-bold">{resolved.label ?? slug}</h1>
77+
{resolved.description ? (
78+
<p className="mt-2 text-sm text-muted-foreground">{resolved.description}</p>
79+
) : null}
80+
81+
<div className="mt-6 space-y-8">
82+
{resolved.groups.map((group) => (
83+
<section key={group.key}>
84+
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
85+
{group.label}
86+
</h2>
87+
<ul className="divide-y divide-border rounded-md border border-border">
88+
{group.entries
89+
.filter((e) => !e.separator)
90+
.map((entry, i) =>
91+
entry.href ? (
92+
<li key={`ext-${i}`}>
93+
<a
94+
href={entry.href}
95+
target="_blank"
96+
rel="noreferrer"
97+
className="flex items-start gap-3 px-3 py-3 hover:bg-muted/50"
98+
>
99+
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
100+
<span className="text-sm font-medium">{entry.label ?? entry.href}</span>
101+
</a>
102+
</li>
103+
) : entry.doc ? (
104+
<li key={entry.doc}>
105+
<Link
106+
to={docHref(entry.doc)}
107+
className="flex items-start gap-3 px-3 py-3 hover:bg-muted/50"
108+
>
109+
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
110+
<div className="min-w-0">
111+
<div className="text-sm font-medium">{entry.label ?? entry.doc}</div>
112+
{entry.description ? (
113+
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
114+
{entry.description}
115+
</div>
116+
) : null}
117+
</div>
118+
</Link>
119+
</li>
120+
) : null,
121+
)}
122+
</ul>
123+
</section>
124+
))}
125+
</div>
126+
</div>
127+
</div>
128+
</DocShell>
129+
);
130+
}
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: 26 additions & 4 deletions
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 { resolveBookTree, scopeDocsToBook, bookSlug, type ResolvedBook } from './book-nav';
1720

1821
interface DocItem {
1922
name: string;
@@ -35,9 +38,9 @@ interface DocItem {
3538
* "not found" notice — never an install-time or hard failure.
3639
*/
3740
export default function DocPage() {
38-
// `appName` is the parent route's package-id segment
39-
// (/apps/:appName/docs/:name); undefined on the legacy top-level /docs/:name.
40-
const { name, appName } = useParams<{ name: string; appName?: string }>();
41+
// Route is `/docs/:slug/:name` (`:slug` = book, `:name` = doc). `appName` is
42+
// the parent route's package-id segment under `/apps/:appName/docs/:slug/:name`.
43+
const { slug, name, appName } = useParams<{ slug?: string; name: string; appName?: string }>();
4144
const navigate = useNavigate();
4245
const adapter = useAdapter();
4346
const { t } = useObjectTranslation();
@@ -104,6 +107,18 @@ 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): the `:slug` segment names the book we're
111+
// reading in, so its spine renders as a persistent left nav. Resolved from
112+
// the portal book set; degrades to no sidebar if the slug is unknown (e.g.
113+
// the backend serves no books yet).
114+
const { books, docs: allDocs } = useBookData();
115+
const base = appName ? `/apps/${appName}/docs` : '/docs';
116+
const resolvedBook = useMemo<ResolvedBook | null>(() => {
117+
const book = books.find((b) => bookSlug(b) === slug);
118+
return book ? resolveBookTree(book, scopeDocsToBook(book, allDocs)) : null;
119+
}, [books, allDocs, slug]);
120+
const docHref = useCallback((docName: string) => `${base}/${slug}/${docName}`, [base, slug]);
121+
107122
if (state === 'loading') {
108123
return (
109124
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
@@ -141,7 +156,14 @@ export default function DocPage() {
141156

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

0 commit comments

Comments
 (0)