Skip to content

Commit c328e5e

Browse files
committed
feat: load docs space from remote Git with bundled fallback
Add docs-loader module that clones github.com/nsheaps/cept scoped to docs/content/ using the existing cloneRemoteRepo infrastructure. Cloned content is cached in .cept/docs-cache/ for instant subsequent loads. When cloning fails (offline, CORS errors), falls back to the bundled DOCS_PAGES/DOCS_CONTENT constants. - New docs-loader.ts with loadDocs() and getRemoteDocsSourceUrl() - App.tsx uses loader on first docs space open - Sidebar reflects actual repository layout when loaded from remote - View on GitHub links work with both remote file paths and bundled IDs - Loading state shown while clone is in progress - Banner indicates content source (remote, cached, or bundled fallback) Closes #68 https://claude.ai/code/session_01TF6rYpGkqBWjzNQHgqrVmD
1 parent ca45381 commit c328e5e

3 files changed

Lines changed: 297 additions & 12 deletions

File tree

packages/ui/src/components/App.tsx

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import type { SearchResult } from './search/SearchPanel.js';
1212
import { PageHeader } from './page-header/PageHeader.js';
1313
import { SettingsModal, DEFAULT_SETTINGS } from './settings/SettingsModal.js';
1414
import type { CeptSettings, SpaceInfo } from './settings/SettingsModal.js';
15-
import { DOCS_PAGES, DOCS_CONTENT, DOCS_SPACE_INFO, getDocsSourceUrl, resolveDocsContent } from './docs/docs-content.js';
15+
import { DOCS_PAGES, DOCS_CONTENT, DOCS_SPACE_INFO, resolveDocsContent } from './docs/docs-content.js';
16+
import { loadDocs, getRemoteDocsSourceUrl } from './docs/docs-loader.js';
17+
import type { DocsLoadResult } from './docs/docs-loader.js';
1618
import {
1719
useStorage,
1820
useWorkspacePersistence,
@@ -111,6 +113,10 @@ export function App() {
111113
const [activeSpace, setActiveSpace] = useState<'user' | 'docs'>('user');
112114
const [docsSelectedPageId, setDocsSelectedPageId] = useState<string | undefined>('docs-index');
113115
const [docsPages, setDocsPages] = useState<PageTreeNode[]>(DOCS_PAGES);
116+
const [docsContents, setDocsContents] = useState<Record<string, string>>(DOCS_CONTENT);
117+
const [docsSource, setDocsSource] = useState<DocsLoadResult['source']>('bundled');
118+
const [docsLoading, setDocsLoading] = useState(false);
119+
const docsLoadedRef = useRef(false);
114120
const [importDialogOpen, setImportDialogOpen] = useState(false);
115121
const [importSource, setImportSource] = useState<ImportSource>('notion');
116122
const [exportDialogOpen, setExportDialogOpen] = useState(false);
@@ -952,10 +958,32 @@ export function App() {
952958

953959
const handleOpenDocs = useCallback(() => {
954960
setActiveSpace('docs');
955-
setDocsSelectedPageId('docs-index');
956-
setDocsPages(DOCS_PAGES);
957-
pushRoute({ space: 'docs', pageId: 'docs-index' });
958-
}, []);
961+
// Select the first page — remote pages use file-path IDs, bundled use 'docs-index'
962+
const firstPageId = docsPages.length > 0 ? docsPages[0].id : 'docs-index';
963+
setDocsSelectedPageId(firstPageId);
964+
pushRoute({ space: 'docs', pageId: firstPageId });
965+
966+
// Trigger remote load if not loaded yet
967+
if (!docsLoadedRef.current && backend instanceof BrowserFsBackend) {
968+
docsLoadedRef.current = true;
969+
setDocsLoading(true);
970+
loadDocs(backend as BrowserFsBackend, false, (msg) => {
971+
addToast(msg, 'info');
972+
}).then((result) => {
973+
setDocsPages(result.pages);
974+
setDocsContents(result.pageContents);
975+
setDocsSource(result.source);
976+
// Select the first page of the loaded docs
977+
if (result.pages.length > 0) {
978+
setDocsSelectedPageId(result.pages[0].id);
979+
}
980+
}).catch(() => {
981+
// Already falls back to bundled inside loadDocs
982+
}).finally(() => {
983+
setDocsLoading(false);
984+
});
985+
}
986+
}, [backend, docsPages, addToast]);
959987

960988
/** Handle "Add Space" from the remote repo form in the wizard. */
961989
const handleAddRemoteRepo = useCallback(async (config: RemoteSpaceConfig) => {
@@ -1208,16 +1236,20 @@ export function App() {
12081236
contentSize,
12091237
});
12101238
}
1211-
list.push(DOCS_SPACE_INFO);
1239+
list.push({
1240+
...DOCS_SPACE_INFO,
1241+
pageCount: Object.keys(docsContents).length,
1242+
contentSize: Object.values(docsContents).reduce((sum, c) => sum + c.length, 0),
1243+
});
12121244
return list;
1213-
}, [hasStarted, pages, pageContents, spaceName, spacesManifest, userSpaceId, backend.type, inactiveSpaceStats]);
1245+
}, [hasStarted, pages, pageContents, spaceName, spacesManifest, userSpaceId, backend.type, inactiveSpaceStats, docsContents]);
12141246

