Skip to content

Commit 9c26d2c

Browse files
authored
fix: recover legacy docs routes and sitemap posts (#719)
1 parent f3b2ae2 commit 9c26d2c

7 files changed

Lines changed: 648 additions & 16 deletions

File tree

‎src/lib/docs-routing.test.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ const vercelConfig = JSON.parse(
2828
const redirects = vercelConfig.redirects.filter((redirect) => !redirect.has)
2929
const hostRedirects = vercelConfig.redirects.filter((redirect) => redirect.has)
3030
const vocsRedirects = vocsConfig.redirects as VocsRedirect[]
31+
const legacySectionPathSource =
32+
'/:section(api|guide|quickstart|protocol|sdk|cli|wallet|tools|ecosystem|developer-tools)/:path*'
3133

3234
function findRedirect(source: string) {
3335
return redirects.find((redirect) => redirect.source === source)
@@ -65,12 +67,20 @@ function findHostRedirectIndex(source: string, host: string) {
6567
)
6668
}
6769

70+
function developersProxyDestination(destination: string) {
71+
if (URL.canParse(destination)) return destination
72+
return `/developers${destination}`
73+
}
74+
6875
describe('docs routing redirects', () => {
6976
it('keeps proxied route destinations inside the public mount in production', () => {
7077
expect(docsRouteDestination('/docs/api', 'production')).toBe(
7178
`${canonicalDevelopersOrigin}/docs/api`,
7279
)
7380
expect(docsRouteDestination('/docs/api', 'preview')).toBe('/docs/api')
81+
expect(docsRouteDestination('https://tempo.xyz/learn/stablecoin-payroll/', 'production')).toBe(
82+
'https://tempo.xyz/learn/stablecoin-payroll/',
83+
)
7484
})
7585

7686
it('normalizes trailing slashes before static route handling', () => {
@@ -83,7 +93,6 @@ describe('docs routing redirects', () => {
8393
destination,
8494
}) => {
8595
const proxySource = `/developers${source}`
86-
const proxyDestination = `/developers${destination}`
8796
const matchingProxyRedirects = redirects.filter((redirect) => redirect.source === proxySource)
8897

8998
expect(
@@ -96,7 +105,7 @@ describe('docs routing redirects', () => {
96105
expect(matchingProxyRedirects).toEqual([
97106
expect.objectContaining({
98107
source: proxySource,
99-
destination: proxyDestination,
108+
destination: developersProxyDestination(destination),
100109
permanent: true,
101110
}),
102111
])
@@ -107,8 +116,8 @@ describe('docs routing redirects', () => {
107116
const redirect = findRedirect(`/developers${source}`)
108117

109118
expect(redirect?.source).not.toMatch(/\/$/)
110-
expect(redirect?.destination).toBe(`/developers${destination}`)
111-
expect(redirect?.destination).not.toMatch(/\/$/)
119+
expect(redirect?.destination).toBe(developersProxyDestination(destination))
120+
if (!URL.canParse(destination)) expect(redirect?.destination).not.toMatch(/\/$/)
112121
}
113122
})
114123
})
@@ -127,7 +136,7 @@ describe('docs routing redirects', () => {
127136
})
128137
})
129138

130-
it.each(legacyDocsHostRoutes)('redirects $source to $destination before the host catch-all', ({
139+
it.each(legacyDocsHostRoutes)('redirects $source to $destination before broad host rules', ({
131140
source,
132141
destination,
133142
}) => {
@@ -137,6 +146,9 @@ describe('docs routing redirects', () => {
137146
permanent: true,
138147
})
139148

149+
expect(findHostRedirectIndex(source, host)).toBeLessThan(
150+
findHostRedirectIndex(legacySectionPathSource, host),
151+
)
140152
expect(findHostRedirectIndex(source, host)).toBeLessThan(
141153
findHostRedirectIndex('/:path*', host),
142154
)

‎src/lib/docs-routing.ts‎

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export type DocsRouteContract = {
66
export const canonicalDevelopersOrigin = 'https://tempo.xyz/developers'
77

88
export function docsRouteDestination(destination: string, environment = process.env.VERCEL_ENV) {
9+
if (URL.canParse(destination)) return destination
910
if (environment === 'production') return `${canonicalDevelopersOrigin}${destination}`
1011
return destination
1112
}
@@ -20,6 +21,81 @@ export const proxiedLegacyDocsRoutes = [
2021
{ source: '/docs/developer-tools/indexer', destination: '/docs/api/indexer-api' },
2122
{ source: '/docs/hosted-services', destination: '/docs/api' },
2223
{ source: '/docs/hosted-services/:path*', destination: '/docs/api' },
24+
{
25+
source: '/docs/guide/use-accounts/add-funds',
26+
destination: '/docs/guide/getting-funds',
27+
},
28+
{
29+
source: '/docs/guide/use-accounts/authorize-access-keys',
30+
destination: '/docs/guide/tempo-transaction#access-keys',
31+
},
32+
{
33+
source: '/docs/guide/use-accounts/batch-transactions',
34+
destination: '/docs/guide/tempo-transaction#batch-calls',
35+
},
36+
{
37+
source: '/docs/guide/use-accounts/fee-sponsorship',
38+
destination: '/docs/guide/payments/sponsor-user-fees',
39+
},
40+
{
41+
source: '/docs/guide/use-accounts/scheduled-transactions',
42+
destination: '/docs/guide/tempo-transaction#scheduled-transactions',
43+
},
44+
{
45+
source: '/docs/guide/use-accounts/webauthn-p256-signatures',
46+
destination: '/docs/protocol/transactions/spec-tempo-transaction#signature-types',
47+
},
48+
{ source: '/docs/sdk/typescript/server', destination: '/docs/server' },
49+
{ source: '/docs/sdk/typescript/server/handlers', destination: '/docs/server' },
50+
{
51+
source: '/docs/sdk/typescript/server/handler.feePayer',
52+
destination: '/docs/server/relay-handler#feepayer',
53+
},
54+
{ source: '/accounts/server', destination: '/docs/server' },
55+
{
56+
source: '/accounts/server/handler.feePayer',
57+
destination: '/docs/server/relay-handler#feepayer',
58+
},
59+
{
60+
source: '/accounts/server/handler.relay',
61+
destination: '/docs/server/relay-handler',
62+
},
63+
{
64+
source: '/learn/tempo/receive-policies',
65+
destination: '/docs/guide/payments/configure-receive-policies',
66+
},
67+
{
68+
source: '/learn/use-cases/agentic-commerce',
69+
destination: 'https://tempo.xyz/learn/blockchain-payments/',
70+
},
71+
{
72+
source: '/learn/use-cases/embedded-finance',
73+
destination: 'https://tempo.xyz/learn/stablecoin-payments/',
74+
},
75+
{
76+
source: '/learn/use-cases/global-payouts',
77+
destination: 'https://tempo.xyz/learn/global-payouts/',
78+
},
79+
{
80+
source: '/learn/use-cases/microtransactions',
81+
destination: 'https://tempo.xyz/learn/microtransactions/',
82+
},
83+
{
84+
source: '/learn/use-cases/payroll',
85+
destination: 'https://tempo.xyz/learn/stablecoin-payroll/',
86+
},
87+
{
88+
source: '/learn/use-cases/remittances',
89+
destination: 'https://tempo.xyz/learn/cross-border-payments/',
90+
},
91+
{
92+
source: '/learn/use-cases/tokenized-deposits',
93+
destination: 'https://tempo.xyz/learn/tokenized-deposits/',
94+
},
95+
{
96+
source: '/docs/protocol/tips/tip-1028',
97+
destination: 'https://tips.sh/1028',
98+
},
2399
] as const satisfies readonly DocsRouteContract[]
24100

25101
export const legacyDocsHostRoutes = [
@@ -61,6 +137,87 @@ export const legacyDocsHostRoutes = [
61137
source: '/network-upgrades',
62138
destination: `${canonicalDevelopersOrigin}/docs/guide/node/network-upgrades`,
63139
},
140+
{
141+
source: '/guide/use-accounts/add-funds',
142+
destination: `${canonicalDevelopersOrigin}/docs/guide/getting-funds`,
143+
},
144+
{
145+
source: '/guide/use-accounts/authorize-access-keys',
146+
destination: `${canonicalDevelopersOrigin}/docs/guide/tempo-transaction#access-keys`,
147+
},
148+
{
149+
source: '/guide/use-accounts/batch-transactions',
150+
destination: `${canonicalDevelopersOrigin}/docs/guide/tempo-transaction#batch-calls`,
151+
},
152+
{
153+
source: '/guide/use-accounts/fee-sponsorship',
154+
destination: `${canonicalDevelopersOrigin}/docs/guide/payments/sponsor-user-fees`,
155+
},
156+
{
157+
source: '/guide/use-accounts/scheduled-transactions',
158+
destination: `${canonicalDevelopersOrigin}/docs/guide/tempo-transaction#scheduled-transactions`,
159+
},
160+
{
161+
source: '/guide/use-accounts/webauthn-p256-signatures',
162+
destination: `${canonicalDevelopersOrigin}/docs/protocol/transactions/spec-tempo-transaction#signature-types`,
163+
},
164+
{
165+
source: '/sdk/typescript/server',
166+
destination: `${canonicalDevelopersOrigin}/docs/server`,
167+
},
168+
{
169+
source: '/sdk/typescript/server/handlers',
170+
destination: `${canonicalDevelopersOrigin}/docs/server`,
171+
},
172+
{
173+
source: '/sdk/typescript/server/handler.feePayer',
174+
destination: `${canonicalDevelopersOrigin}/docs/server/relay-handler#feepayer`,
175+
},
176+
{ source: '/accounts/server', destination: `${canonicalDevelopersOrigin}/docs/server` },
177+
{
178+
source: '/accounts/server/handler.feePayer',
179+
destination: `${canonicalDevelopersOrigin}/docs/server/relay-handler#feepayer`,
180+
},
181+
{
182+
source: '/accounts/server/handler.relay',
183+
destination: `${canonicalDevelopersOrigin}/docs/server/relay-handler`,
184+
},
185+
{
186+
source: '/learn/tempo/receive-policies',
187+
destination: `${canonicalDevelopersOrigin}/docs/guide/payments/configure-receive-policies`,
188+
},
189+
{
190+
source: '/learn/use-cases/agentic-commerce',
191+
destination: 'https://tempo.xyz/learn/blockchain-payments/',
192+
},
193+
{
194+
source: '/learn/use-cases/embedded-finance',
195+
destination: 'https://tempo.xyz/learn/stablecoin-payments/',
196+
},
197+
{
198+
source: '/learn/use-cases/global-payouts',
199+
destination: 'https://tempo.xyz/learn/global-payouts/',
200+
},
201+
{
202+
source: '/learn/use-cases/microtransactions',
203+
destination: 'https://tempo.xyz/learn/microtransactions/',
204+
},
205+
{
206+
source: '/learn/use-cases/payroll',
207+
destination: 'https://tempo.xyz/learn/stablecoin-payroll/',
208+
},
209+
{
210+
source: '/learn/use-cases/remittances',
211+
destination: 'https://tempo.xyz/learn/cross-border-payments/',
212+
},
213+
{
214+
source: '/learn/use-cases/tokenized-deposits',
215+
destination: 'https://tempo.xyz/learn/tokenized-deposits/',
216+
},
217+
{
218+
source: '/protocol/tips/tip-1028',
219+
destination: 'https://tips.sh/1028',
220+
},
64221
] as const satisfies readonly DocsRouteContract[]
65222

66223
export const routingSmokeCases = {
@@ -73,6 +230,21 @@ export const routingSmokeCases = {
73230
expectedLocation: `${canonicalDevelopersOrigin}/docs/api`,
74231
expectedFinalStatus: 200,
75232
},
233+
{
234+
path: '/developers/docs/guide/use-accounts/batch-transactions',
235+
expectedLocation: `${canonicalDevelopersOrigin}/docs/guide/tempo-transaction#batch-calls`,
236+
expectedFinalStatus: 200,
237+
},
238+
{
239+
path: '/developers/accounts/server/handler.relay',
240+
expectedLocation: `${canonicalDevelopersOrigin}/docs/server/relay-handler`,
241+
expectedFinalStatus: 200,
242+
},
243+
{
244+
path: '/developers/learn/use-cases/payroll',
245+
expectedLocation: 'https://tempo.xyz/learn/stablecoin-payroll/',
246+
expectedFinalStatus: 200,
247+
},
76248
{ path: '/developers/api/og', expectedNonRedirect: true },
77249
],
78250
legacy: [
@@ -91,5 +263,20 @@ export const routingSmokeCases = {
91263
expectedLocation: `${canonicalDevelopersOrigin}/docs/quickstart/integrate-tempo`,
92264
expectedFinalStatus: 200,
93265
},
266+
{
267+
path: '/guide/use-accounts/batch-transactions',
268+
expectedLocation: `${canonicalDevelopersOrigin}/docs/guide/tempo-transaction#batch-calls`,
269+
expectedFinalStatus: 200,
270+
},
271+
{
272+
path: '/accounts/server/handler.relay',
273+
expectedLocation: `${canonicalDevelopersOrigin}/docs/server/relay-handler`,
274+
expectedFinalStatus: 200,
275+
},
276+
{
277+
path: '/learn/use-cases/payroll',
278+
expectedLocation: 'https://tempo.xyz/learn/stablecoin-payroll/',
279+
expectedFinalStatus: 200,
280+
},
94281
],
95282
} as const

‎src/lib/sitemap.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { finalizeSitemap } from './sitemap'
3+
4+
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
5+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
6+
<url>
7+
<loc>https://tempo.xyz/developers/blog</loc>
8+
</url>
9+
<url>
10+
<loc>https://tempo.xyz/developers/blog/[slug]</loc>
11+
<lastmod>2026-07-18</lastmod>
12+
</url>
13+
<url>
14+
<loc>https://tempo.xyz/developers/example/[id]</loc>
15+
</url>
16+
</urlset>`
17+
18+
describe('finalizeSitemap', () => {
19+
it('replaces the blog template with canonical post URLs and removes other templates', () => {
20+
const result = finalizeSitemap(sitemap, ['t7-network-upgrade', 't6'])
21+
22+
expect(result).toContain('<loc>https://tempo.xyz/developers/blog/t6</loc>')
23+
expect(result).toContain('<loc>https://tempo.xyz/developers/blog/t7-network-upgrade</loc>')
24+
expect(result.indexOf('/blog/t6')).toBeLessThan(result.indexOf('/blog/t7-network-upgrade'))
25+
expect(result).not.toContain('[slug]')
26+
expect(result).not.toContain('[id]')
27+
})
28+
29+
it('does not duplicate a blog URL that the sitemap already contains', () => {
30+
const withExistingPost = sitemap.replace(
31+
'</urlset>',
32+
' <url>\n <loc>https://tempo.xyz/developers/blog/t6</loc>\n </url>\n</urlset>',
33+
)
34+
const result = finalizeSitemap(withExistingPost, ['t6'])
35+
36+
expect(result.match(/<loc>https:\/\/tempo\.xyz\/developers\/blog\/t6<\/loc>/g)).toHaveLength(1)
37+
})
38+
39+
it('uses the blog index when the framework stops emitting a template route', () => {
40+
const withoutBlogTemplate = sitemap.replace(
41+
/ {2}<url>\n {4}<loc>https:\/\/tempo\.xyz\/developers\/blog\/\[slug\]<\/loc>\n {4}<lastmod>2026-07-18<\/lastmod>\n {2}<\/url>\n/,
42+
'',
43+
)
44+
const result = finalizeSitemap(withoutBlogTemplate, ['t6'])
45+
46+
expect(result).toContain('<loc>https://tempo.xyz/developers/blog/t6</loc>')
47+
})
48+
})

‎src/lib/sitemap.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
const TEMPLATE_URL_PATTERN = /<url>\s*<loc>([^<]*\/\[[^\]]+\][^<]*)<\/loc>[\s\S]*?<\/url>\s*/g
2+
const LOCATION_PATTERN = /<loc>([^<]+)<\/loc>/g
3+
4+
export function finalizeSitemap(sitemap: string, blogPostSlugs: readonly string[]): string {
5+
let blogBaseUrl: string | undefined
6+
7+
const withoutTemplates = sitemap.replace(TEMPLATE_URL_PATTERN, (_entry, location: string) => {
8+
const blogTemplate = /^(.*\/blog\/)\[slug\]\/?$/.exec(location)
9+
if (blogTemplate) blogBaseUrl = blogTemplate[1]
10+
return ''
11+
})
12+
13+
const existingLocations = new Set(
14+
Array.from(withoutTemplates.matchAll(LOCATION_PATTERN), ([, location]) => location),
15+
)
16+
17+
if (!blogBaseUrl) {
18+
const blogIndexUrl = Array.from(existingLocations).find((location) =>
19+
/\/blog\/?$/.test(location),
20+
)
21+
if (blogIndexUrl) blogBaseUrl = `${blogIndexUrl.replace(/\/$/, '')}/`
22+
}
23+
24+
const uniqueSlugs = [...new Set(blogPostSlugs)].sort((a, b) => a.localeCompare(b))
25+
if (uniqueSlugs.length === 0) return withoutTemplates
26+
if (!blogBaseUrl) throw new Error('Could not resolve the blog base URL from the sitemap')
27+
if (!withoutTemplates.includes('</urlset>')) {
28+
throw new Error('Could not find the sitemap urlset closing tag')
29+
}
30+
31+
const entries = uniqueSlugs
32+
.map((slug) => `${blogBaseUrl}${encodeURIComponent(slug)}`)
33+
.filter((location) => !existingLocations.has(location))
34+
.map((location) => ` <url>\n <loc>${location}</loc>\n </url>`)
35+
36+
if (entries.length === 0) return withoutTemplates
37+
38+
return withoutTemplates.replace('</urlset>', `${entries.join('\n')}\n</urlset>`)
39+
}

0 commit comments

Comments
 (0)