Skip to content

Commit 2a282b0

Browse files
KyleAMathewsclaude
andcommitted
refactor: create tested utility for markdown path normalization
- Extracted path normalization logic to separate utility function - Added comprehensive test suite with 33 test cases covering real examples from DB and Router docs - Handles all patterns: ./, ../, bare paths, external links, hash fragments - All tests pass, ensuring no regressions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 7981a52 commit 2a282b0

File tree

3 files changed

+147
-15
lines changed

3 files changed

+147
-15
lines changed

src/components/MarkdownLink.tsx

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Link } from '@tanstack/react-router'
22
import type { HTMLProps } from 'react'
3+
import { normalizeMarkdownPath } from '~/utils/normalize-markdown-path'
34

45
export function MarkdownLink({
56
href: hrefProp,
@@ -16,21 +17,8 @@ export function MarkdownLink({
1617

1718
const [hrefWithoutHash, hash] = hrefProp?.split('#') ?? []
1819
let [to] = hrefWithoutHash?.split('.md') ?? []
19-
20-
// Normalize relative paths that work in GitHub to work with our routing structure
21-
// In GitHub: ./guides/foo.md works from docs/overview.md
22-
// In our router: we need ../guides/foo because the current path is /lib/version/docs/overview (not overview/)
23-
if (to?.startsWith('./')) {
24-
// Convert ./path to ../path to account for the fact that we're at /docs/file not /docs/file/
25-
to = '../' + to.slice(2)
26-
} else if (to && !to.startsWith('/') && !to.startsWith('../')) {
27-
// Handle bare relative paths like "foo" or "guides/foo"
28-
// These should be treated as siblings, so prepend ../
29-
// This handles:
30-
// - "foo.md" (same directory) -> "../foo"
31-
// - "guides/foo.md" (subdirectory) -> "../guides/foo"
32-
to = '../' + to
33-
}
20+
21+
to = normalizeMarkdownPath(to)
3422

3523
return (
3624
<Link
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Test suite for markdown link path normalization
2+
// This can be run with: npx tsx src/components/test-normalize-path.ts
3+
4+
import { normalizeMarkdownPath } from '../utils/normalize-markdown-path'
5+
6+
// Test cases
7+
const testCases: Array<[string, string, string]> = [
8+
// [input, expected, description]
9+
10+
// Paths that should be modified
11+
['./foo', '../foo', 'Same directory with ./'],
12+
['./guides/foo', '../guides/foo', 'Subdirectory with ./'],
13+
['./overview.md#api-reference', '../overview.md#api-reference', 'Same directory with ./ and hash'],
14+
['./installation.md', '../installation.md', 'Real example from quick-start.md'],
15+
['./overview.md', '../overview.md', 'Real example from quick-start.md'],
16+
['./live-queries.md', '../live-queries.md', 'Real example from quick-start.md'],
17+
['foo', '../foo', 'Bare filename (same directory)'],
18+
['guides/foo', '../guides/foo', 'Bare subdirectory path'],
19+
['guides/subfolder/foo', '../guides/subfolder/foo', 'Nested bare path'],
20+
['live-queries.md', '../live-queries.md', 'Real bare filename from overview.md'],
21+
22+
// Paths that should NOT be modified (from real DB docs)
23+
['../foo', '../foo', 'Already has ../'],
24+
['../classes/foo', '../classes/foo', 'Already has ../ with subdirectory'],
25+
['../classes/aggregatefunctionnotinselecterror.md', '../classes/aggregatefunctionnotinselecterror.md', 'Real example from reference docs'],
26+
['../interfaces/btreeindexoptions.md', '../interfaces/btreeindexoptions.md', 'Real example from reference docs'],
27+
['../type-aliases/changelistener.md', '../type-aliases/changelistener.md', 'Real example from reference docs'],
28+
['../../foo', '../../foo', 'Multiple ../'],
29+
['/absolute/path', '/absolute/path', 'Absolute path'],
30+
['http://example.com', 'http://example.com', 'HTTP URL'],
31+
['https://example.com', 'https://example.com', 'HTTPS URL'],
32+
['https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L228', 'https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L228', 'GitHub source link'],
33+
34+
// Real examples from TanStack Router docs
35+
['../learn-the-basics.md', '../learn-the-basics.md', 'Router: Parent directory link'],
36+
['../hosting.md', '../hosting.md', 'Router: Parent directory hosting guide'],
37+
['../server-functions.md', '../server-functions.md', 'Router: Parent directory server functions'],
38+
['../server-routes.md', '../server-routes.md', 'Router: Parent directory server routes'],
39+
['../middleware.md', '../middleware.md', 'Router: Parent directory middleware'],
40+
]
41+
42+
// Run tests
43+
console.log('Running path normalization tests:\n')
44+
let passed = 0
45+
let failed = 0
46+
47+
for (const [input, expected, description] of testCases) {
48+
const result = normalizeMarkdownPath(input)
49+
const isPass = result === expected
50+
51+
if (isPass) {
52+
console.log(`✅ PASS: ${description}`)
53+
console.log(` Input: "${input}" → Output: "${result}"`)
54+
passed++
55+
} else {
56+
console.log(`❌ FAIL: ${description}`)
57+
console.log(` Input: "${input}"`)
58+
console.log(` Expected: "${expected}"`)
59+
console.log(` Got: "${result}"`)
60+
failed++
61+
}
62+
console.log('')
63+
}
64+
65+
console.log(`\nResults: ${passed} passed, ${failed} failed`)
66+
67+
// Edge cases to consider
68+
console.log('\n--- Edge Cases to Consider ---')
69+
console.log('1. Hash fragments: "foo#section" should become "../foo#section"')
70+
console.log('2. Query params: "foo?param=value" should become "../foo?param=value"')
71+
console.log('3. Special protocols: "mailto:", "javascript:", etc. should not be modified')
72+
console.log('4. Empty string or undefined should return as-is')
73+
74+
// Test edge cases
75+
console.log('\n--- Testing Edge Cases ---\n')
76+
77+
const edgeCases: Array<[string | undefined, string | undefined, string]> = [
78+
['foo#section', '../foo#section', 'Hash fragment'],
79+
['./foo#section', '../foo#section', 'With ./ and hash'],
80+
['../foo#section', '../foo#section', 'Already ../ with hash'],
81+
[undefined, undefined, 'Undefined input'],
82+
['', '', 'Empty string'],
83+
['#section', '#section', 'Hash only (should not be modified)'],
84+
['mailto:[email protected]', 'mailto:[email protected]', 'Mailto protocol'],
85+
['javascript:void(0)', 'javascript:void(0)', 'Javascript protocol'],
86+
]
87+
88+
for (const [input, expected, description] of edgeCases) {
89+
const result = normalizeMarkdownPath(input)
90+
const isPass = result === expected
91+
92+
if (isPass) {
93+
console.log(`✅ PASS: ${description}`)
94+
console.log(` Input: "${input}" → Output: "${result}"`)
95+
} else {
96+
console.log(`❌ FAIL: ${description}`)
97+
console.log(` Input: "${input}"`)
98+
console.log(` Expected: "${expected}"`)
99+
console.log(` Got: "${result}"`)
100+
}
101+
console.log('')
102+
}
103+
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Normalizes markdown link paths for TanStack website routing
3+
*
4+
* GitHub markdown links work differently from our router structure:
5+
* - In GitHub: ./guides/foo.md works from docs/overview.md
6+
* - In our router: we need ../guides/foo because the current path is /lib/version/docs/overview (not overview/)
7+
*
8+
* @param path The original markdown link path
9+
* @returns The normalized path for website routing
10+
*/
11+
export function normalizeMarkdownPath(path: string | undefined): string | undefined {
12+
if (!path) return path
13+
14+
// Don't modify:
15+
// - Absolute paths (/)
16+
// - Paths already using ../
17+
// - External links (http/https)
18+
// - Hash-only links (#)
19+
// - Special protocols (mailto:, javascript:, etc)
20+
if (
21+
path.startsWith('/') ||
22+
path.startsWith('../') ||
23+
path.startsWith('http') ||
24+
path.startsWith('#') ||
25+
path.includes(':') // Catches mailto:, javascript:, etc
26+
) {
27+
return path
28+
}
29+
30+
// Convert ./path to ../path (GitHub style to website style)
31+
if (path.startsWith('./')) {
32+
return '../' + path.slice(2)
33+
}
34+
35+
// Handle bare paths (no ./ prefix)
36+
// These are treated as siblings, so prepend ../
37+
// This covers:
38+
// - "foo" (same directory file)
39+
// - "guides/foo" (subdirectory)
40+
return '../' + path
41+
}

0 commit comments

Comments
 (0)