12151247
/** Known Git hosting domains — only these produce "View on GitHub" links. */
12161248
const KNOWN_GIT_HOSTS = ['github.com', 'gitlab.com', 'bitbucket.org'];
12171249

12181250
const currentGithubUrl = useMemo((): string | undefined => {
12191251
if (activeSpace === 'docs' && docsSelectedPageId) {
1220-
return getDocsSourceUrl(docsSelectedPageId) ?? undefined;
1252+
return getRemoteDocsSourceUrl(docsSelectedPageId, docsSource);
12211253
}
12221254
if (activeSpace !== 'user' || !selectedPageId || !isRemoteSpaceId(userSpaceId)) return undefined;
12231255
const parsed = parseRemoteSpaceId(userSpaceId);
@@ -1227,7 +1259,7 @@ export function App() {
12271259
if (!KNOWN_GIT_HOSTS.includes(host)) return undefined;
12281260
const subPath = parsed.subPath ? `${parsed.subPath}/` : '';
12291261
return `https://${parsed.repo}/blob/${parsed.branch}/${subPath}${selectedPageId}`;
1230-
}, [activeSpace, docsSelectedPageId, selectedPageId, userSpaceId]);
1262+
}, [activeSpace, docsSelectedPageId, docsSource, selectedPageId, userSpaceId]);
12311263

