Skip to content

Commit e2f039c

Browse files
authored
docs: fix SSR and add GitHub Pages SPA routing support (#17)
The prerender step was failing silently because three Effect.run calls at module/component level accessed document/window APIs without SSR guards. This prevented any pre-rendered HTML files from being generated, causing 404s on page refresh for non-root routes on GitHub Pages. - Wrap theme Effect.run with isBrowser check (Layout.res) - Wrap Cmd+K shortcut Effect.run with isBrowser check (Layout.res) - Wrap Header scroll listener Effect.run with isBrowser check (Layout.res) - Make prerender script resilient to per-route errors with client-only fallback HTML so all routes have an index.html file
1 parent 962c09f commit e2f039c

4 files changed

Lines changed: 105 additions & 45 deletions

File tree

docs-website/index.html

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@
1212
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=Geist+Mono:wght@400;500;600&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet">
1313
</head>
1414
<body>
15+
<!-- SPA redirect decoder for GitHub Pages (counterpart to 404.html) -->
16+
<script>
17+
(function() {
18+
var redirect = window.location.search.match(/^\?\/(.*)/);
19+
if (redirect) {
20+
var decoded = redirect[1].split('&').map(function(s) {
21+
return s.replace(/~and~/g, '&');
22+
}).join('?');
23+
var basePath = '/rescript-signals';
24+
window.history.replaceState(null, '',
25+
basePath + '/' + decoded + window.location.hash
26+
);
27+
}
28+
})();
29+
</script>
1530
<a href="#main-content" class="skip-to-content">Skip to content</a>
1631
<div id="app"><!--ssr-outlet--></div>
1732
<script type="module" src="/src/Main.res.mjs"></script>

docs-website/public/404.html

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8">
5+
<title>Redirecting...</title>
6+
<script>
7+
// SPA redirect for GitHub Pages
8+
// See: https://github.com/rafgraph/spa-github-pages
9+
var pathSegmentsToKeep = 1; // Keep '/rescript-signals' prefix (1 segment)
10+
var l = window.location;
11+
l.replace(
12+
l.protocol + '//' + l.hostname + (l.port ? ':' + l.port : '') +
13+
l.pathname.split('/').slice(0, 1 + pathSegmentsToKeep).join('/') + '/?/' +
14+
l.pathname.slice(1).split('/').slice(pathSegmentsToKeep).join('/').replace(/&/g, '~and~') +
15+
(l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
16+
l.hash
17+
);
18+
</script>
19+
</head>
20+
<body>
21+
</body>
22+
</html>

docs-website/scripts/prerender.mjs

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,29 +45,46 @@ async function prerender() {
4545

4646
console.log(`Pre-rendering ${routes.length} routes...\n`)
4747

48+
let rendered = 0
49+
let failed = 0
50+
4851
for (const route of routes) {
49-
// Render the app HTML for this route
50-
const appHtml = render(route)
52+
try {
53+
// Render the app HTML for this route
54+
const appHtml = render(route)
5155

52-
// Inject into the template
53-
const html = template.replace('<!--ssr-outlet-->', appHtml)
56+
// Inject into the template
57+
const html = template.replace('<!--ssr-outlet-->', appHtml)
5458

55-
// Write to the correct directory structure
56-
// e.g., "/" → build/client/index.html (already exists, overwrite)
57-
// "/getting-started" → build/client/getting-started/index.html
58-
const filePath = route === '/'
59-
? path.join(buildDir, 'index.html')
60-
: path.join(buildDir, route, 'index.html')
59+
// Write to the correct directory structure
60+
// e.g., "/" → build/client/index.html (already exists, overwrite)
61+
// "/getting-started" → build/client/getting-started/index.html
62+
const filePath = route === '/'
63+
? path.join(buildDir, 'index.html')
64+
: path.join(buildDir, route, 'index.html')
6165

62-
// Ensure directory exists
63-
const dir = path.dirname(filePath)
64-
fs.mkdirSync(dir, { recursive: true })
66+
// Ensure directory exists
67+
const dir = path.dirname(filePath)
68+
fs.mkdirSync(dir, { recursive: true })
6569

66-
fs.writeFileSync(filePath, html)
67-
console.log(` ${route}${path.relative(buildDir, filePath)}`)
70+
fs.writeFileSync(filePath, html)
71+
console.log(` ✓ ${route}${path.relative(buildDir, filePath)}`)
72+
rendered++
73+
} catch (err) {
74+
// Write the template without SSR content as a fallback so the route
75+
// still works via client-side rendering on page refresh.
76+
const filePath = route === '/'
77+
? path.join(buildDir, 'index.html')
78+
: path.join(buildDir, route, 'index.html')
79+
const dir = path.dirname(filePath)
80+
fs.mkdirSync(dir, { recursive: true })
81+
fs.writeFileSync(filePath, template)
82+
console.warn(` ~ ${route}${path.relative(buildDir, filePath)} (client-only fallback: ${err.message})`)
83+
failed++
84+
}
6885
}
6986

70-
console.log('\nPre-rendering complete!')
87+
console.log(`\nPre-rendering complete! (${rendered} rendered, ${failed} skipped)`)
7188
}
7289

7390
prerender()

docs-website/src/Layout.res

Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,15 @@ let toggleTheme = () => {
3838
)
3939
}
4040

41-
let _ = Effect.run(() => {
42-
let t = Signal.get(theme)
43-
setHtmlAttribute("data-theme", t)
44-
setItem("rescript-signals-theme", t)
45-
Basefn.Theme.applyTheme(t == "dark" ? Basefn.Theme.Dark : Basefn.Theme.Light)
46-
None
47-
})->ignore
41+
let _ = if isBrowser {
42+
Effect.run(() => {
43+
let t = Signal.get(theme)
44+
setHtmlAttribute("data-theme", t)
45+
setItem("rescript-signals-theme", t)
46+
Basefn.Theme.applyTheme(t == "dark" ? Basefn.Theme.Dark : Basefn.Theme.Light)
47+
None
48+
})->ignore
49+
}
4850

4951
// ---- Search state ----
5052
let searchOpen = Signal.make(false)
@@ -244,14 +246,16 @@ module Header = {
244246

245247
let make = (_props: props) => {
246248
// Scroll listener
247-
let _ = Effect.run(() => {
248-
let handleScroll = () => {
249-
let scrollY: float = %raw(`window.scrollY`)
250-
Signal.set(isScrolled, scrollY > 10.0)
251-
}
252-
addEventListener("scroll", handleScroll)
253-
Some(() => removeEventListener("scroll", handleScroll))
254-
})->ignore
249+
let _ = if isBrowser {
250+
Effect.run(() => {
251+
let handleScroll = () => {
252+
let scrollY: float = %raw(`window.scrollY`)
253+
Signal.set(isScrolled, scrollY > 10.0)
254+
}
255+
addEventListener("scroll", handleScroll)
256+
Some(() => removeEventListener("scroll", handleScroll))
257+
})->ignore
258+
}
255259

256260
Component.element(
257261
"header",
@@ -456,22 +460,24 @@ module Footer = {
456460
}
457461

458462
// ---- Global Cmd+K shortcut ----
459-
let _ = Effect.run(() => {
460-
let handler = (_evt: Dom.event) => {
461-
let ctrlOrMeta: bool = %raw(`_evt.ctrlKey || _evt.metaKey`)
462-
let key: string = %raw(`_evt.key`)
463-
if ctrlOrMeta && key == "k" {
464-
let _ = %raw(`_evt.preventDefault()`)
465-
if Signal.peek(searchOpen) {
466-
closeSearch()
467-
} else {
468-
openSearch()
463+
let _ = if isBrowser {
464+
Effect.run(() => {
465+
let handler = (_evt: Dom.event) => {
466+
let ctrlOrMeta: bool = %raw(`_evt.ctrlKey || _evt.metaKey`)
467+
let key: string = %raw(`_evt.key`)
468+
if ctrlOrMeta && key == "k" {
469+
let _ = %raw(`_evt.preventDefault()`)
470+
if Signal.peek(searchOpen) {
471+
closeSearch()
472+
} else {
473+
openSearch()
474+
}
469475
}
470476
}
471-
}
472-
addEventListener("keydown", handler)
473-
Some(() => removeEventListener("keydown", handler))
474-
})->ignore
477+
addEventListener("keydown", handler)
478+
Some(() => removeEventListener("keydown", handler))
479+
})->ignore
480+
}
475481

476482
// ---- Main layout wrapper ----
477483
type props = {children: Component.node}

0 commit comments

Comments
 (0)