Skip to content

Commit 2847bda

Browse files
Merge pull request #11 from jamesmontemagno/copilot/fix-subpage-url-navigation
Fix direct URL navigation for SPA routes on GitHub Pages
2 parents 95b42ce + 1cb9467 commit 2847bda

6 files changed

Lines changed: 310 additions & 1 deletion

File tree

.vscode/memory/spa-routing-fix.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# SPA Routing Fix for GitHub Pages
2+
3+
## Problem
4+
Single Page Applications (SPAs) using client-side routing (like React Router's `BrowserRouter`) face a common issue when deployed to GitHub Pages: direct navigation to subpages results in 404 errors.
5+
6+
## Why This Happens
7+
- SPAs have only one `index.html` file at the root
8+
- All routing is handled client-side by JavaScript
9+
- When you visit `/mcp` directly, GitHub Pages looks for `/mcp/index.html`
10+
- Since this file doesn't exist, GitHub Pages returns a 404 error
11+
12+
## Solution
13+
We implement the standard GitHub Pages SPA workaround using a two-step redirect:
14+
15+
### Step 1: 404.html Redirect
16+
- Located in `public/404.html`
17+
- When GitHub Pages returns 404, it serves this file
18+
- The script captures the requested path (e.g., `/mcp`)
19+
- Converts it to a query parameter: `/?/mcp`
20+
- Redirects to the root with the path preserved
21+
22+
### Step 2: index.html Path Restoration
23+
- Located in `index.html` (added script in `<head>`)
24+
- When the root loads, checks for the special query parameter format
25+
- Restores the original URL using `history.replaceState()`
26+
- React Router then handles the routing normally
27+
28+
## How It Works (Example)
29+
1. User visits `https://mcpbadge.dev/mcp`
30+
2. GitHub Pages: "No file at /mcp/index.html" → Returns 404.html
31+
3. 404.html script: Redirect to `/?/mcp`
32+
4. Browser loads index.html with query parameter
33+
5. index.html script: Restore URL to `/mcp`
34+
6. React Router: Route to MCP component
35+
36+
## Files Modified
37+
- `public/404.html` - New file with redirect script
38+
- `index.html` - Added path restoration script
39+
- `src/App.tsx` - Fixed useLocation hook import (unrelated lint warning)
40+
41+
## Testing
42+
Direct navigation to any route should now work:
43+
- `/` - Home page
44+
- `/mcp` - MCP Badges page
45+
- `/extensions` - VS Code Extensions page
46+
- `/settings` - Settings page
47+
48+
## References
49+
This solution is based on the [spa-github-pages](https://github.com/rafgraph/spa-github-pages) approach, which is widely used by SPAs deployed to GitHub Pages.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# SPA Routing Fix - Visual Flow
2+
3+
## Before Fix (Problem)
4+
```
5+
User visits: https://mcpbadge.dev/mcp
6+
7+
GitHub Pages: "Looking for /mcp/index.html"
8+
9+
GitHub Pages: "File not found!"
10+
11+
Result: 404 Error Page ❌
12+
```
13+
14+
## After Fix (Solution)
15+
```
16+
User visits: https://mcpbadge.dev/mcp
17+
18+
GitHub Pages: "Looking for /mcp/index.html"
19+
20+
GitHub Pages: "File not found! Serving 404.html"
21+
22+
404.html script executes:
23+
- Captures path: "/mcp"
24+
- Encodes as query param: "?/mcp"
25+
- Redirects to: https://mcpbadge.dev/?/mcp
26+
27+
Browser loads: https://mcpbadge.dev/?/mcp
28+
29+
GitHub Pages: "Serving /index.html"
30+
31+
index.html script executes BEFORE React loads:
32+
- Detects query param: "?/mcp"
33+
- Decodes back to: "/mcp"
34+
- Uses history.replaceState to change URL
35+
- URL now shows: https://mcpbadge.dev/mcp
36+
37+
React loads and sees URL: /mcp
38+
39+
React Router routes to: MCP component
40+
41+
Result: MCP page displays correctly ✅
42+
```
43+
44+
## Key Technical Details
45+
46+
### 404.html Script
47+
```javascript
48+
// Converts: /mcp → /?/mcp
49+
var segmentCount = 0; // Set to 0 for root domain
50+
l.replace(
51+
l.protocol + '//' + l.hostname +
52+
'/?/' + l.pathname.slice(1) +
53+
l.hash
54+
);
55+
```
56+
57+
### index.html Script
58+
```javascript
59+
// Converts: /?/mcp → /mcp
60+
if (l.search[1] === '/' ) {
61+
var decoded = l.search.slice(1);
62+
window.history.replaceState(null, null,
63+
l.pathname.slice(0, -1) + decoded + l.hash
64+
);
65+
}
66+
```
67+
68+
## Why This Works
69+
70+
1. **GitHub Pages behavior**: Always serves 404.html when file not found
71+
2. **Query parameters**: Don't trigger file lookups, always route to root
72+
3. **history.replaceState**: Changes URL without triggering navigation
73+
4. **Timing**: Redirect happens before React/Router initialize
74+
75+
## Works With
76+
- All client-side routes: `/mcp`, `/extensions`, `/settings`
77+
- Hash fragments: `/mcp#section`
78+
- Query parameters: `/mcp?param=value`
79+
- Browser back/forward buttons
80+
- Bookmarks and direct links
81+
82+
## Doesn't Break
83+
- Home page (`/`)
84+
- Existing navigation
85+
- Browser history
86+
- SEO (search engines see correct URLs)

index.html

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,31 @@
99
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
1010
<meta name="theme-color" content="#24bfa5" />
1111

12+
<!-- Start Single Page Apps for GitHub Pages -->
13+
<script type="text/javascript">
14+
// Single Page Apps for GitHub Pages
15+
// MIT License
16+
// https://github.com/rafgraph/spa-github-pages
17+
// This script checks to see if a redirect is present in the query string,
18+
// converts it back into the correct url and adds it to the
19+
// browser's history using window.history.replaceState(...),
20+
// which won't cause the browser to attempt to load the new url.
21+
// When the single page app is loaded further down in this file,
22+
// the correct url will be waiting in the browser's history for
23+
// the single page app to route accordingly.
24+
(function(l) {
25+
if (l.search[1] === '/' ) {
26+
var decoded = l.search.slice(1).split('&').map(function(s) {
27+
return s.replace(/~and~/g, '&')
28+
}).join('?');
29+
window.history.replaceState(null, null,
30+
l.pathname.slice(0, -1) + decoded + l.hash
31+
);
32+
}
33+
}(window.location))
34+
</script>
35+
<!-- End Single Page Apps for GitHub Pages -->
36+
1237
<!-- Primary Meta Tags -->
1338
<title>README Badge Creator - Generate Install Badges for MCP Servers & VS Code Extensions</title>
1439
<meta name="title" content="README Badge Creator - Generate Install Badges for MCP Servers & VS Code Extensions" />

public/404.html

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>README Badge Creator</title>
7+
<script>
8+
// Single Page Apps for GitHub Pages
9+
// MIT License
10+
// https://github.com/rafgraph/spa-github-pages
11+
// This script takes the current url and converts the path and query
12+
// string into just a query string, and then redirects the browser
13+
// to the new url with only a query string and hash fragment,
14+
// e.g. https://www.foo.tld/one/two?a=b&c=d#qwe, becomes
15+
// https://www.foo.tld/?/one/two&a=b~and~c=d#qwe
16+
// Note: this 404.html file must be at least 512 bytes for it to work
17+
// with Internet Explorer (it is currently > 512 bytes)
18+
19+
// If you're creating an SPA that will use a custom domain, and there is no
20+
// path segment in the domain (e.g. https://example.tld/ instead of
21+
// https://example.tld/path-to-spa), then set segmentCount to 0.
22+
// If you're creating an SPA that will use a subdirectory path
23+
// (e.g. https://example.tld/path-to-spa), then set segmentCount to the
24+
// number of path segments in the subdirectory (e.g. 1 for /path-to-spa,
25+
// 2 for /path/to/spa, etc.)
26+
var segmentCount = 0;
27+
28+
var l = window.location;
29+
l.replace(
30+
l.protocol +
31+
'//' +
32+
l.hostname +
33+
(l.port ? ':' + l.port : '') +
34+
l.pathname
35+
.split('/')
36+
.slice(0, 1 + segmentCount)
37+
.join('/') +
38+
'/?/' +
39+
l.pathname
40+
.slice(1)
41+
.split('/')
42+
.slice(segmentCount)
43+
.join('/')
44+
.replace(/&/g, '~and~') +
45+
(l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
46+
l.hash
47+
);
48+
</script>
49+
</head>
50+
<body></body>
51+
</html>

src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect } from 'react'
2-
import { NavLink, Outlet, Route, BrowserRouter, Routes } from 'react-router-dom'
2+
import { NavLink, Outlet, Route, BrowserRouter, Routes, useLocation } from 'react-router-dom'
33
import './App.css'
44
import Home from './pages/Home'
55
import MCP from './pages/MCP'
@@ -15,6 +15,7 @@ const navItems = [
1515
]
1616

1717
function Layout() {
18+
const location = useLocation()
1819
const [theme, setTheme] = useState<ThemeType>(() => {
1920
const savedTheme = localStorage.getItem('readme-badge-theme') as ThemeType
2021
return savedTheme || 'system'

tests/direct-navigation.spec.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('Direct URL Navigation', () => {
4+
test('should navigate directly to /mcp page', async ({ page }) => {
5+
// Navigate directly to the MCP page URL
6+
await page.goto('/mcp');
7+
8+
// Wait for the page to load
9+
await page.waitForLoadState('networkidle');
10+
11+
// Verify the URL is correct
12+
expect(page.url()).toContain('/mcp');
13+
14+
// Verify the page content is loaded (check for MCP specific content)
15+
const heading = page.locator('h1, h2').first();
16+
await expect(heading).toBeVisible();
17+
18+
// Check that the MCP nav link is active
19+
const mcpNavLink = page.locator('a.nav-link.active', { hasText: 'MCP Badges' });
20+
await expect(mcpNavLink).toBeVisible();
21+
});
22+
23+
test('should navigate directly to /extensions page', async ({ page }) => {
24+
// Navigate directly to the Extensions page URL
25+
await page.goto('/extensions');
26+
27+
// Wait for the page to load
28+
await page.waitForLoadState('networkidle');
29+
30+
// Verify the URL is correct
31+
expect(page.url()).toContain('/extensions');
32+
33+
// Verify the page content is loaded
34+
const heading = page.locator('h1, h2').first();
35+
await expect(heading).toBeVisible();
36+
37+
// Check that the Extensions nav link is active
38+
const extensionsNavLink = page.locator('a.nav-link.active', { hasText: 'VS Code Extensions' });
39+
await expect(extensionsNavLink).toBeVisible();
40+
});
41+
42+
test('should navigate directly to /settings page', async ({ page }) => {
43+
// Navigate directly to the Settings page URL
44+
await page.goto('/settings');
45+
46+
// Wait for the page to load
47+
await page.waitForLoadState('networkidle');
48+
49+
// Verify the URL is correct
50+
expect(page.url()).toContain('/settings');
51+
52+
// Verify the page content is loaded
53+
const heading = page.locator('h1, h2').first();
54+
await expect(heading).toBeVisible();
55+
56+
// Check that the Settings nav link is active
57+
const settingsNavLink = page.locator('a.nav-link.settings-link.active');
58+
await expect(settingsNavLink).toBeVisible();
59+
});
60+
61+
test('should handle deep linking with query parameters', async ({ page }) => {
62+
// Navigate to a page with query parameters
63+
await page.goto('/mcp?test=value');
64+
65+
// Wait for the page to load
66+
await page.waitForLoadState('networkidle');
67+
68+
// Verify the URL preserves query parameters
69+
expect(page.url()).toContain('/mcp');
70+
expect(page.url()).toContain('test=value');
71+
72+
// Verify the page loaded correctly
73+
const heading = page.locator('h1, h2').first();
74+
await expect(heading).toBeVisible();
75+
});
76+
77+
test('should navigate from home to subpage and back', async ({ page }) => {
78+
// Start at home
79+
await page.goto('/');
80+
await page.waitForLoadState('networkidle');
81+
82+
// Navigate to MCP page
83+
await page.click('a.nav-link:has-text("MCP Badges")');
84+
await page.waitForLoadState('networkidle');
85+
86+
// Verify we're on the MCP page
87+
expect(page.url()).toContain('/mcp');
88+
89+
// Navigate back to home
90+
await page.click('a.nav-link:has-text("Home")');
91+
await page.waitForLoadState('networkidle');
92+
93+
// Verify we're back at home
94+
expect(page.url()).not.toContain('/mcp');
95+
expect(page.url()).not.toContain('/extensions');
96+
});
97+
});

0 commit comments

Comments
 (0)