12321264
const commandItems: CommandItem[] = useMemo(() => [
12331265
{ id: 'new-page', title: 'New Page', icon: '\u{1F4C4}', category: 'Pages', action: () => handlePageAdd() },
@@ -1363,18 +1395,28 @@ export function App() {
13631395
)}
13641396
<section className="flex-1 min-w-0 p-4 md:p-8 overflow-y-auto">
13651397
{isDocsActive ? (
1366-
docsSelectedPageId && DOCS_CONTENT[docsSelectedPageId] ? (
1398+
docsLoading ? (
1399+
<div className="text-center text-gray-400 mt-20" data-testid="docs-loading">
1400+
<p>Loading documentation...</p>
1401+
</div>
1402+
) : docsSelectedPageId && docsContents[docsSelectedPageId] ? (
13671403
<>
13681404
<div className="cept-docs-banner" data-testid="docs-banner">
13691405
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
13701406
<rect x="2" y="1" width="12" height="14" rx="1" />
13711407
<path d="M5 5h6M5 8h6M5 11h3" />
13721408
</svg>
1373-
<span>Read-only &mdash; sourced from docs/ in the Git repository</span>
1409+
<span>
1410+
Read-only &mdash; {docsSource === 'bundled'
1411+
? 'sourced from bundled docs (offline fallback)'
1412+
: docsSource === 'cache'
1413+
? 'sourced from cached Git clone'
1414+
: 'sourced from docs/ in the Git repository'}
1415+
</span>
13741416
</div>
13751417
<CeptEditor
13761418
key={`docs-${docsSelectedPageId}`}
1377-
content={resolveDocsContent(DOCS_CONTENT[docsSelectedPageId])}
1419+
content={docsSource === 'bundled' ? resolveDocsContent(docsContents[docsSelectedPageId]) : docsContents[docsSelectedPageId]}
13781420
placeholder=""
13791421
onUpdate={() => {/* read-only */}}
13801422
editable={false}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { getRemoteDocsSourceUrl } from './docs-loader.js';
3+
4+
// Mock the docs-content module to avoid importing the full bundled content
5+
vi.mock('./docs-content.js', () => ({
6+
DOCS_PAGES: [{ id: 'docs-index', title: 'Test', children: [] }],
7+
DOCS_CONTENT: { 'docs-index': '# Test' },
8+
resolveDocsContent: (c: string) => c.replace(/\{\{base\}\}/g, '/'),
9+
getDocsSourceUrl: (id: string) => {
10+
const paths: Record<string, string> = {
11+
'docs-index': 'docs/content/index.md',
12+
'docs-introduction': 'docs/content/getting-started/introduction.md',
13+
};
14+
return paths[id] ? `https://github.com/nsheaps/cept/blob/main/${paths[id]}` : undefined;
15+
},
16+
}));
17+
18+
describe('getRemoteDocsSourceUrl', () => {
19+
it('returns GitHub URL for bundled page IDs using source path lookup', () => {
20+
const url = getRemoteDocsSourceUrl('docs-index', 'bundled');
21+
expect(url).toBe('https://github.com/nsheaps/cept/blob/main/docs/content/index.md');
22+
});
23+
24+
it('returns undefined for unknown bundled page IDs', () => {
25+
const url = getRemoteDocsSourceUrl('unknown-page', 'bundled');
26+
expect(url).toBeUndefined();
27+
});
28+
29+
it('returns GitHub URL for remote page IDs using file path', () => {
30+
const url = getRemoteDocsSourceUrl('getting-started/introduction.md', 'remote');
31+
expect(url).toBe('https://github.com/nsheaps/cept/blob/main/docs/content/getting-started/introduction.md');
32+
});
33+
34+
it('returns GitHub URL for cached page IDs using file path', () => {
35+
const url = getRemoteDocsSourceUrl('reference/roadmap.md', 'cache');
36+
expect(url).toBe('https://github.com/nsheaps/cept/blob/main/docs/content/reference/roadmap.md');
37+
});
38+
39+
it('uses custom branch when provided', () => {
40+
const url = getRemoteDocsSourceUrl('index.md', 'remote', 'develop');
41+
expect(url).toBe('https://github.com/nsheaps/cept/blob/develop/docs/content/index.md');
42+
});
43+
});
44+
45+
describe('loadDocs', () => {
46+
it('returns bundled content when backend is null', async () => {
47+
// Dynamic import to get loadDocs after mocks are set up
48+
const { loadDocs } = await import('./docs-loader.js');
49+
const result = await loadDocs(null);
50+
expect(result.source).toBe('bundled');
51+
expect(result.pages).toHaveLength(1);
52+
expect(result.pages[0].id).toBe('docs-index');
53+
expect(result.pageContents['docs-index']).toBe('# Test');
54+
});
55+
});
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/**
2+
* docs-loader — Load documentation from the remote Git repository with
3+
* bundled fallback.
4+
*
5+
* On first load (no cache), attempts to clone `github.com/nsheaps/cept`
6+
* scoped to the `docs/content` subdirectory. Cloned pages and content are
7+
* cached in the StorageBackend so subsequent loads are instant.
8+
*
9+
* When cloning fails (offline, CORS, network issues) the bundled constants
10+
* from `docs-content.ts` are used as a fallback.
11+
*/
12+
13+
import type { PageTreeNode } from '../sidebar/PageTreeItem.js';
14+
import { BrowserFsBackend } from '@cept/core';
15+
import type { GitHttp } from '@cept/core';
16+
import { cloneRemoteRepo } from '../storage/git-space.js';
17+
import { DOCS_PAGES, DOCS_CONTENT, resolveDocsContent, getDocsSourceUrl } from './docs-content.js';
18+
19+
/** Result of loading docs (from remote or fallback). */
20+
export interface DocsLoadResult {
21+
pages: PageTreeNode[];
22+
pageContents: Record<string, string>;
23+
/** Where the content came from */
24+
source: 'remote' | 'cache' | 'bundled';
25+
}
26+
27+
const DOCS_REPO_URL = 'github.com/nsheaps/cept';
28+
const DOCS_BRANCH = 'main';
29+
const DOCS_SUB_PATH = 'docs/content';
30+
const CORS_PROXY = 'https://cors.isomorphic-git.org';
31+
32+
/** Storage paths for cached remote docs */
33+
const CACHE_DIR = '.cept/docs-cache';
34+
const CACHE_PAGES_FILE = `${CACHE_DIR}/pages.json`;
35+
const CACHE_CONTENTS_FILE = `${CACHE_DIR}/contents.json`;
36+
const CACHE_META_FILE = `${CACHE_DIR}/meta.json`;
37+
38+
function encode(value: unknown): Uint8Array {
39+
return new TextEncoder().encode(JSON.stringify(value));
40+
}
41+
42+
function decode<T>(data: Uint8Array | null): T | null {
43+
if (!data) return null;
44+
try {
45+
return JSON.parse(new TextDecoder().decode(data)) as T;
46+
} catch {
47+
return null;
48+
}
49+
}
50+
51+
/** Read cached docs from the backend, if they exist. */
52+
async function readCache(backend: BrowserFsBackend): Promise<DocsLoadResult | null> {
53+
try {
54+
const [pagesData, contentsData, metaData] = await Promise.all([
55+
backend.readFile(CACHE_PAGES_FILE),
56+
backend.readFile(CACHE_CONTENTS_FILE),
57+
backend.readFile(CACHE_META_FILE),
58+
]);
59+
if (!pagesData || !contentsData || !metaData) return null;
60+
const pages = decode<PageTreeNode[]>(pagesData);
61+
const pageContents = decode<Record<string, string>>(contentsData);
62+
if (!pages || !pageContents) return null;
63+
return { pages, pageContents, source: 'cache' };
64+
} catch {
65+
return null;
66+
}
67+
}
68+
69+
/** Write cloned docs to the cache. */
70+
async function writeCache(
71+
backend: BrowserFsBackend,
72+
pages: PageTreeNode[],
73+
pageContents: Record<string, string>,
74+
): Promise<void> {
75+
try {
76+
// Ensure cache directory exists
77+
const exists = await backend.exists(CACHE_DIR);
78+
if (!exists) {
79+
await backend.writeFile(`${CACHE_DIR}/.gitkeep`, new Uint8Array(0));
80+
}
81+
await Promise.all([
82+
backend.writeFile(CACHE_PAGES_FILE, encode(pages)),
83+
backend.writeFile(CACHE_CONTENTS_FILE, encode(pageContents)),
84+
backend.writeFile(CACHE_META_FILE, encode({
85+
clonedAt: new Date().toISOString(),
86+
repo: DOCS_REPO_URL,
87+
branch: DOCS_BRANCH,
88+
subPath: DOCS_SUB_PATH,
89+
})),
90+
]);
91+
} catch {
92+
// Cache write failures are non-fatal
93+
}
94+
}
95+
96+
/**
97+
* Resolve `{{base}}` placeholders in all page contents.
98+
* Remote-cloned docs won't have these, but bundled fallback content does.
99+
*/
100+
function resolveAllContent(contents: Record<string, string>): Record<string, string> {
101+
const resolved: Record<string, string> = {};
102+
for (const [id, content] of Object.entries(contents)) {
103+
resolved[id] = resolveDocsContent(content);
104+
}
105+
return resolved;
106+
}
107+
108+
/**
109+
* Load docs: try cache → try remote clone → fall back to bundled.
110+
*
111+
* @param backend - The BrowserFsBackend (needed for cache and clone)
112+
* @param forceRefresh - Skip cache and re-clone from remote
113+
* @param onStatus - Optional callback for status messages
114+
*/
115+
export async function loadDocs(
116+
backend: BrowserFsBackend | null,
117+
forceRefresh: boolean = false,
118+
onStatus?: (message: string) => void,
119+
): Promise<DocsLoadResult> {
120+
// If no BrowserFsBackend, go straight to bundled fallback
121+
if (!backend) {
122+
return {
123+
pages: DOCS_PAGES,
124+
pageContents: resolveAllContent(DOCS_CONTENT),
125+
source: 'bundled',
126+
};
127+
}
128+
129+
// 1. Try cache (unless force-refreshing)
130+
if (!forceRefresh) {
131+
const cached = await readCache(backend);
132+
if (cached) {
133+
return cached;
134+
}
135+
}
136+
137+
// 2. Try remote clone
138+
onStatus?.('Cloning documentation from GitHub...');
139+
try {
140+
const httpModule = await import('isomorphic-git/http/web');
141+
const gitHttp: GitHttp = httpModule.default as GitHttp;
142+
143+
const { pages, pageContents } = await cloneRemoteRepo(
144+
backend,
145+
gitHttp,
146+
DOCS_REPO_URL,
147+
DOCS_BRANCH,
148+
DOCS_SUB_PATH,
149+
CORS_PROXY,
150+
);
151+
152+
// Cache the result for next time
153+
await writeCache(backend, pages, pageContents);
154+
155+
onStatus?.('Documentation loaded from GitHub');
156+
return { pages, pageContents, source: 'remote' };
157+
} catch (err) {
158+
const message = err instanceof Error ? err.message : String(err);
159+
onStatus?.(`Could not load remote docs (${message}), using bundled content`);
160+
}
161+
162+
// 3. Fall back to bundled content
163+
return {
164+
pages: DOCS_PAGES,
165+
pageContents: resolveAllContent(DOCS_CONTENT),
166+
source: 'bundled',
167+
};
168+
}
169+
170+
/**
171+
* Build a GitHub source URL for a page loaded from the remote clone.
172+
*
173+
* For remote/cached pages the pageId IS the file path relative to docs/content/
174+
* (e.g. "getting-started/introduction.md").
175+
*
176+
* For bundled pages we fall back to the DOCS_SOURCE_PATHS lookup.
177+
*/
178+
export function getRemoteDocsSourceUrl(
179+
pageId: string,
180+
source: 'remote' | 'cache' | 'bundled',
181+
branch: string = DOCS_BRANCH,
182+
): string | undefined {
183+
if (source === 'bundled') {
184+
return getDocsSourceUrl(pageId) ?? undefined;
185+
}
186+
// Remote/cached: pageId is the relative file path within docs/content/
187+
return `https://github.com/nsheaps/cept/blob/${branch}/${DOCS_SUB_PATH}/${pageId}`;
188+
}

0 commit comments

Comments
 (0)