Skip to content

Commit f1adf64

Browse files
authored
Set docs base path to developers (#588)
* Set docs base path to developers * Set Vocs base path to developers * Redirect docs root to developers base * Remove docs root redirect * Use Vite base without Vocs base path * Serve docs preview under developers path * Fix developers docs base path * Proxy developers paths through docs * Patch developers routes in Vercel output * Remove developers route remapping * Remove developers runtime base * Normalize proxied RSC fetch paths * Bootstrap proxied RSC fetch normalization * Allow RSC fetch bootstrap script * Format proxied RSC wrapper * Route proxied docs root RSC to Waku root * Normalize proxied developers routes in Waku client * Keep proxied docs links under developers
1 parent 6c1798b commit f1adf64

3 files changed

Lines changed: 135 additions & 0 deletions

File tree

src/pages/_layout.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
import type { PropsWithChildren } from 'react'
22

3+
const normalizeProxiedRscFetch = `
4+
(() => {
5+
if (window.__tempoNormalizeProxiedRscFetch) return;
6+
window.__tempoNormalizeProxiedRscFetch = true;
7+
const originalFetch = window.fetch.bind(window);
8+
window.fetch = (input, init) => {
9+
const url = typeof input === 'string'
10+
? input
11+
: input instanceof URL
12+
? input.toString()
13+
: input.url;
14+
const rewritten = url
15+
.replace(/\\/RSC\\/R\\/developers\\.txt(?=($|\\?))/, '/RSC/R/_root.txt')
16+
.replace(/\\/RSC\\/R\\/developers\\//, '/RSC/R/');
17+
18+
if (rewritten === url) return originalFetch(input, init);
19+
if (typeof input === 'string' || input instanceof URL) return originalFetch(rewritten, init);
20+
21+
return originalFetch(new Request(rewritten, input), init);
22+
};
23+
})();
24+
`
25+
326
export default function Layout(
427
props: PropsWithChildren<{
528
path: string
@@ -15,6 +38,8 @@ export default function Layout(
1538
type="font/woff2"
1639
crossOrigin="anonymous"
1740
/>
41+
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: static bootstrap must run before the RSC client bundle. */}
42+
<script dangerouslySetInnerHTML={{ __html: normalizeProxiedRscFetch }} />
1843
{props.children}
1944
</>
2045
)

src/pages/docs/_layout.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@ const GoogleAnalytics = lazy(() => import('../../components/GoogleAnalytics'))
1717
const PostHogSetup = lazy(() => import('../../components/PostHogSetup'))
1818

1919
if (typeof window !== 'undefined') {
20+
const originalFetch = window.fetch.bind(window)
21+
window.fetch = (input, init) => {
22+
const url =
23+
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
24+
const rewritten = url
25+
.replace(/\/RSC\/R\/developers\.txt(?=($|\?))/, '/RSC/R/_root.txt')
26+
.replace(/\/RSC\/R\/developers\//, '/RSC/R/')
27+
28+
if (rewritten === url) return originalFetch(input, init)
29+
if (typeof input === 'string' || input instanceof URL) return originalFetch(rewritten, init)
30+
31+
return originalFetch(new Request(rewritten, input), init)
32+
}
33+
2034
window.addEventListener('vite:preloadError', (event) => {
2135
const key = `vite:preloadError:${(event as unknown as CustomEvent).detail?.message}`
2236
if (!sessionStorage.getItem(key)) {

vite.config.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export default defineConfig(({ mode }) => {
3131
blogPostsPlugin(),
3232
marketingSearchIndexPlugin({ source: 'vocs' }),
3333
marketingPages(),
34+
developersProxyRouteNormalization(),
3435
vocs(),
3536
Icons({ compiler: 'jsx', jsx: 'react' }),
3637
react(),
@@ -64,6 +65,101 @@ export default defineConfig(({ mode }) => {
6465

6566
const marketingRoutes = ['/', '/build', '/blog', '/performance']
6667

68+
function developersProxyRouteNormalization(): Plugin {
69+
return {
70+
name: 'tempo-developers-proxy-route-normalization',
71+
enforce: 'post',
72+
transform(code, id) {
73+
if (id !== '\0virtual:vite-rsc-waku/client-entry') return
74+
75+
return code
76+
.replace(
77+
"import { Router } from 'waku/router/client';",
78+
"import { Router, unstable_parseRoute } from 'waku/router/client';",
79+
)
80+
.replace(
81+
'const rootElement = createElement(StrictMode, null, createElement(Router));',
82+
`const developersPrefix = '/developers';
83+
const shouldUseDevelopersPrefix = () => window.location.pathname === developersPrefix || window.location.pathname.startsWith(developersPrefix + '/');
84+
const publicDevelopersPath = (path) => {
85+
if (path === '/') return developersPrefix;
86+
if (path.startsWith(developersPrefix + '/')) return path;
87+
return developersPrefix + path;
88+
};
89+
const normalizeDevelopersRoute = (route) => {
90+
if (route.path === '/developers') return { ...route, path: '/' };
91+
if (route.path.startsWith('/developers/')) {
92+
return { ...route, path: route.path.slice('/developers'.length) || '/' };
93+
}
94+
return route;
95+
};
96+
const getUnprefixedInternalLink = (event) => {
97+
if (!shouldUseDevelopersPrefix()) return;
98+
const link = event.target instanceof Element ? event.target.closest('a[href^="/"]') : null;
99+
if (!(link instanceof HTMLAnchorElement)) return;
100+
const href = link.getAttribute('href');
101+
if (!href || href.startsWith('//') || href.startsWith(developersPrefix + '/')) return;
102+
if (href.startsWith('/assets/') || href.startsWith('/fonts/') || href.startsWith('/RSC/')) return;
103+
return { link, href };
104+
};
105+
const originalFetch = window.fetch.bind(window);
106+
window.fetch = async (input, init) => {
107+
const response = await originalFetch(input, init);
108+
if (!shouldUseDevelopersPrefix()) return response;
109+
const requestUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
110+
if (!requestUrl.includes('/RSC/')) return response;
111+
const clone = response.clone();
112+
const contentType = clone.headers.get('content-type') || '';
113+
if (!contentType.includes('text/plain')) return response;
114+
const text = await clone.text();
115+
const normalized = text
116+
.replaceAll('route:/developers/', 'route:/')
117+
.replaceAll('route:/developers"', 'route:/"')
118+
.replaceAll('route:/developers,', 'route:/,')
119+
.replaceAll('route:/developers\\n', 'route:/\\n');
120+
if (normalized === text) return response;
121+
return new Response(normalized, {
122+
status: response.status,
123+
statusText: response.statusText,
124+
headers: response.headers,
125+
});
126+
};
127+
document.addEventListener(
128+
'click',
129+
(event) => {
130+
const target = getUnprefixedInternalLink(event);
131+
if (!target) return;
132+
event.preventDefault();
133+
event.stopImmediatePropagation();
134+
window.location.assign(publicDevelopersPath(target.href));
135+
},
136+
true,
137+
);
138+
for (const eventName of ['pointerover', 'mouseover']) {
139+
document.addEventListener(
140+
eventName,
141+
(event) => {
142+
if (!getUnprefixedInternalLink(event)) return;
143+
event.stopImmediatePropagation();
144+
},
145+
true,
146+
);
147+
}
148+
149+
const initialRoute = normalizeDevelopersRoute(unstable_parseRoute(new URL(window.location.href)));
150+
const rootElement = createElement(
151+
StrictMode,
152+
null,
153+
createElement(Router, {
154+
initialRoute,
155+
unstable_routeInterceptor: normalizeDevelopersRoute,
156+
}),
157+
);`,
158+
)
159+
},
160+
}
161+
}
162+
67163
function isMarketingPath(pathname: string) {
68164
const normalized = pathname.replace(/\/$/, '') || '/'
69165
// Let requests for actual files (e.g. /blog/foo.svg) fall through to Vite's

0 commit comments

Comments
 (0)