Skip to content

Commit 752822e

Browse files
fix(markdown,ui): route guides by stable IDs
generate deterministic guide IDs from relative markdown paths encode/decode route IDs safely in UI links and page params switch guide route to /guide/:id/:slug and keep readable slugs add unit/e2e coverage for guide navigation and section edge branches Signed-off-by: night-slayer18 <samanuaia257@gmail.com>
1 parent 8ba05d4 commit 752822e

11 files changed

Lines changed: 263 additions & 13 deletions

File tree

e2e/guides.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { test, expect } from './coverage';
2+
3+
test('guide navigation uses stable short IDs', async ({ page }) => {
4+
await page.goto('/');
5+
6+
const openNav = page.getByRole('button', { name: /open navigation/i });
7+
if (await openNav.isVisible()) {
8+
await openNav.click();
9+
}
10+
11+
await page.getByRole('link', { name: 'README' }).first().click();
12+
13+
await expect(page).toHaveURL(/\/guide\/[0-9a-f]{8}\/readme$/i);
14+
});

packages/core/tests/markdown-plugin.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ Some content.`,
4545

4646
const guide = docs[0];
4747
expect(guide.kind).toBe('guide');
48+
expect(guide.id).toMatch(/^[0-9a-f]{8}$/);
49+
expect(guide.id).not.toContain(':');
50+
expect(guide.id).not.toContain('/');
4851
expect(guide.name).toBe('Getting Started');
52+
expect(guide.fileName).toBe('guide.md');
53+
expect(guide.source?.file).toBe('guide.md');
4954
expect(guide.documentation?.summary).toBe('Intro guide');
5055
const metadata = guide.metadata as { markdown?: string; html?: string };
5156
expect(metadata.markdown).toContain('Some content');

packages/plugins/markdown/src/index.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from 'fs/promises';
22
import path from 'path';
3+
import crypto from 'crypto';
34
import matter from 'gray-matter';
45
import { marked } from 'marked';
56
import { glob } from 'glob';
@@ -13,11 +14,21 @@ export interface MarkdownPluginOptions {
1314

1415
interface MarkdownFile {
1516
path: string;
17+
relativePath: string;
1618
frontMatter: Record<string, unknown>;
1719
markdown: string;
1820
html: string;
1921
}
2022

23+
function normalizePath(input: string): string {
24+
return input.replace(/\\/g, '/');
25+
}
26+
27+
function generateGuideId(relativePath: string): string {
28+
const content = `guide|${normalizePath(relativePath)}`;
29+
return crypto.createHash('md5').update(content).digest('hex').slice(0, 8);
30+
}
31+
2132
export default function markdownPlugin(options: MarkdownPluginOptions): Plugin {
2233
const files: MarkdownFile[] = [];
2334

@@ -37,6 +48,7 @@ export default function markdownPlugin(options: MarkdownPluginOptions): Plugin {
3748

3849
for (const file of foundFiles) {
3950
const content = await fs.readFile(file, 'utf-8');
51+
const relativePath = normalizePath(path.relative(options.sourceDir, file));
4052
let frontMatter: Record<string, unknown> = {};
4153
let markdown = content;
4254

@@ -51,6 +63,7 @@ export default function markdownPlugin(options: MarkdownPluginOptions): Plugin {
5163

5264
files.push({
5365
path: file,
66+
relativePath,
5467
frontMatter,
5568
markdown,
5669
html,
@@ -64,17 +77,19 @@ export default function markdownPlugin(options: MarkdownPluginOptions): Plugin {
6477
afterExtract(docs: DocEntry[]) {
6578
const guideDocs: DocEntry[] = files.map((file) => {
6679
const fileName = path.basename(file.path, path.extname(file.path));
80+
const modulePath = file.relativePath.replace(/\.[^/.]+$/, '');
6781
const title =
6882
typeof file.frontMatter.title === 'string' ? file.frontMatter.title : fileName;
6983
const description =
7084
typeof file.frontMatter.description === 'string' ? file.frontMatter.description : '';
7185

7286
return {
73-
id: `guide:${file.path}`,
87+
id: generateGuideId(file.relativePath),
7488
name: title,
7589
kind: 'guide',
76-
fileName: file.path,
77-
source: { file: file.path, line: 1, column: 0 },
90+
fileName: file.relativePath,
91+
module: modulePath,
92+
source: { file: file.relativePath, line: 1, column: 0 },
7893
position: { line: 1, column: 0 },
7994
signature: '',
8095
documentation: {

packages/ui/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export function App() {
9898
<Route element={<AppShell />}>
9999
<Route path="/" element={<HomePage />} />
100100
<Route path="/docs/*" element={<MarkdownPage />} />
101-
<Route path="/guide/:name" element={<GuidePage />} />
101+
<Route path="/guide/:id/:slug?" element={<GuidePage />} />
102102
<Route path="/section/:slug" element={<SectionPage />} />
103103
<Route path="/:kind/:id/:slug?" element={<TypePage />} />
104104
</Route>

packages/ui/src/lib/routes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ import type { DocEntry } from '../store';
33

44
export function docPath(entry: Pick<DocEntry, 'kind' | 'id' | 'name'>): string {
55
const slug = slugify(entry.name) || 'entry';
6-
return `/${entry.kind}/${entry.id}/${slug}`;
6+
const encodedId = encodeURIComponent(entry.id);
7+
return `/${entry.kind}/${encodedId}/${slug}`;
78
}

packages/ui/src/pages/GuidePage.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ interface GuideMetadata {
1111
}
1212

1313
export function GuidePage() {
14-
const { name } = useParams<{ name: string }>();
14+
const { id } = useParams<{ id: string; slug?: string }>();
1515
const docs = useStore((state) => state.docs);
16+
const decodedId = id ? decodeURIComponent(id) : id;
1617

1718
const entry = useMemo(
18-
() => docs.find((doc) => doc.kind === 'guide' && doc.name === name),
19-
[docs, name]
19+
() => docs.find((doc) => doc.kind === 'guide' && doc.id === decodedId),
20+
[docs, decodedId]
2021
);
2122

2223
if (!entry) {

packages/ui/src/pages/TypePage.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@ import { TypeView } from '../components/Documentation/TypeView';
55
export function TypePage() {
66
const { kind, id } = useParams<{ kind: string; id: string; slug?: string }>();
77
const docs = useStore((state) => state.docs);
8+
const decodedId = id ? decodeURIComponent(id) : id;
89

9-
const entry = kind && id ? docs.find((d) => d.id === id && d.kind === kind) : undefined;
10+
const entry =
11+
kind && decodedId ? docs.find((d) => d.id === decodedId && d.kind === kind) : undefined;
1012

1113
if (!entry) {
1214
return (
1315
<div className="text-center">
1416
<h1 className="text-2xl font-bold text-foreground">Not Found</h1>
1517
<p className="mt-2 text-muted-foreground">
16-
Could not find documentation for {kind ?? 'unknown'}/{id ?? 'unknown'}
18+
Could not find documentation for {kind ?? 'unknown'}/{decodedId ?? 'unknown'}
1719
</p>
1820
</div>
1921
);

packages/ui/tests/components/Layout/Sidebar.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ const docs: DocEntry[] = [
2020
position: { line: 1, column: 0 },
2121
signature: 'class Beta {}',
2222
},
23+
{
24+
id: 'a1b2c3d4',
25+
name: 'README',
26+
kind: 'guide',
27+
fileName: 'docs/README.md',
28+
module: 'docs/README',
29+
position: { line: 1, column: 0 },
30+
signature: 'markdown README',
31+
},
2332
];
2433

2534
describe('Sidebar', () => {
@@ -59,4 +68,14 @@ describe('Sidebar', () => {
5968
expect(queryByText(/functions/i)).not.toBeInTheDocument();
6069
expect(queryByText(/classes/i)).not.toBeInTheDocument();
6170
});
71+
72+
it('uses clean guide IDs in links', () => {
73+
const { getByRole } = renderWithStore(<Sidebar />, {
74+
initialState: { docs },
75+
route: '/',
76+
});
77+
78+
const link = getByRole('link', { name: 'README' });
79+
expect(link.getAttribute('href')).toBe('/guide/a1b2c3d4/readme');
80+
});
6281
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { docPath } from '@/lib/routes';
3+
4+
describe('docPath', () => {
5+
it('builds clean guide URLs with stable short IDs', () => {
6+
const path = docPath({
7+
kind: 'guide',
8+
id: 'a1b2c3d4',
9+
name: 'Intro',
10+
});
11+
12+
expect(path).toBe('/guide/a1b2c3d4/intro');
13+
});
14+
});

packages/ui/tests/pages/GuidePage.test.tsx

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ describe('GuidePage', () => {
1111
});
1212
});
1313
it('renders markdown guides from metadata', async () => {
14+
const guideId = 'a1b2c3d4';
1415
act(() => {
1516
useStore.setState({
1617
docs: [
1718
{
18-
id: 'guide:1',
19+
id: guideId,
1920
name: 'Getting Started',
2021
kind: 'guide',
2122
fileName: 'guide.md',
@@ -28,13 +29,60 @@ describe('GuidePage', () => {
2829
});
2930

3031
render(
31-
<MemoryRouter initialEntries={['/guide/Getting%20Started']}>
32+
<MemoryRouter initialEntries={[`/guide/${guideId}/getting-started`]}>
3233
<Routes>
33-
<Route path="/guide/:name" element={<GuidePage />} />
34+
<Route path="/guide/:id/:slug?" element={<GuidePage />} />
3435
</Routes>
3536
</MemoryRouter>
3637
);
3738

3839
expect(await screen.findByText('Hello guide')).toBeInTheDocument();
3940
});
41+
42+
it('renders HTML guides from metadata when html is present', async () => {
43+
const guideId = 'd4c3b2a1';
44+
act(() => {
45+
useStore.setState({
46+
docs: [
47+
{
48+
id: guideId,
49+
name: 'HTML Guide',
50+
kind: 'guide',
51+
fileName: 'html-guide.md',
52+
position: { line: 1, column: 0 },
53+
signature: '',
54+
metadata: { html: '<h2>Rendered HTML guide</h2><p>Body</p>' },
55+
},
56+
],
57+
});
58+
});
59+
60+
render(
61+
<MemoryRouter initialEntries={[`/guide/${guideId}/html-guide`]}>
62+
<Routes>
63+
<Route path="/guide/:id/:slug?" element={<GuidePage />} />
64+
</Routes>
65+
</MemoryRouter>
66+
);
67+
68+
expect(await screen.findByText('Rendered HTML guide')).toBeInTheDocument();
69+
expect(screen.getByText('Body')).toBeInTheDocument();
70+
});
71+
72+
it('renders a not found state for unknown guides', () => {
73+
act(() => {
74+
useStore.setState({ docs: [] });
75+
});
76+
77+
render(
78+
<MemoryRouter initialEntries={['/guide/ffffffff/missing']}>
79+
<Routes>
80+
<Route path="/guide/:id/:slug?" element={<GuidePage />} />
81+
</Routes>
82+
</MemoryRouter>
83+
);
84+
85+
expect(screen.getByText('Guide not found')).toBeInTheDocument();
86+
expect(screen.getByText(/does not exist/i)).toBeInTheDocument();
87+
});
4088
});

0 commit comments

Comments
 (0)