Skip to content

Commit 893e530

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(ADR-0046): package documentation portal + nav entry (#1686)
The /docs/:name viewer (DocPage) already existed but had no entry point — no index and no nav link — so a doc was reachable only by typing its exact URL. That is why docs were invisible in the console. - DocsIndex (/docs): platform-level portal listing every installed `doc` grouped by package namespace, each linking to the existing viewer. Fetches via meta.getItems('doc'); empty/error/loading states mirror DocPage. - groupDocsByPackage: pure, unit-tested grouping helper (6 tests). - UnifiedSidebar: "Documentation" entry in the base home/system nav — visible to ALL users, not gated behind workspace-admin (it lives in the always-shown items array, not the admin cluster). - App.tsx: register the top-level /docs route alongside /docs/:name; both are app-independent (reachable with no active app), keeping the doc URL single-coordinate as ADR-0046 specifies. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 43e6d25 commit 893e530

6 files changed

Lines changed: 272 additions & 3 deletions

File tree

.changeset/adr-0046-docs-portal.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@object-ui/console": minor
3+
"@object-ui/app-shell": minor
4+
---
5+
6+
Package documentation portal + nav entry (ADR-0046).
7+
8+
The `/docs/:name` viewer already existed but had no way in: no index and no
9+
navigation entry, so a doc was reachable only by typing its exact URL. Adds a
10+
platform-level docs portal at `/docs` (`DocsIndex`) that lists every installed
11+
`doc` metadata item grouped by package namespace, each linking to the existing
12+
viewer. A "Documentation" entry now appears in the home/system navigation
13+
(`UnifiedSidebar`), visible to all users (not gated behind workspace-admin), so
14+
docs are discoverable. The viewer route stays app-independent and
15+
single-coordinate (`/docs/<name>`); per-app deep-links remain opt-in `url` nav
16+
items pointing at that same global URL. Doc grouping is a pure, unit-tested
17+
helper (`groupDocsByPackage`).

apps/console/src/App.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { FormPage } from './components/FormPage';
4343
import { MetadataHmrReloader } from './components/MetadataHmrReloader';
4444
import SharedRecordPage from './pages/SharedRecordPage';
4545
import DocPage from './pages/DocPage';
46+
import DocsIndex from './pages/DocsIndex';
4647
import { LoginPage } from './pages/auth/LoginPage';
4748
import { RegisterPage } from './pages/auth/RegisterPage';
4849
import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage';
@@ -179,9 +180,15 @@ export function App() {
179180
<FormPage mode="internal" />
180181
</ProtectedRoute>
181182
} />
182-
{/* Package documentation (ADR-0046): one route renders any
183-
* installed `doc` metadata item; cross-references between docs
184-
* resolve to this same route. */}
183+
{/* Package documentation (ADR-0046): a platform-level portal
184+
* lists every installed `doc` (grouped by package), and one
185+
* viewer route renders any item; cross-references between docs
186+
* resolve to that same viewer route. Both are app-independent. */}
187+
<Route path="/docs" element={
188+
<ProtectedRoute>
189+
<DocsIndex />
190+
</ProtectedRoute>
191+
} />
185192
<Route path="/docs/:name" element={
186193
<ProtectedRoute>
187194
<DocPage />
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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 { useEffect, useState } from 'react';
10+
import { Link } from 'react-router-dom';
11+
import { AlertCircle, BookOpen, Loader2 } from 'lucide-react';
12+
import { useAdapter } from '@object-ui/app-shell';
13+
import { type DocGroup, groupDocsByPackage } from './doc-groups';
14+
15+
/**
16+
* `/docs` — the platform-level documentation portal (ADR-0046).
17+
*
18+
* Lists every installed `doc` metadata item, grouped by package
19+
* namespace, each linking to the single-doc viewer at `/docs/<name>`.
20+
* The viewer route is app-independent, so this portal is the canonical
21+
* place to discover docs regardless of which app (if any) is active.
22+
* An app may additionally surface a contextual link into a specific
23+
* `/docs/<name>`, but discovery lives here.
24+
*/
25+
export default function DocsIndex() {
26+
const adapter = useAdapter();
27+
const [groups, setGroups] = useState<DocGroup[]>([]);
28+
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
29+
const [errorMessage, setErrorMessage] = useState<string>('');
30+
31+
useEffect(() => {
32+
let cancelled = false;
33+
async function load() {
34+
if (!adapter) return;
35+
const client: any = adapter.getClient();
36+
if (!client?.meta?.getItems) {
37+
setErrorMessage('meta.getItems is not available on this client');
38+
setState('error');
39+
return;
40+
}
41+
setState('loading');
42+
try {
43+
const result: any = await client.meta.getItems('doc');
44+
const items: any[] = Array.isArray(result)
45+
? result
46+
: Array.isArray(result?.items)
47+
? result.items
48+
: Array.isArray(result?.value)
49+
? result.value
50+
: [];
51+
if (cancelled) return;
52+
setGroups(
53+
groupDocsByPackage(
54+
items.map((it) => ({ name: it?.name, label: it?.label })),
55+
),
56+
);
57+
setState('ready');
58+
} catch (err: any) {
59+
if (cancelled) return;
60+
setErrorMessage(err?.message ?? 'Failed to load documentation');
61+
setState('error');
62+
}
63+
}
64+
void load();
65+
return () => {
66+
cancelled = true;
67+
};
68+
}, [adapter]);
69+
70+
if (state === 'loading') {
71+
return (
72+
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
73+
<Loader2 className="h-5 w-5 animate-spin" aria-label="Loading documentation" />
74+
</div>
75+
);
76+
}
77+
78+
if (state === 'error') {
79+
return (
80+
<div className="mx-auto flex max-w-3xl flex-col items-center gap-3 p-10 text-center">
81+
<AlertCircle className="h-10 w-10 text-destructive" />
82+
<h1 className="text-lg font-semibold">Failed to load documentation</h1>
83+
<p className="text-sm text-muted-foreground">{errorMessage}</p>
84+
</div>
85+
);
86+
}
87+
88+
return (
89+
<div className="mx-auto max-w-3xl p-4 sm:p-6">
90+
<div className="mb-6 flex items-center gap-2">
91+
<BookOpen className="h-6 w-6 text-muted-foreground" />
92+
<h1 className="text-2xl font-semibold">Documentation</h1>
93+
</div>
94+
95+
{groups.length === 0 ? (
96+
<div className="flex flex-col items-center gap-3 py-16 text-center text-muted-foreground">
97+
<BookOpen className="h-10 w-10" />
98+
<p className="text-sm">
99+
No documentation is installed. Packages ship docs as flat
100+
<code className="mx-1 rounded bg-muted px-1 py-0.5">src/docs/*.md</code>
101+
files (ADR-0046).
102+
</p>
103+
</div>
104+
) : (
105+
<div className="space-y-8">
106+
{groups.map((group) => (
107+
<section key={group.pkg}>
108+
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
109+
{group.pkg}
110+
</h2>
111+
<ul className="divide-y divide-border rounded-md border border-border">
112+
{group.docs.map((doc) => (
113+
<li key={doc.name}>
114+
<Link
115+
to={`/docs/${doc.name}`}
116+
className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted/50"
117+
>
118+
<BookOpen className="h-4 w-4 shrink-0 text-muted-foreground" />
119+
<span className="font-medium">{doc.label ?? doc.name}</span>
120+
<code className="ml-auto text-xs text-muted-foreground">{doc.name}</code>
121+
</Link>
122+
</li>
123+
))}
124+
</ul>
125+
</section>
126+
))}
127+
</div>
128+
)}
129+
</div>
130+
);
131+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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, expect, it } from 'vitest';
10+
import { groupDocsByPackage } from './doc-groups';
11+
12+
describe('groupDocsByPackage (ADR-0046)', () => {
13+
it('groups docs by namespace prefix', () => {
14+
const groups = groupDocsByPackage([
15+
{ name: 'crm_user_guide' },
16+
{ name: 'crm_index' },
17+
{ name: 'hr_onboarding' },
18+
]);
19+
expect(groups.map((g) => g.pkg)).toEqual(['crm', 'hr']);
20+
expect(groups[0].docs.map((d) => d.name)).toEqual(['crm_index', 'crm_user_guide']);
21+
expect(groups[1].docs.map((d) => d.name)).toEqual(['hr_onboarding']);
22+
});
23+
24+
it('sorts groups alphabetically and docs by label then name', () => {
25+
const groups = groupDocsByPackage([
26+
{ name: 'zoo_b', label: 'Zebra' },
27+
{ name: 'zoo_a', label: 'Antelope' },
28+
{ name: 'apex_x' },
29+
]);
30+
expect(groups.map((g) => g.pkg)).toEqual(['apex', 'zoo']);
31+
// Sorted by label: Antelope before Zebra.
32+
expect(groups[1].docs.map((d) => d.name)).toEqual(['zoo_a', 'zoo_b']);
33+
});
34+
35+
it('groups a bare (unprefixed) name under itself', () => {
36+
const groups = groupDocsByPackage([{ name: 'readme' }]);
37+
expect(groups).toEqual([{ pkg: 'readme', docs: [{ name: 'readme' }] }]);
38+
});
39+
40+
it('ignores a leading underscore (no empty-string package)', () => {
41+
// indexOf('_') === 0 → not a real prefix; group under the whole name.
42+
const groups = groupDocsByPackage([{ name: '_hidden' }]);
43+
expect(groups[0].pkg).toBe('_hidden');
44+
});
45+
46+
it('drops malformed items without a name', () => {
47+
const groups = groupDocsByPackage([
48+
{ name: 'crm_a' },
49+
{ name: '' },
50+
// @ts-expect-error — exercising the runtime guard
51+
{ label: 'no name' },
52+
// @ts-expect-error — exercising the runtime guard
53+
null,
54+
]);
55+
expect(groups).toEqual([{ pkg: 'crm', docs: [{ name: 'crm_a' }] }]);
56+
});
57+
58+
it('returns an empty array for no docs', () => {
59+
expect(groupDocsByPackage([])).toEqual([]);
60+
});
61+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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+
export interface DocListItem {
10+
name: string;
11+
label?: string;
12+
}
13+
14+
export interface DocGroup {
15+
/** Namespace prefix (the package), derived from the doc name. */
16+
pkg: string;
17+
docs: DocListItem[];
18+
}
19+
20+
/**
21+
* Group docs by their namespace prefix (everything before the first `_`).
22+
*
23+
* Doc names are namespace-prefixed by build-time convention (ADR-0046) —
24+
* `crm_user_guide` belongs to package `crm`. The spec carries no explicit
25+
* `namespace` field, so the package is derived from the name; a bare name
26+
* with no underscore groups under itself. Groups and the docs within each
27+
* are returned in stable alphabetical order. Malformed items (missing
28+
* name) are dropped.
29+
*/
30+
export function groupDocsByPackage(items: DocListItem[]): DocGroup[] {
31+
const byPkg = new Map<string, DocListItem[]>();
32+
for (const item of items) {
33+
if (!item || typeof item.name !== 'string' || !item.name) continue;
34+
const underscore = item.name.indexOf('_');
35+
const pkg = underscore > 0 ? item.name.slice(0, underscore) : item.name;
36+
const bucket = byPkg.get(pkg);
37+
if (bucket) bucket.push(item);
38+
else byPkg.set(pkg, [item]);
39+
}
40+
return Array.from(byPkg.keys())
41+
.sort((a, b) => a.localeCompare(b))
42+
.map((pkg) => ({
43+
pkg,
44+
docs: byPkg
45+
.get(pkg)!
46+
.slice()
47+
.sort((a, b) => (a.label ?? a.name).localeCompare(b.label ?? b.name)),
48+
}));
49+
}

packages/app-shell/src/layout/UnifiedSidebar.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,10 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) {
225225
const homeNavigation: NavigationItem[] = React.useMemo(() => {
226226
const items: NavigationItem[] = [
227227
{ id: 'home-dashboard', label: t('home.nav', { defaultValue: 'Home' }), type: 'url' as const, url: '/home', icon: 'home' },
228+
// Package documentation portal (ADR-0046) — visible to all users, not
229+
// just workspace admins, so it lives in the base items rather than the
230+
// admin cluster below.
231+
{ id: 'docs', label: t('layout.systemNav.documentation', { defaultValue: 'Documentation' }), type: 'url' as const, url: '/docs', icon: 'book-open' },
228232
];
229233
if (isWorkspaceAdmin) {
230234
const adminItems: NavigationItem[] = [

0 commit comments

Comments
 (0)