Skip to content

Commit 4d331ad

Browse files
authored
feat(console): book-driven documentation portal (ADR-0046 §6) (#1776)
1 parent 0763559 commit 4d331ad

14 files changed

Lines changed: 1452 additions & 190 deletions

apps/console/src/App.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ 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';
48+
import DocsLayout from './pages/DocsLayout';
4749
import { LoginPage } from './pages/auth/LoginPage';
4850
import { RegisterPage } from './pages/auth/RegisterPage';
4951
import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage';
@@ -184,16 +186,22 @@ export function App() {
184186
* lists every installed `doc` (grouped by package), and one
185187
* viewer route renders any item; cross-references between docs
186188
* resolve to that same viewer route. Both are app-independent. */}
189+
{/* Docs portal (ADR-0046 §6). The layout fetches book + doc once
190+
* and shares it with every child route via context. Children:
191+
* index → book index
192+
* :slug → a book landing, or a flat-doc permalink that
193+
* redirects to its canonical /docs/<book>/<name>
194+
* :slug/:name → in-book reader (doc identity stays single-
195+
* coordinate; the book segment is derived nav). */}
187196
<Route path="/docs" element={
188197
<ProtectedRoute>
189-
<DocsIndex />
198+
<DocsLayout />
190199
</ProtectedRoute>
191-
} />
192-
<Route path="/docs/:name" element={
193-
<ProtectedRoute>
194-
<DocPage />
195-
</ProtectedRoute>
196-
} />
200+
}>
201+
<Route index element={<DocsIndex />} />
202+
<Route path=":slug" element={<DocsSlug />} />
203+
<Route path=":slug/:name" element={<DocPage />} />
204+
</Route>
197205
<Route path="/home" element={
198206
<ProtectedRoute>
199207
<HomeRoute />

apps/console/src/AppContent.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ 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'));
39+
const DocsLayout = lazy(() => import('./pages/DocsLayout'));
3840

3941
// Note: marketplace routes (`system/marketplace`, `system/marketplace/:packageId`)
4042
// are registered by DefaultAppContent in @object-ui/app-shell so they're
@@ -91,8 +93,14 @@ const systemRoutes = (
9193
<Route path="developer/integrations" element={<Suspense fallback={<LoadingScreen />}><IntegrationsPage /></Suspense>} />
9294
{/* ADR-0048 — package docs under the package container: app-scoped index
9395
(/apps/:packageId/docs) + single-doc viewer (/apps/:packageId/docs/:name) */}
94-
<Route path="docs" element={<Suspense fallback={<LoadingScreen />}><AppDocsIndex /></Suspense>} />
95-
<Route path="docs/:name" element={<Suspense fallback={<LoadingScreen />}><DocPage /></Suspense>} />
96+
{/* ADR-0046 §6 — the docs layout fetches book + doc once and shares it with
97+
the app-scoped index, book landings, and reader (one segment resolves to
98+
a book landing or redirects a flat doc to /docs/<book>/<name>). */}
99+
<Route path="docs" element={<Suspense fallback={<LoadingScreen />}><DocsLayout /></Suspense>}>
100+
<Route index element={<Suspense fallback={<LoadingScreen />}><AppDocsIndex /></Suspense>} />
101+
<Route path=":slug" element={<Suspense fallback={<LoadingScreen />}><DocsSlug /></Suspense>} />
102+
<Route path=":slug/:name" element={<Suspense fallback={<LoadingScreen />}><DocPage /></Suspense>} />
103+
</Route>
96104
{/* Legacy URL redirects → metadata-admin engine (zero-cost compatibility). */}
97105
<Route path="system/objects" element={<ObjectRedirect />} />
98106
<Route path="system/objects/:objectName" element={<ObjectRedirect />} />

apps/console/src/pages/AppDocsIndex.tsx

Lines changed: 40 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -6,77 +6,33 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88

9-
import { useEffect, useState } from 'react';
10-
import { Link, useParams } from 'react-router-dom';
11-
import { AlertCircle, BookOpen, Loader2 } from 'lucide-react';
12-
import { useAdapter } from '@object-ui/app-shell';
9+
import { useMemo } from 'react';
10+
import { Link, Navigate, useParams } from 'react-router-dom';
11+
import { BookOpen, Loader2 } from 'lucide-react';
1312
import { DocShell } from './DocShell';
14-
15-
interface AppDocItem {
16-
name: string;
17-
label?: string;
18-
description?: string;
19-
}
13+
import { useBookData } from './use-book-data';
14+
import { buildBookCards, pkgOfBook, type Book } from './book-nav';
2015

2116
/**
22-
* `/apps/:appName/docs` — the app-scoped documentation index (ADR-0046/0048).
17+
* `/apps/:appName/docs` — the app-scoped documentation index (ADR-0046 §6 /
18+
* ADR-0048). The package-scoped sibling of the platform `/docs` portal: it
19+
* shows the books owned by the app whose container this route renders inside
20+
* (`:appName` is the package-id segment).
2321
*
24-
* The platform `/docs` portal lists *every* installed doc grouped by package;
25-
* this is its package-scoped sibling: the docs owned by the app whose
26-
* container this route renders inside (`:appName` is the package-id segment,
27-
* matched against each doc's `_packageId`). It is the landing target for the
28-
* header Help menu's "This app's docs" entry when an app ships more than one
29-
* doc — a single-doc app deep-links straight to the viewer instead.
30-
*
31-
* Like the rest of the doc routes it degrades softly: an app with no docs
32-
* shows an empty-state notice, never a hard error.
22+
* Book-driven like the portal: a package always has at least its implicit book
23+
* (§6.4). The common single-book case lands straight on that book's grouped
24+
* reader; a package with several books lists them. Reads the shared book/doc
25+
* data provided by the `/docs` layout — no separate fetch.
3326
*/
3427
export default function AppDocsIndex() {
35-
// `appName` is the parent route's package-id segment (/apps/:appName/docs).
3628
const { appName } = useParams<{ appName?: string }>();
37-
const adapter = useAdapter();
38-
const [docs, setDocs] = useState<AppDocItem[]>([]);
39-
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
40-
const [errorMessage, setErrorMessage] = useState<string>('');
29+
const { books, docs, state } = useBookData();
4130

42-
useEffect(() => {
43-
let cancelled = false;
44-
async function load() {
45-
if (!adapter) return;
46-
const client: any = adapter.getClient();
47-
if (!client?.meta?.getItems) {
48-
setErrorMessage('meta.getItems is not available on this client');
49-
setState('error');
50-
return;
51-
}
52-
setState('loading');
53-
try {
54-
const result: any = await client.meta.getItems('doc');
55-
const items: any[] = Array.isArray(result)
56-
? result
57-
: Array.isArray(result?.items)
58-
? result.items
59-
: Array.isArray(result?.value)
60-
? result.value
61-
: [];
62-
if (cancelled) return;
63-
const mine = items
64-
.filter((it) => it && it.name && it._packageId === appName)
65-
.map((it) => ({ name: it.name, label: it.label, description: it.description }))
66-
.sort((a, b) => (a.label ?? a.name).localeCompare(b.label ?? b.name));
67-
setDocs(mine);
68-
setState('ready');
69-
} catch (err: any) {
70-
if (cancelled) return;
71-
setErrorMessage(err?.message ?? 'Failed to load documentation');
72-
setState('error');
73-
}
74-
}
75-
void load();
76-
return () => {
77-
cancelled = true;
78-
};
79-
}, [adapter, appName]);
31+
const myBooks = useMemo<Book[]>(
32+
() => books.filter((b) => pkgOfBook(b) === appName),
33+
[books, appName],
34+
);
35+
const cards = useMemo(() => buildBookCards(myBooks, docs), [myBooks, docs]);
8036

8137
if (state === 'loading') {
8238
return (
@@ -86,45 +42,45 @@ export default function AppDocsIndex() {
8642
);
8743
}
8844

89-
if (state === 'error') {
90-
return (
91-
<div className="mx-auto flex max-w-3xl flex-col items-center gap-3 p-10 text-center">
92-
<AlertCircle className="h-10 w-10 text-destructive" />
93-
<h1 className="text-lg font-semibold">Failed to load documentation</h1>
94-
<p className="text-sm text-muted-foreground">{errorMessage}</p>
95-
</div>
96-
);
45+
// One book (the usual case) → land directly on its grouped reader.
46+
if (cards.length === 1) {
47+
return <Navigate to={`/apps/${appName}/docs/${cards[0].slug}`} replace />;
9748
}
9849

9950
return (
10051
<DocShell>
10152
<div className="mx-auto max-w-3xl p-4 sm:p-6">
102-
{docs.length === 0 ? (
53+
{cards.length === 0 ? (
10354
<div className="flex flex-col items-center gap-3 py-16 text-center text-muted-foreground">
10455
<BookOpen className="h-10 w-10" />
105-
<p className="text-sm">
106-
This app does not ship any documentation yet.
107-
</p>
56+
<p className="text-sm">This app does not ship any documentation yet.</p>
10857
<Link to="/docs" className="text-sm font-medium text-primary hover:underline">
10958
Browse all documentation
11059
</Link>
11160
</div>
11261
) : (
113-
<ul className="divide-y divide-border rounded-md border border-border">
114-
{docs.map((doc) => (
115-
<li key={doc.name}>
62+
<ul className="grid gap-3 sm:grid-cols-2">
63+
{cards.map((card) => (
64+
<li key={card.name}>
11665
<Link
117-
to={`/apps/${appName}/docs/${doc.name}`}
118-
className="flex items-start gap-3 px-3 py-3 hover:bg-muted/50"
66+
to={`/apps/${appName}/docs/${card.slug}`}
67+
className="flex h-full items-start gap-3 rounded-md border border-border p-3 hover:bg-muted/50"
11968
>
120-
<BookOpen className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
69+
<BookOpen className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
12170
<div className="min-w-0">
122-
<div className="text-sm font-medium">{doc.label ?? doc.name}</div>
123-
{doc.description ? (
71+
<div className="text-sm font-semibold">{card.label}</div>
72+
{card.description ? (
12473
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
125-
{doc.description}
74+
{card.description}
75+
</div>
76+
) : card.subtitle ? (
77+
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground/70">
78+
{card.subtitle}
12679
</div>
12780
) : null}
81+
<div className="mt-1 text-xs text-muted-foreground/80">
82+
{card.docCount} {card.docCount === 1 ? 'article' : 'articles'}
83+
</div>
12884
</div>
12985
</Link>
13086
</li>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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 { Navigate, useParams } from 'react-router-dom';
11+
import { BookOpen, FileQuestion, Loader2 } from 'lucide-react';
12+
import { DocShell } from './DocShell';
13+
import { useBookData } from './use-book-data';
14+
import { resolveBookTree, scopeDocsToBook, bookSlug, firstDoc } from './book-nav';
15+
16+
/**
17+
* `/docs/:slug` — a book landing (ADR-0046 §6). Opening a book means starting
18+
* to read it, so this resolves the book's spine and redirects to its overview
19+
* doc (`*_index`, else the first doc in order); the reader then shows that doc
20+
* with the book sidebar. This avoids duplicating the table of contents (the
21+
* sidebar already is it). A book with no readable docs shows an empty state.
22+
*/
23+
export default function BookPage() {
24+
const { slug, appName } = useParams<{ slug: string; appName?: string }>();
25+
const { books, docs, state } = useBookData();
26+
27+
const found = useMemo(() => books.find((b) => bookSlug(b) === slug), [books, slug]);
28+
const opensTo = useMemo(
29+
() => (found ? firstDoc(resolveBookTree(found, scopeDocsToBook(found, docs))) : undefined),
30+
[found, docs],
31+
);
32+
33+
if (state === 'loading') {
34+
return (
35+
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
36+
<Loader2 className="h-5 w-5 animate-spin" aria-label="Loading documentation" />
37+
</div>
38+
);
39+
}
40+
41+
if (found && opensTo) {
42+
const base = appName ? `/apps/${appName}/docs` : '/docs';
43+
return <Navigate to={`${base}/${slug}/${opensTo}`} replace />;
44+
}
45+
46+
// Unknown book, or a book with no readable docs yet.
47+
return (
48+
<DocShell breadcrumb={found ? (found.label ?? slug) : slug}>
49+
<div className="mx-auto flex max-w-3xl flex-col items-center gap-3 p-10 text-center">
50+
{found ? (
51+
<>
52+
<BookOpen className="h-10 w-10 text-muted-foreground" />
53+
<h1 className="text-lg font-semibold">{found.label ?? slug}</h1>
54+
<p className="text-sm text-muted-foreground">This book has no documents yet.</p>
55+
</>
56+
) : (
57+
<>
58+
<FileQuestion className="h-10 w-10 text-muted-foreground" />
59+
<h1 className="text-lg font-semibold">Book not found</h1>
60+
<p className="text-sm text-muted-foreground">
61+
No book named <code className="rounded bg-muted px-1 py-0.5">{slug}</code> is installed.
62+
</p>
63+
</>
64+
)}
65+
</div>
66+
</DocShell>
67+
);
68+
}
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+
});

0 commit comments

Comments
 (0)