From 1fe769fb656bcd8b1534340f8987758441ee5ede Mon Sep 17 00:00:00 2001 From: Muhammad Sohail Date: Mon, 28 Sep 2026 17:30:27 +0500 Subject: [PATCH 1/4] fix(portal): keep the encoding a page slug carries Fumadocs encodes each page slug segment with encodeURI, and the router hands a route its splat already decoded, so a slug spelt from an operationId carrying a space was looked up in a spelling no page had. The static build failed on the first such page and a reader following the sidebar got the not-found page. Closes apimatic/apimatic-io#2270 Co-Authored-By: Claude Opus 5 (1M context) --- portal-template/src/lib/shared.ts | 11 ++++ portal-template/src/routes/$.tsx | 4 +- portal-template/src/routes/{$}[.]md.ts | 4 +- test/portal-template/page-slugs.test.ts | 85 +++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 test/portal-template/page-slugs.test.ts diff --git a/portal-template/src/lib/shared.ts b/portal-template/src/lib/shared.ts index 457ed7ffc..cd18e574f 100644 --- a/portal-template/src/lib/shared.ts +++ b/portal-template/src/lib/shared.ts @@ -33,6 +33,17 @@ export function getPageMarkdownUrl(page: { slugs: string[]; locale?: string }) { return { segments, url: getDocsUrl(segments, page.locale) }; } +/** + * The router hands back a decoded splat, and a page's slugs are `encodeURI`'d segment by segment, + * so a slug spelt from a space or a non-ASCII character only matches once it is encoded again. + */ +export function slugsFromSplat(splat: string | undefined): string[] { + return (splat ?? '') + .split('/') + .filter((segment) => segment.length > 0) + .map(encodeURI); +} + /** @returns page slugs */ export function decodeMarkdownUrl(segments: string[]) { if (segments.length === 0) return []; diff --git a/portal-template/src/routes/$.tsx b/portal-template/src/routes/$.tsx index 41ca64e2a..3cfbd0f5e 100644 --- a/portal-template/src/routes/$.tsx +++ b/portal-template/src/routes/$.tsx @@ -3,7 +3,7 @@ import { createServerFn } from '@tanstack/react-start'; import { docs, generated } from '@/lib/source'; import { source } from '@/lib/source.server'; import { PortalLayout } from '@/lib/layout'; -import { getPageMarkdownUrl } from '@/lib/shared'; +import { getPageMarkdownUrl, slugsFromSplat } from '@/lib/shared'; import { portal } from '@/lib/portal'; import { absoluteUrl, canonicalLink } from '@/lib/seo'; import { useFumadocsLoader } from 'fumadocs-core/source/client'; @@ -26,7 +26,7 @@ const collections = { docs, generated }; export const Route = createFileRoute('/$')({ component: Page, loader: async ({ params }) => { - const slugs = params._splat?.split('/').filter((segment) => segment.length > 0) ?? []; + const slugs = slugsFromSplat(params._splat); const data = await loadPage(slugs); if (data.type === 'docs') { diff --git a/portal-template/src/routes/{$}[.]md.ts b/portal-template/src/routes/{$}[.]md.ts index 12ac199ee..d15ac73a5 100644 --- a/portal-template/src/routes/{$}[.]md.ts +++ b/portal-template/src/routes/{$}[.]md.ts @@ -1,7 +1,7 @@ import { createFileRoute, notFound } from '@tanstack/react-router'; import { source } from '@/lib/source.server'; import { renderHome, renderPage } from '@/lib/llms.server'; -import { decodeMarkdownUrl } from '@/lib/shared'; +import { decodeMarkdownUrl, slugsFromSplat } from '@/lib/shared'; const headers = { 'Content-Type': 'text/markdown' }; @@ -9,7 +9,7 @@ export const Route = createFileRoute('/{$}.md')({ server: { handlers: { GET: async ({ params }) => { - const slugs = decodeMarkdownUrl(params._splat?.split('/') ?? []); + const slugs = decodeMarkdownUrl(slugsFromSplat(params._splat)); const page = source.getPage(slugs); if (page) return new Response(await renderPage(page), { headers }); // Mirrors the generated landing page served at "/" when there is no index page. diff --git a/test/portal-template/page-slugs.test.ts b/test/portal-template/page-slugs.test.ts new file mode 100644 index 000000000..3d99926c4 --- /dev/null +++ b/test/portal-template/page-slugs.test.ts @@ -0,0 +1,85 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { expect } from 'chai'; +import { loader } from 'fumadocs-core/source'; +import { openApiSection } from '../../portal-template/src/lib/openapi-section.server'; +import { decodeMarkdownUrl, slugsFromSplat } from '../../portal-template/src/lib/shared'; + +const ok = { 200: { description: 'ok' } }; + +describe('slugsFromSplat', () => { + it('drops the empty segments a leading or trailing slash leaves behind', () => { + expect(slugsFromSplat('guides/getting-started/')).to.deep.equal(['guides', 'getting-started']); + expect(slugsFromSplat('')).to.deep.equal([]); + expect(slugsFromSplat(undefined)).to.deep.equal([]); + }); + + it('leaves an ordinary slug exactly as it was', () => { + expect(slugsFromSplat('api/openapi/pets/listPets')).to.deep.equal(['api', 'openapi', 'pets', 'listPets']); + }); + + it('encodes each segment the way the page slugs were encoded, and no further', () => { + expect(slugsFromSplat('api/List All Pets')).to.deep.equal(['api', 'List%20All%20Pets']); + expect(slugsFromSplat('café')).to.deep.equal(['caf%C3%A9']); + // encodeURI, not encodeURIComponent: Fumadocs leaves these in, so matching them means leaving them too. + expect(slugsFromSplat('pets&cats')).to.deep.equal(['pets&cats']); + }); +}); + +// A page whose slug needed encoding was reachable only by the URL Fumadocs prints, never by the +// decoded path the router hands back -- so the static build failed to prerender it and a reader +// following the sidebar got the not-found page. +describe('a page whose operationId needs encoding', () => { + let directory: string; + + const urlOf = async () => { + const source = loader( + { openapi: await openApiSection('openapi', path.join(directory, 'api.json'), null) }, + { + baseUrl: '/' + } + ); + const page = source.getPages().find((candidate) => candidate.url.includes('%20')); + expect(page, 'no page slug needed encoding').to.not.equal(undefined); + return { source, url: page!.url }; + }; + + /** What TanStack gives a route: the pathname, already percent-decoded. */ + const splatOf = (url: string) => decodeURIComponent(url).replace(/^\//, ''); + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'page-slugs-')); + fs.writeFileSync( + path.join(directory, 'api.json'), + JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Pets', version: '1' }, + tags: [{ name: 'pets' }], + paths: { '/pets': { get: { operationId: 'List All Pets', tags: ['pets'], responses: ok } } } + }) + ); + }); + + afterEach(() => { + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it('is found from the splat the router decoded', async () => { + const { source, url } = await urlOf(); + + expect(source.getPage(slugsFromSplat(splatOf(url)))?.url).to.equal(url); + }); + + it('is found at its Markdown address too', async () => { + const { source, url } = await urlOf(); + + expect(source.getPage(decodeMarkdownUrl(slugsFromSplat(`${splatOf(url)}.md`)))?.url).to.equal(url); + }); + + it('is missed when the splat is looked up as it arrives', async () => { + const { source, url } = await urlOf(); + + expect(source.getPage(splatOf(url).split('/'))).to.equal(undefined); + }); +}); From 7ef48447e8881770296ff0c93fc2846b863f942b Mon Sep 17 00:00:00 2001 From: Muhammad Sohail Date: Mon, 28 Sep 2026 17:33:32 +0500 Subject: [PATCH 2/4] style(portal): let the names carry what the comments said Co-Authored-By: Claude Opus 5 (1M context) --- portal-template/src/lib/shared.ts | 4 ---- test/portal-template/page-slugs.test.ts | 13 ++++--------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/portal-template/src/lib/shared.ts b/portal-template/src/lib/shared.ts index cd18e574f..f38b65b7e 100644 --- a/portal-template/src/lib/shared.ts +++ b/portal-template/src/lib/shared.ts @@ -33,10 +33,6 @@ export function getPageMarkdownUrl(page: { slugs: string[]; locale?: string }) { return { segments, url: getDocsUrl(segments, page.locale) }; } -/** - * The router hands back a decoded splat, and a page's slugs are `encodeURI`'d segment by segment, - * so a slug spelt from a space or a non-ASCII character only matches once it is encoded again. - */ export function slugsFromSplat(splat: string | undefined): string[] { return (splat ?? '') .split('/') diff --git a/test/portal-template/page-slugs.test.ts b/test/portal-template/page-slugs.test.ts index 3d99926c4..1b6c373d3 100644 --- a/test/portal-template/page-slugs.test.ts +++ b/test/portal-template/page-slugs.test.ts @@ -22,14 +22,10 @@ describe('slugsFromSplat', () => { it('encodes each segment the way the page slugs were encoded, and no further', () => { expect(slugsFromSplat('api/List All Pets')).to.deep.equal(['api', 'List%20All%20Pets']); expect(slugsFromSplat('café')).to.deep.equal(['caf%C3%A9']); - // encodeURI, not encodeURIComponent: Fumadocs leaves these in, so matching them means leaving them too. expect(slugsFromSplat('pets&cats')).to.deep.equal(['pets&cats']); }); }); -// A page whose slug needed encoding was reachable only by the URL Fumadocs prints, never by the -// decoded path the router hands back -- so the static build failed to prerender it and a reader -// following the sidebar got the not-found page. describe('a page whose operationId needs encoding', () => { let directory: string; @@ -45,8 +41,7 @@ describe('a page whose operationId needs encoding', () => { return { source, url: page!.url }; }; - /** What TanStack gives a route: the pathname, already percent-decoded. */ - const splatOf = (url: string) => decodeURIComponent(url).replace(/^\//, ''); + const decodedSplatOf = (url: string) => decodeURIComponent(url).replace(/^\//, ''); beforeEach(() => { directory = fs.mkdtempSync(path.join(os.tmpdir(), 'page-slugs-')); @@ -68,18 +63,18 @@ describe('a page whose operationId needs encoding', () => { it('is found from the splat the router decoded', async () => { const { source, url } = await urlOf(); - expect(source.getPage(slugsFromSplat(splatOf(url)))?.url).to.equal(url); + expect(source.getPage(slugsFromSplat(decodedSplatOf(url)))?.url).to.equal(url); }); it('is found at its Markdown address too', async () => { const { source, url } = await urlOf(); - expect(source.getPage(decodeMarkdownUrl(slugsFromSplat(`${splatOf(url)}.md`)))?.url).to.equal(url); + expect(source.getPage(decodeMarkdownUrl(slugsFromSplat(`${decodedSplatOf(url)}.md`)))?.url).to.equal(url); }); it('is missed when the splat is looked up as it arrives', async () => { const { source, url } = await urlOf(); - expect(source.getPage(splatOf(url).split('/'))).to.equal(undefined); + expect(source.getPage(decodedSplatOf(url).split('/'))).to.equal(undefined); }); }); From b5c0694c221632d18437ec96551fd106e32faaec Mon Sep 17 00:00:00 2001 From: Muhammad Sohail Date: Mon, 28 Sep 2026 17:57:06 +0500 Subject: [PATCH 3/4] fix(portal): address the review on the page slug encoding Build the page address in head() from the same rule the loader uses, so the canonical link and og:url agree with sitemap.xml. Keep the empty-segment filter in the catch-all route, where it already lived, rather than in the shared helper, so the Markdown route answers exactly what it answered before. Co-Authored-By: Claude Opus 5 (1M context) --- portal-template/src/lib/shared.ts | 7 +-- portal-template/src/routes/$.tsx | 10 +++-- portal-template/src/routes/{$}[.]md.ts | 4 +- test/portal-template/page-slugs.test.ts | 60 ++++++++++--------------- 4 files changed, 33 insertions(+), 48 deletions(-) diff --git a/portal-template/src/lib/shared.ts b/portal-template/src/lib/shared.ts index f38b65b7e..87eb1d13d 100644 --- a/portal-template/src/lib/shared.ts +++ b/portal-template/src/lib/shared.ts @@ -33,11 +33,8 @@ export function getPageMarkdownUrl(page: { slugs: string[]; locale?: string }) { return { segments, url: getDocsUrl(segments, page.locale) }; } -export function slugsFromSplat(splat: string | undefined): string[] { - return (splat ?? '') - .split('/') - .filter((segment) => segment.length > 0) - .map(encodeURI); +export function encodeSlugs(segments: string[]): string[] { + return segments.map(encodeURI); } /** @returns page slugs */ diff --git a/portal-template/src/routes/$.tsx b/portal-template/src/routes/$.tsx index 3cfbd0f5e..89ca0ab7e 100644 --- a/portal-template/src/routes/$.tsx +++ b/portal-template/src/routes/$.tsx @@ -3,7 +3,7 @@ import { createServerFn } from '@tanstack/react-start'; import { docs, generated } from '@/lib/source'; import { source } from '@/lib/source.server'; import { PortalLayout } from '@/lib/layout'; -import { getPageMarkdownUrl, slugsFromSplat } from '@/lib/shared'; +import { encodeSlugs, getPageMarkdownUrl } from '@/lib/shared'; import { portal } from '@/lib/portal'; import { absoluteUrl, canonicalLink } from '@/lib/seo'; import { useFumadocsLoader } from 'fumadocs-core/source/client'; @@ -17,6 +17,9 @@ import { slimOpenAPIPageProps } from '@/lib/openapi-slim'; const rootRoute = getRouteApi('__root__'); +const pageSlugs = (splat: string | undefined) => + encodeSlugs(splat?.split('/').filter((segment) => segment.length > 0) ?? []); + /** * The collections a Markdown page is compiled into, by the loader source it came from. A * page's `path` is relative to its own collection's directory, so it is looked up there. @@ -26,7 +29,7 @@ const collections = { docs, generated }; export const Route = createFileRoute('/$')({ component: Page, loader: async ({ params }) => { - const slugs = slugsFromSplat(params._splat); + const slugs = pageSlugs(params._splat); const data = await loadPage(slugs); if (data.type === 'docs') { @@ -37,8 +40,7 @@ export const Route = createFileRoute('/$')({ head: ({ loaderData, params }) => { const title = loaderData && loaderData.type !== 'home' ? `${loaderData.title} | ${portal.name}` : portal.name; const description = loaderData?.description ?? portal.description; - const splat = params._splat?.replace(/\/$/, '') ?? ''; - const pageUrl = splat.length > 0 ? `/${splat}` : '/'; + const pageUrl = `/${pageSlugs(params._splat).join('/')}`; const absolute = absoluteUrl(pageUrl); return { // Without the og:* pair, a documentation link pasted into Slack, Teams or LinkedIn diff --git a/portal-template/src/routes/{$}[.]md.ts b/portal-template/src/routes/{$}[.]md.ts index d15ac73a5..0c89aca35 100644 --- a/portal-template/src/routes/{$}[.]md.ts +++ b/portal-template/src/routes/{$}[.]md.ts @@ -1,7 +1,7 @@ import { createFileRoute, notFound } from '@tanstack/react-router'; import { source } from '@/lib/source.server'; import { renderHome, renderPage } from '@/lib/llms.server'; -import { decodeMarkdownUrl, slugsFromSplat } from '@/lib/shared'; +import { decodeMarkdownUrl, encodeSlugs } from '@/lib/shared'; const headers = { 'Content-Type': 'text/markdown' }; @@ -9,7 +9,7 @@ export const Route = createFileRoute('/{$}.md')({ server: { handlers: { GET: async ({ params }) => { - const slugs = decodeMarkdownUrl(slugsFromSplat(params._splat)); + const slugs = decodeMarkdownUrl(encodeSlugs(params._splat?.split('/') ?? [])); const page = source.getPage(slugs); if (page) return new Response(await renderPage(page), { headers }); // Mirrors the generated landing page served at "/" when there is no index page. diff --git a/test/portal-template/page-slugs.test.ts b/test/portal-template/page-slugs.test.ts index 1b6c373d3..01d474696 100644 --- a/test/portal-template/page-slugs.test.ts +++ b/test/portal-template/page-slugs.test.ts @@ -4,46 +4,33 @@ import path from 'path'; import { expect } from 'chai'; import { loader } from 'fumadocs-core/source'; import { openApiSection } from '../../portal-template/src/lib/openapi-section.server'; -import { decodeMarkdownUrl, slugsFromSplat } from '../../portal-template/src/lib/shared'; +import { decodeMarkdownUrl, encodeSlugs } from '../../portal-template/src/lib/shared'; const ok = { 200: { description: 'ok' } }; -describe('slugsFromSplat', () => { - it('drops the empty segments a leading or trailing slash leaves behind', () => { - expect(slugsFromSplat('guides/getting-started/')).to.deep.equal(['guides', 'getting-started']); - expect(slugsFromSplat('')).to.deep.equal([]); - expect(slugsFromSplat(undefined)).to.deep.equal([]); - }); - +describe('encodeSlugs', () => { it('leaves an ordinary slug exactly as it was', () => { - expect(slugsFromSplat('api/openapi/pets/listPets')).to.deep.equal(['api', 'openapi', 'pets', 'listPets']); + expect(encodeSlugs(['api', 'openapi', 'pets', 'listPets'])).to.deep.equal(['api', 'openapi', 'pets', 'listPets']); }); it('encodes each segment the way the page slugs were encoded, and no further', () => { - expect(slugsFromSplat('api/List All Pets')).to.deep.equal(['api', 'List%20All%20Pets']); - expect(slugsFromSplat('café')).to.deep.equal(['caf%C3%A9']); - expect(slugsFromSplat('pets&cats')).to.deep.equal(['pets&cats']); + expect(encodeSlugs(['api', 'List All Pets'])).to.deep.equal(['api', 'List%20All%20Pets']); + expect(encodeSlugs(['café'])).to.deep.equal(['caf%C3%A9']); + expect(encodeSlugs(['pets&cats'])).to.deep.equal(['pets&cats']); }); }); describe('a page whose operationId needs encoding', () => { let directory: string; + let source: Awaited>; + let url: string; - const urlOf = async () => { - const source = loader( - { openapi: await openApiSection('openapi', path.join(directory, 'api.json'), null) }, - { - baseUrl: '/' - } - ); - const page = source.getPages().find((candidate) => candidate.url.includes('%20')); - expect(page, 'no page slug needed encoding').to.not.equal(undefined); - return { source, url: page!.url }; - }; + const sectionSource = async () => + loader({ openapi: await openApiSection('openapi', path.join(directory, 'api.json'), null) }, { baseUrl: '/' }); - const decodedSplatOf = (url: string) => decodeURIComponent(url).replace(/^\//, ''); + const decodedSplatOf = (pageUrl: string) => decodeURIComponent(pageUrl).replace(/^\//, ''); - beforeEach(() => { + before(async () => { directory = fs.mkdtempSync(path.join(os.tmpdir(), 'page-slugs-')); fs.writeFileSync( path.join(directory, 'api.json'), @@ -54,27 +41,26 @@ describe('a page whose operationId needs encoding', () => { paths: { '/pets': { get: { operationId: 'List All Pets', tags: ['pets'], responses: ok } } } }) ); + + source = await sectionSource(); + const page = source.getPages().find((candidate) => candidate.url.includes('%20')); + expect(page, 'no page slug needed encoding').to.not.equal(undefined); + url = page!.url; }); - afterEach(() => { + after(() => { fs.rmSync(directory, { recursive: true, force: true }); }); - it('is found from the splat the router decoded', async () => { - const { source, url } = await urlOf(); - - expect(source.getPage(slugsFromSplat(decodedSplatOf(url)))?.url).to.equal(url); + it('is found from the splat the router decoded', () => { + expect(source.getPage(encodeSlugs(decodedSplatOf(url).split('/')))?.url).to.equal(url); }); - it('is found at its Markdown address too', async () => { - const { source, url } = await urlOf(); - - expect(source.getPage(decodeMarkdownUrl(slugsFromSplat(`${decodedSplatOf(url)}.md`)))?.url).to.equal(url); + it('is found at its Markdown address too', () => { + expect(source.getPage(decodeMarkdownUrl(encodeSlugs(decodedSplatOf(url).split('/'))))?.url).to.equal(url); }); - it('is missed when the splat is looked up as it arrives', async () => { - const { source, url } = await urlOf(); - + it('is missed when the splat is looked up as it arrives', () => { expect(source.getPage(decodedSplatOf(url).split('/'))).to.equal(undefined); }); }); From 299b824aebd044ebf59f48e41ff77d3ae56b3489 Mon Sep 17 00:00:00 2001 From: Muhammad Sohail Date: Mon, 28 Sep 2026 18:17:17 +0500 Subject: [PATCH 4/4] refactor(portal): read a route's page slugs through one helper Both routes take their slugs from slugsFromSplat, so the splitting lives in one place rather than in each route, and only the encodeURI has to go once operation pages are named so their slugs need no encoding. Co-Authored-By: Claude Opus 5 (1M context) --- portal-template/src/lib/shared.ts | 7 +++++-- portal-template/src/routes/$.tsx | 9 +++------ portal-template/src/routes/{$}[.]md.ts | 4 ++-- test/portal-template/page-slugs.test.ts | 22 ++++++++++++++-------- 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/portal-template/src/lib/shared.ts b/portal-template/src/lib/shared.ts index 87eb1d13d..f38b65b7e 100644 --- a/portal-template/src/lib/shared.ts +++ b/portal-template/src/lib/shared.ts @@ -33,8 +33,11 @@ export function getPageMarkdownUrl(page: { slugs: string[]; locale?: string }) { return { segments, url: getDocsUrl(segments, page.locale) }; } -export function encodeSlugs(segments: string[]): string[] { - return segments.map(encodeURI); +export function slugsFromSplat(splat: string | undefined): string[] { + return (splat ?? '') + .split('/') + .filter((segment) => segment.length > 0) + .map(encodeURI); } /** @returns page slugs */ diff --git a/portal-template/src/routes/$.tsx b/portal-template/src/routes/$.tsx index 89ca0ab7e..590159bf7 100644 --- a/portal-template/src/routes/$.tsx +++ b/portal-template/src/routes/$.tsx @@ -3,7 +3,7 @@ import { createServerFn } from '@tanstack/react-start'; import { docs, generated } from '@/lib/source'; import { source } from '@/lib/source.server'; import { PortalLayout } from '@/lib/layout'; -import { encodeSlugs, getPageMarkdownUrl } from '@/lib/shared'; +import { getPageMarkdownUrl, slugsFromSplat } from '@/lib/shared'; import { portal } from '@/lib/portal'; import { absoluteUrl, canonicalLink } from '@/lib/seo'; import { useFumadocsLoader } from 'fumadocs-core/source/client'; @@ -17,9 +17,6 @@ import { slimOpenAPIPageProps } from '@/lib/openapi-slim'; const rootRoute = getRouteApi('__root__'); -const pageSlugs = (splat: string | undefined) => - encodeSlugs(splat?.split('/').filter((segment) => segment.length > 0) ?? []); - /** * The collections a Markdown page is compiled into, by the loader source it came from. A * page's `path` is relative to its own collection's directory, so it is looked up there. @@ -29,7 +26,7 @@ const collections = { docs, generated }; export const Route = createFileRoute('/$')({ component: Page, loader: async ({ params }) => { - const slugs = pageSlugs(params._splat); + const slugs = slugsFromSplat(params._splat); const data = await loadPage(slugs); if (data.type === 'docs') { @@ -40,7 +37,7 @@ export const Route = createFileRoute('/$')({ head: ({ loaderData, params }) => { const title = loaderData && loaderData.type !== 'home' ? `${loaderData.title} | ${portal.name}` : portal.name; const description = loaderData?.description ?? portal.description; - const pageUrl = `/${pageSlugs(params._splat).join('/')}`; + const pageUrl = `/${slugsFromSplat(params._splat).join('/')}`; const absolute = absoluteUrl(pageUrl); return { // Without the og:* pair, a documentation link pasted into Slack, Teams or LinkedIn diff --git a/portal-template/src/routes/{$}[.]md.ts b/portal-template/src/routes/{$}[.]md.ts index 0c89aca35..d15ac73a5 100644 --- a/portal-template/src/routes/{$}[.]md.ts +++ b/portal-template/src/routes/{$}[.]md.ts @@ -1,7 +1,7 @@ import { createFileRoute, notFound } from '@tanstack/react-router'; import { source } from '@/lib/source.server'; import { renderHome, renderPage } from '@/lib/llms.server'; -import { decodeMarkdownUrl, encodeSlugs } from '@/lib/shared'; +import { decodeMarkdownUrl, slugsFromSplat } from '@/lib/shared'; const headers = { 'Content-Type': 'text/markdown' }; @@ -9,7 +9,7 @@ export const Route = createFileRoute('/{$}.md')({ server: { handlers: { GET: async ({ params }) => { - const slugs = decodeMarkdownUrl(encodeSlugs(params._splat?.split('/') ?? [])); + const slugs = decodeMarkdownUrl(slugsFromSplat(params._splat)); const page = source.getPage(slugs); if (page) return new Response(await renderPage(page), { headers }); // Mirrors the generated landing page served at "/" when there is no index page. diff --git a/test/portal-template/page-slugs.test.ts b/test/portal-template/page-slugs.test.ts index 01d474696..0f499e61f 100644 --- a/test/portal-template/page-slugs.test.ts +++ b/test/portal-template/page-slugs.test.ts @@ -4,19 +4,25 @@ import path from 'path'; import { expect } from 'chai'; import { loader } from 'fumadocs-core/source'; import { openApiSection } from '../../portal-template/src/lib/openapi-section.server'; -import { decodeMarkdownUrl, encodeSlugs } from '../../portal-template/src/lib/shared'; +import { decodeMarkdownUrl, slugsFromSplat } from '../../portal-template/src/lib/shared'; const ok = { 200: { description: 'ok' } }; -describe('encodeSlugs', () => { +describe('slugsFromSplat', () => { + it('drops the empty segments a leading or trailing slash leaves behind', () => { + expect(slugsFromSplat('guides/getting-started/')).to.deep.equal(['guides', 'getting-started']); + expect(slugsFromSplat('')).to.deep.equal([]); + expect(slugsFromSplat(undefined)).to.deep.equal([]); + }); + it('leaves an ordinary slug exactly as it was', () => { - expect(encodeSlugs(['api', 'openapi', 'pets', 'listPets'])).to.deep.equal(['api', 'openapi', 'pets', 'listPets']); + expect(slugsFromSplat('api/openapi/pets/listPets')).to.deep.equal(['api', 'openapi', 'pets', 'listPets']); }); it('encodes each segment the way the page slugs were encoded, and no further', () => { - expect(encodeSlugs(['api', 'List All Pets'])).to.deep.equal(['api', 'List%20All%20Pets']); - expect(encodeSlugs(['café'])).to.deep.equal(['caf%C3%A9']); - expect(encodeSlugs(['pets&cats'])).to.deep.equal(['pets&cats']); + expect(slugsFromSplat('api/List All Pets')).to.deep.equal(['api', 'List%20All%20Pets']); + expect(slugsFromSplat('café')).to.deep.equal(['caf%C3%A9']); + expect(slugsFromSplat('pets&cats')).to.deep.equal(['pets&cats']); }); }); @@ -53,11 +59,11 @@ describe('a page whose operationId needs encoding', () => { }); it('is found from the splat the router decoded', () => { - expect(source.getPage(encodeSlugs(decodedSplatOf(url).split('/')))?.url).to.equal(url); + expect(source.getPage(slugsFromSplat(decodedSplatOf(url)))?.url).to.equal(url); }); it('is found at its Markdown address too', () => { - expect(source.getPage(decodeMarkdownUrl(encodeSlugs(decodedSplatOf(url).split('/'))))?.url).to.equal(url); + expect(source.getPage(decodeMarkdownUrl(slugsFromSplat(decodedSplatOf(url))))?.url).to.equal(url); }); it('is missed when the splat is looked up as it arrives', () => {