From f3f5ed6ac49a191b482480042d9ee3dfecb46923 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Mar 2026 11:50:33 +0100 Subject: [PATCH 1/3] init --- src/components/SearchDialog.tsx | 3 +- src/components/search.tsx | 3 +- src/lib/layout.shared.tsx | 11 +- src/lib/redirect-route-rules.test.ts | 199 ++++++++++------------- src/lib/redirect-route-rules.ts | 39 ++--- src/lib/url-base.test.ts | 25 +++ src/lib/url-base.ts | 31 ++++ src/mdx-components.tsx | 3 +- src/routeTree.gen.ts | 147 +++-------------- src/routes/{docs => }/$.tsx | 5 +- src/routes/__root.tsx | 30 +++- src/routes/docs/api/404-report.ts | 10 -- src/routes/docs/api/feedback.ts | 10 -- src/routes/docs/api/raw/$.ts | 10 -- src/routes/docs/api/search.ts | 10 -- src/routes/docs/index.tsx | 232 --------------------------- src/routes/index.tsx | 224 +++++++++++++++++++++++++- src/start.ts | 78 +++++---- vite.config.ts | 5 +- 19 files changed, 478 insertions(+), 597 deletions(-) create mode 100644 src/lib/url-base.test.ts create mode 100644 src/lib/url-base.ts rename src/routes/{docs => }/$.tsx (97%) delete mode 100644 src/routes/docs/api/404-report.ts delete mode 100644 src/routes/docs/api/feedback.ts delete mode 100644 src/routes/docs/api/raw/$.ts delete mode 100644 src/routes/docs/api/search.ts delete mode 100644 src/routes/docs/index.tsx diff --git a/src/components/SearchDialog.tsx b/src/components/SearchDialog.tsx index 3b8781f0..d704f465 100644 --- a/src/components/SearchDialog.tsx +++ b/src/components/SearchDialog.tsx @@ -16,6 +16,7 @@ import { } from "fumadocs-ui/components/dialog/search"; import { useDocsSearch } from "fumadocs-core/search/client"; import type { SharedProps } from "fumadocs-ui/contexts/search"; +import { buildDocsApiPath } from "@/lib/url-base"; const tags = [ { name: "iOS", value: "ios" }, @@ -28,7 +29,7 @@ export function CustomSearchDialog(props: SharedProps) { const [tag, setTag] = useState(); const { search, setSearch, query } = useDocsSearch({ type: "fetch", - api: "/docs/api/search", + api: buildDocsApiPath("search"), tag, }); diff --git a/src/components/search.tsx b/src/components/search.tsx index e3872247..aa7e0856 100644 --- a/src/components/search.tsx +++ b/src/components/search.tsx @@ -28,6 +28,7 @@ import { Markdown } from "./markdown"; import { Presence } from "@radix-ui/react-presence"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { SDK_OPTIONS } from "../lib/sdk-options"; +import { buildDocsApiPath } from "../lib/url-base"; const API_URL = "https://docs-ai-api.superwall.com"; @@ -268,7 +269,7 @@ export function AISearch({ children }: { children: ReactNode }) { messagesRef.current.slice(0, idx).reverse().find((m) => m.role === "user")?.content ?? ""; - fetch("/docs/api/feedback", { + fetch(buildDocsApiPath("feedback"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/src/lib/layout.shared.tsx b/src/lib/layout.shared.tsx index c9375a0a..7dc2d7fc 100644 --- a/src/lib/layout.shared.tsx +++ b/src/lib/layout.shared.tsx @@ -1,5 +1,6 @@ import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared"; import { CircleHelp } from "lucide-react"; +import { buildDocsPath } from "./url-base"; export const gitConfig = { user: "superwall", @@ -10,27 +11,27 @@ export const gitConfig = { export function baseOptions(): BaseLayoutProps { return { nav: { - url: "/docs", + url: buildDocsPath(), title: ( <> - Superwall + Superwall ), }, links: [ { text: "Integrations", - url: "/docs/integrations", + url: buildDocsPath("integrations"), }, { text: "Changelog", - url: "/docs/changelog", + url: buildDocsPath("changelog"), }, { type: "icon", icon: , text: "Support", - url: "/docs/support", + url: buildDocsPath("support"), label: "Support", }, ], diff --git a/src/lib/redirect-route-rules.test.ts b/src/lib/redirect-route-rules.test.ts index 2ce7ffb3..bd24aa37 100644 --- a/src/lib/redirect-route-rules.test.ts +++ b/src/lib/redirect-route-rules.test.ts @@ -3,103 +3,72 @@ import { describe, test } from "node:test"; import { externalRedirectsMap, fileRedirectsMap, folderRedirectsMap } from "../../redirects-map"; import { buildRedirectRouteRules } from "./redirect-route-rules"; -describe("buildRedirectRouteRules", () => { - test("generates internal file redirect with /docs prefix and 308 status", () => { - const rules = buildRedirectRouteRules(); - const rule = rules["/web-checkout-post-checkout-redirecting"]; - const expectedDestination = `/docs/${fileRedirectsMap["web-checkout-post-checkout-redirecting"]}`; - - assert.ok(rule); - assert.equal(rule.redirect.to, expectedDestination); - assert.equal(rule.redirect.status, 308); - }); - - test("generates docs-prefixed internal file redirect compatibility rule", () => { - const rules = buildRedirectRouteRules(); - const rule = rules["/docs/web-checkout-post-checkout-redirecting"]; - const expectedDestination = `/docs/${fileRedirectsMap["web-checkout-post-checkout-redirecting"]}`; - - assert.ok(rule); - assert.equal(rule.redirect.to, expectedDestination); - assert.equal(rule.redirect.status, 308); - }); +const DOCS_BASE = "/docs"; + +function normalizeSourcePath(path: string): string { + const trimmed = path.trim(); + if (!trimmed) return "/"; + return `/${trimmed.replace(/^\/+/, "").replace(/\/+$/, "")}`; +} + +function normalizeDocsSourcePath(path: string): string { + const normalized = normalizeSourcePath(path); + if (normalized === DOCS_BASE || normalized.startsWith(`${DOCS_BASE}/`)) return normalized; + if (normalized === "/") return DOCS_BASE; + return `${DOCS_BASE}${normalized}`; +} + +function normalizeInternalDestination(path: string): string { + const normalized = normalizeSourcePath(path); + if (normalized === DOCS_BASE || normalized.startsWith(`${DOCS_BASE}/`)) return normalized; + if (normalized === "/") return DOCS_BASE; + return `${DOCS_BASE}${normalized}`; +} - test("generates external redirect with 308 status", () => { +describe("buildRedirectRouteRules", () => { + test("generates docs-scoped internal file redirects with 308 status", () => { const rules = buildRedirectRouteRules(); - const rule = rules["/troubleshooting"]; - - assert.ok(rule); - assert.equal( - rule.redirect.to, - "https://support.superwall.com/collections/6437438776-troubleshooting", - ); - assert.equal(rule.redirect.status, 308); + const source = "web-checkout-post-checkout-redirecting"; + const docsKey = normalizeDocsSourcePath(source); + const expectedDestination = normalizeInternalDestination(fileRedirectsMap[source]); + + assert.ok(rules[docsKey]); + assert.equal(rules[docsKey].redirect.to, expectedDestination); + assert.equal(rules[docsKey].redirect.status, 308); + assert.equal(rules["/web-checkout-post-checkout-redirecting"], undefined); }); - test("prefers external redirect for conflicting root slug while keeping docs-prefixed internal route", () => { + test("generates docs-scoped external redirects with 308 status", () => { const rules = buildRedirectRouteRules(); - const rootRule = rules["/troubleshooting"]; - const docsRule = rules["/docs/troubleshooting"]; + const docsKey = normalizeDocsSourcePath("troubleshooting"); - assert.ok(rootRule); - assert.ok(docsRule); + assert.ok(rules[docsKey]); assert.equal( - rootRule.redirect.to, + rules[docsKey].redirect.to, "https://support.superwall.com/collections/6437438776-troubleshooting", ); - assert.equal(docsRule.redirect.to, "/docs/support/troubleshooting"); - }); - - test("generates docs double-prefix guard redirect", () => { - const rules = buildRedirectRouteRules(); - const rule = rules["/docs/docs/**"]; - - assert.ok(rule); - assert.equal(rule.redirect.to, "/docs/**"); - assert.equal(rule.redirect.status, 308); - }); - - test("includes legacy rn expo one-off redirect", () => { - const rules = buildRedirectRouteRules(); - const rule = rules["/installation-via-rn-expo"]; - - assert.ok(rule); - assert.equal(rule.redirect.to, "/installation-via-expo"); - assert.equal(rule.redirect.status, 308); + assert.equal(rules[docsKey].redirect.status, 308); + assert.equal(rules["/troubleshooting"], undefined); }); - test("includes Ask AI compatibility redirects", () => { + test("includes docs compatibility one-off redirects", () => { const rules = buildRedirectRouteRules(); - const rootRule = rules["/ai"]; - const docsRule = rules["/docs/ai"]; - - assert.ok(rootRule); - assert.ok(docsRule); - assert.equal(rootRule.redirect.to, "/docs/support"); - assert.equal(docsRule.redirect.to, "/docs/support"); - assert.equal(rootRule.redirect.status, 308); - assert.equal(docsRule.redirect.status, 308); - }); - test("redirects root path to /docs", () => { - const rules = buildRedirectRouteRules(); - const rule = rules["/"]; - - assert.ok(rule); - assert.equal(rule.redirect.to, "/docs"); - assert.equal(rule.redirect.status, 308); + assert.equal(rules["/docs/docs/**"]?.redirect.to, "/docs/**"); + assert.equal(rules["/docs/installation-via-rn-expo"]?.redirect.to, "/docs/installation-via-expo"); + assert.equal(rules["/docs/ai"]?.redirect.to, "/docs/support"); }); - test("redirects docs-prefixed home path to dashboard", () => { + test("does not generate root compatibility redirects", () => { const rules = buildRedirectRouteRules(); - const rule = rules["/docs/home"]; - assert.ok(rule); - assert.equal(rule.redirect.to, "/docs/dashboard"); - assert.equal(rule.redirect.status, 308); + assert.equal(rules["/"], undefined); + assert.equal(rules["/ai"], undefined); + assert.equal(rules["/installation-via-rn-expo"], undefined); + assert.equal(rules["/home"], undefined); }); - test("generates folder redirects for each slug", () => { + test("generates folder redirects as docs-scoped only", () => { const rules = buildRedirectRouteRules({ folderRedirectsMap: { sdk: ["getting-started"], @@ -108,12 +77,13 @@ describe("buildRedirectRouteRules", () => { externalRedirectsMap: {}, }); - assert.ok(rules["/getting-started"]); - assert.equal(rules["/getting-started"].redirect.to, "/docs/sdk/getting-started"); - assert.equal(rules["/getting-started"].redirect.status, 308); + assert.ok(rules["/docs/getting-started"]); + assert.equal(rules["/docs/getting-started"].redirect.to, "/docs/sdk/getting-started"); + assert.equal(rules["/docs/getting-started"].redirect.status, 308); + assert.equal(rules["/getting-started"], undefined); }); - test("throws on conflicting duplicate source collisions", () => { + test("throws on docs-scoped duplicate source collisions", () => { assert.throws( () => buildRedirectRouteRules({ @@ -125,58 +95,57 @@ describe("buildRedirectRouteRules", () => { }, externalRedirectsMap: {}, }), - /Duplicate redirect source "\/same-slug"/, + /Duplicate redirect source "\/docs\/same-slug"/, ); }); - test("builds rules for every configured file and external redirect key", () => { + test("does not emit framework/internal endpoint redirects", () => { + const rules = buildRedirectRouteRules(); + const keys = Object.keys(rules); + + assert.equal(rules["/docs/_serverFn"], undefined); + assert.equal(rules["/docs/_serverFn/test"], undefined); + assert.equal(rules["/docs/api"], undefined); + assert.equal(rules["/docs/api/search"], undefined); + assert.equal(keys.some((key) => key.startsWith("/_serverFn")), false); + assert.equal(keys.some((key) => key.startsWith("/api")), false); + }); + + test("builds rules for every configured redirect map key under /docs scope", () => { const rules = buildRedirectRouteRules(); + const externalSourceKeys = new Set( + Object.keys(externalRedirectsMap).map((source) => normalizeDocsSourcePath(source)), + ); for (const [source, destination] of Object.entries(fileRedirectsMap)) { - const key = `/${source}`; - const docsKey = `/docs/${source}`; - const mappedDestination = `/docs/${destination}`; - - if (docsKey !== mappedDestination) { - assert.ok(rules[docsKey]); + const docsKey = normalizeDocsSourcePath(source); + const mappedDestination = normalizeInternalDestination(destination); + if (!externalSourceKeys.has(docsKey) && docsKey !== mappedDestination) { + assert.ok(rules[docsKey], `missing redirect for ${docsKey}`); assert.equal(rules[docsKey].redirect.status, 308); assert.equal(rules[docsKey].redirect.to, mappedDestination); } - - if (!externalRedirectsMap[source] && key !== mappedDestination) { - assert.ok(rules[key]); - assert.equal(rules[key].redirect.status, 308); - assert.equal(rules[key].redirect.to, mappedDestination); - } - } - - for (const [source, destination] of Object.entries(externalRedirectsMap)) { - const key = `/${source}`; - assert.ok(rules[key]); - assert.equal(rules[key].redirect.status, 308); - assert.equal(rules[key].redirect.to, destination); } for (const [folder, slugs] of Object.entries( folderRedirectsMap as Record, )) { for (const slug of slugs) { - const key = `/${slug}`; - const docsKey = `/docs/${slug}`; - const mappedDestination = `/docs/${folder}/${slug}`; - - if (docsKey !== mappedDestination) { - assert.ok(rules[docsKey]); + const docsKey = normalizeDocsSourcePath(slug); + const mappedDestination = normalizeInternalDestination(`${folder}/${slug}`); + if (!externalSourceKeys.has(docsKey) && docsKey !== mappedDestination) { + assert.ok(rules[docsKey], `missing redirect for ${docsKey}`); assert.equal(rules[docsKey].redirect.status, 308); assert.equal(rules[docsKey].redirect.to, mappedDestination); } - - if (!externalRedirectsMap[slug] && key !== mappedDestination) { - assert.ok(rules[key]); - assert.equal(rules[key].redirect.status, 308); - assert.equal(rules[key].redirect.to, mappedDestination); - } } } + + for (const [source, destination] of Object.entries(externalRedirectsMap)) { + const docsKey = normalizeDocsSourcePath(source); + assert.ok(rules[docsKey], `missing external redirect for ${docsKey}`); + assert.equal(rules[docsKey].redirect.status, 308); + assert.equal(rules[docsKey].redirect.to, destination); + } }); }); diff --git a/src/lib/redirect-route-rules.ts b/src/lib/redirect-route-rules.ts index 480d41a8..e70aba87 100644 --- a/src/lib/redirect-route-rules.ts +++ b/src/lib/redirect-route-rules.ts @@ -19,6 +19,8 @@ type BuildRedirectRouteRulesInput = { externalRedirectsMap?: ExternalRedirectsInput; }; +const DOCS_BASE = "/docs"; + function normalizeSourcePath(path: string): string { const trimmed = path.trim(); if (!trimmed) return "/"; @@ -26,17 +28,17 @@ function normalizeSourcePath(path: string): string { } function normalizeInternalDestination(path: string): string { - const trimmed = path.trim().replace(/^\/+/, ""); - return `/docs/${trimmed}`; + const normalized = normalizeSourcePath(path); + if (normalized === DOCS_BASE || normalized.startsWith(`${DOCS_BASE}/`)) return normalized; + if (normalized === "/") return DOCS_BASE; + return `${DOCS_BASE}${normalized}`; } -function normalizeDocsPrefixedSourcePath(path: string): string { - const trimmed = path.trim().replace(/^\/+/, ""); - if (!trimmed) return "/docs"; - if (trimmed === "docs" || trimmed.startsWith("docs/")) { - return `/${trimmed}`; - } - return `/docs/${trimmed}`; +function normalizeDocsScopedSourcePath(path: string): string { + const normalized = normalizeSourcePath(path); + if (normalized === DOCS_BASE || normalized.startsWith(`${DOCS_BASE}/`)) return normalized; + if (normalized === "/") return DOCS_BASE; + return `${DOCS_BASE}${normalized}`; } function assertNoCollision(routeRules: NitroRedirectRouteRules, source: string, to: string) { @@ -56,7 +58,7 @@ export function buildRedirectRouteRules( const routeRules: NitroRedirectRouteRules = {}; const externalSources = new Set( - Object.keys(externalMap).map((source) => normalizeSourcePath(source)), + Object.keys(externalMap).map((source) => normalizeDocsScopedSourcePath(source)), ); const addRule = (source: string, destination: string) => { @@ -71,24 +73,17 @@ export function buildRedirectRouteRules( }; const addInternalLegacyRule = (source: string, destination: string) => { - const normalizedSource = normalizeSourcePath(source); - const normalizedDestination = normalizeSourcePath(destination); - const docsSource = normalizeDocsPrefixedSourcePath(source); + const normalizedSource = normalizeDocsScopedSourcePath(source); + const normalizedDestination = normalizeInternalDestination(destination); if (!externalSources.has(normalizedSource) && normalizedSource !== normalizedDestination) { - addRule(normalizedSource, destination); - } - if (normalizeSourcePath(docsSource) !== normalizedDestination) { - addRule(docsSource, destination); + addRule(normalizedSource, normalizedDestination); } }; - // One-offs mirrored from legacy Next config semantics. - addRule("/", "/docs"); + // One-offs mirrored from legacy docs compatibility semantics. addRule("/docs/docs/**", "/docs/**"); - addRule("/installation-via-rn-expo", "/installation-via-expo"); addRule("/docs/installation-via-rn-expo", "/docs/installation-via-expo"); - addRule("/ai", "/docs/support"); addRule("/docs/ai", "/docs/support"); for (const [folder, slugs] of Object.entries(folderMap)) { @@ -102,7 +97,7 @@ export function buildRedirectRouteRules( } for (const [source, destination] of Object.entries(externalMap)) { - addRule(source, destination); + addRule(normalizeDocsScopedSourcePath(source), destination); } return routeRules; diff --git a/src/lib/url-base.test.ts b/src/lib/url-base.test.ts new file mode 100644 index 00000000..44436556 --- /dev/null +++ b/src/lib/url-base.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { buildDocsApiPath, buildDocsPath, toRouterPath } from "./url-base"; + +describe("url-base helpers", () => { + test("buildDocsPath prefixes with /docs", () => { + assert.equal(buildDocsPath(), "/docs"); + assert.equal(buildDocsPath("ios/quickstart/install"), "/docs/ios/quickstart/install"); + assert.equal(buildDocsPath("/support"), "/docs/support"); + }); + + test("buildDocsApiPath prefixes with /docs/api", () => { + assert.equal(buildDocsApiPath("search"), "/docs/api/search"); + assert.equal(buildDocsApiPath("/feedback"), "/docs/api/feedback"); + assert.equal(buildDocsApiPath(""), "/docs/api"); + }); + + test("toRouterPath strips the docs base for TanStack Link", () => { + assert.equal(toRouterPath("/docs"), "/"); + assert.equal(toRouterPath("/docs/ios"), "/ios"); + assert.equal(toRouterPath("/docs/ios?tab=1#intro"), "/ios?tab=1#intro"); + assert.equal(toRouterPath("/dashboard"), "/dashboard"); + assert.equal(toRouterPath("https://example.com"), "https://example.com"); + }); +}); diff --git a/src/lib/url-base.ts b/src/lib/url-base.ts new file mode 100644 index 00000000..b0dde47c --- /dev/null +++ b/src/lib/url-base.ts @@ -0,0 +1,31 @@ +export const DOCS_BASE = "/docs"; +const SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; + +function stripLeadingSlash(path: string): string { + return path.replace(/^\/+/, ""); +} + +export function buildDocsPath(path = ""): string { + const normalized = stripLeadingSlash(path.trim()); + return normalized ? `${DOCS_BASE}/${normalized}` : DOCS_BASE; +} + +export function buildDocsApiPath(path: string): string { + const normalized = stripLeadingSlash(path.trim()); + return normalized ? `${DOCS_BASE}/api/${normalized}` : `${DOCS_BASE}/api`; +} + +export function toRouterPath(path: string): string { + const trimmed = path.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("//") || SCHEME_PATTERN.test(trimmed)) { + return trimmed; + } + + const [pathname, suffix = ""] = trimmed.split(/([?#].*)/, 2); + if (pathname === DOCS_BASE) return `/${suffix}`; + if (pathname.startsWith(`${DOCS_BASE}/`)) { + const withoutBase = pathname.slice(DOCS_BASE.length); + return `${withoutBase || "/"}${suffix}`; + } + return trimmed; +} diff --git a/src/mdx-components.tsx b/src/mdx-components.tsx index 69f0baa6..a4878c24 100644 --- a/src/mdx-components.tsx +++ b/src/mdx-components.tsx @@ -65,6 +65,7 @@ import { GithubInfo as GithubInfoComponent } from "fumadocs-ui/components/github import { TypeTable } from "./components/type-table"; import { Mermaid } from "./components/Mermaid"; import { normalizeDocsInternalHref } from "./lib/docs-url"; +import { toRouterPath } from "./lib/url-base"; const Callout = ({ icon, @@ -275,7 +276,7 @@ const Card = ({ } return ( - + {content} ); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index d15efcc6..b7db8a12 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -13,18 +13,13 @@ import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml' import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt' import { Route as LlmsDottxtRouteImport } from './routes/llms[.]txt' import { Route as LlmsFullDottxtRouteImport } from './routes/llms-full[.]txt' +import { Route as SplatRouteImport } from './routes/$' import { Route as IndexRouteImport } from './routes/index' -import { Route as DocsIndexRouteImport } from './routes/docs/index' -import { Route as DocsSplatRouteImport } from './routes/docs/$' import { Route as ApiSearchRouteImport } from './routes/api/search' import { Route as ApiFeedbackRouteImport } from './routes/api/feedback' import { Route as Api404ReportRouteImport } from './routes/api/404-report' import { Route as LlmsDotmdxDocsSplatRouteImport } from './routes/llms[.]mdx.docs.$' -import { Route as DocsApiSearchRouteImport } from './routes/docs/api/search' -import { Route as DocsApiFeedbackRouteImport } from './routes/docs/api/feedback' -import { Route as DocsApi404ReportRouteImport } from './routes/docs/api/404-report' import { Route as ApiRawSplatRouteImport } from './routes/api/raw/$' -import { Route as DocsApiRawSplatRouteImport } from './routes/docs/api/raw/$' const SitemapDotxmlRoute = SitemapDotxmlRouteImport.update({ id: '/sitemap.xml', @@ -46,21 +41,16 @@ const LlmsFullDottxtRoute = LlmsFullDottxtRouteImport.update({ path: '/llms-full.txt', getParentRoute: () => rootRouteImport, } as any) +const SplatRoute = SplatRouteImport.update({ + id: '/$', + path: '/$', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) -const DocsIndexRoute = DocsIndexRouteImport.update({ - id: '/docs/', - path: '/docs/', - getParentRoute: () => rootRouteImport, -} as any) -const DocsSplatRoute = DocsSplatRouteImport.update({ - id: '/docs/$', - path: '/docs/$', - getParentRoute: () => rootRouteImport, -} as any) const ApiSearchRoute = ApiSearchRouteImport.update({ id: '/api/search', path: '/api/search', @@ -81,34 +71,15 @@ const LlmsDotmdxDocsSplatRoute = LlmsDotmdxDocsSplatRouteImport.update({ path: '/llms.mdx/docs/$', getParentRoute: () => rootRouteImport, } as any) -const DocsApiSearchRoute = DocsApiSearchRouteImport.update({ - id: '/docs/api/search', - path: '/docs/api/search', - getParentRoute: () => rootRouteImport, -} as any) -const DocsApiFeedbackRoute = DocsApiFeedbackRouteImport.update({ - id: '/docs/api/feedback', - path: '/docs/api/feedback', - getParentRoute: () => rootRouteImport, -} as any) -const DocsApi404ReportRoute = DocsApi404ReportRouteImport.update({ - id: '/docs/api/404-report', - path: '/docs/api/404-report', - getParentRoute: () => rootRouteImport, -} as any) const ApiRawSplatRoute = ApiRawSplatRouteImport.update({ id: '/api/raw/$', path: '/api/raw/$', getParentRoute: () => rootRouteImport, } as any) -const DocsApiRawSplatRoute = DocsApiRawSplatRouteImport.update({ - id: '/docs/api/raw/$', - path: '/docs/api/raw/$', - getParentRoute: () => rootRouteImport, -} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/$': typeof SplatRoute '/llms-full.txt': typeof LlmsFullDottxtRoute '/llms.txt': typeof LlmsDottxtRoute '/robots.txt': typeof RobotsDottxtRoute @@ -116,17 +87,12 @@ export interface FileRoutesByFullPath { '/api/404-report': typeof Api404ReportRoute '/api/feedback': typeof ApiFeedbackRoute '/api/search': typeof ApiSearchRoute - '/docs/$': typeof DocsSplatRoute - '/docs/': typeof DocsIndexRoute '/api/raw/$': typeof ApiRawSplatRoute - '/docs/api/404-report': typeof DocsApi404ReportRoute - '/docs/api/feedback': typeof DocsApiFeedbackRoute - '/docs/api/search': typeof DocsApiSearchRoute '/llms.mdx/docs/$': typeof LlmsDotmdxDocsSplatRoute - '/docs/api/raw/$': typeof DocsApiRawSplatRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/$': typeof SplatRoute '/llms-full.txt': typeof LlmsFullDottxtRoute '/llms.txt': typeof LlmsDottxtRoute '/robots.txt': typeof RobotsDottxtRoute @@ -134,18 +100,13 @@ export interface FileRoutesByTo { '/api/404-report': typeof Api404ReportRoute '/api/feedback': typeof ApiFeedbackRoute '/api/search': typeof ApiSearchRoute - '/docs/$': typeof DocsSplatRoute - '/docs': typeof DocsIndexRoute '/api/raw/$': typeof ApiRawSplatRoute - '/docs/api/404-report': typeof DocsApi404ReportRoute - '/docs/api/feedback': typeof DocsApiFeedbackRoute - '/docs/api/search': typeof DocsApiSearchRoute '/llms.mdx/docs/$': typeof LlmsDotmdxDocsSplatRoute - '/docs/api/raw/$': typeof DocsApiRawSplatRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/$': typeof SplatRoute '/llms-full.txt': typeof LlmsFullDottxtRoute '/llms.txt': typeof LlmsDottxtRoute '/robots.txt': typeof RobotsDottxtRoute @@ -153,19 +114,14 @@ export interface FileRoutesById { '/api/404-report': typeof Api404ReportRoute '/api/feedback': typeof ApiFeedbackRoute '/api/search': typeof ApiSearchRoute - '/docs/$': typeof DocsSplatRoute - '/docs/': typeof DocsIndexRoute '/api/raw/$': typeof ApiRawSplatRoute - '/docs/api/404-report': typeof DocsApi404ReportRoute - '/docs/api/feedback': typeof DocsApiFeedbackRoute - '/docs/api/search': typeof DocsApiSearchRoute '/llms.mdx/docs/$': typeof LlmsDotmdxDocsSplatRoute - '/docs/api/raw/$': typeof DocsApiRawSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/$' | '/llms-full.txt' | '/llms.txt' | '/robots.txt' @@ -173,17 +129,12 @@ export interface FileRouteTypes { | '/api/404-report' | '/api/feedback' | '/api/search' - | '/docs/$' - | '/docs/' | '/api/raw/$' - | '/docs/api/404-report' - | '/docs/api/feedback' - | '/docs/api/search' | '/llms.mdx/docs/$' - | '/docs/api/raw/$' fileRoutesByTo: FileRoutesByTo to: | '/' + | '/$' | '/llms-full.txt' | '/llms.txt' | '/robots.txt' @@ -191,17 +142,12 @@ export interface FileRouteTypes { | '/api/404-report' | '/api/feedback' | '/api/search' - | '/docs/$' - | '/docs' | '/api/raw/$' - | '/docs/api/404-report' - | '/docs/api/feedback' - | '/docs/api/search' | '/llms.mdx/docs/$' - | '/docs/api/raw/$' id: | '__root__' | '/' + | '/$' | '/llms-full.txt' | '/llms.txt' | '/robots.txt' @@ -209,18 +155,13 @@ export interface FileRouteTypes { | '/api/404-report' | '/api/feedback' | '/api/search' - | '/docs/$' - | '/docs/' | '/api/raw/$' - | '/docs/api/404-report' - | '/docs/api/feedback' - | '/docs/api/search' | '/llms.mdx/docs/$' - | '/docs/api/raw/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + SplatRoute: typeof SplatRoute LlmsFullDottxtRoute: typeof LlmsFullDottxtRoute LlmsDottxtRoute: typeof LlmsDottxtRoute RobotsDottxtRoute: typeof RobotsDottxtRoute @@ -228,14 +169,8 @@ export interface RootRouteChildren { Api404ReportRoute: typeof Api404ReportRoute ApiFeedbackRoute: typeof ApiFeedbackRoute ApiSearchRoute: typeof ApiSearchRoute - DocsSplatRoute: typeof DocsSplatRoute - DocsIndexRoute: typeof DocsIndexRoute ApiRawSplatRoute: typeof ApiRawSplatRoute - DocsApi404ReportRoute: typeof DocsApi404ReportRoute - DocsApiFeedbackRoute: typeof DocsApiFeedbackRoute - DocsApiSearchRoute: typeof DocsApiSearchRoute LlmsDotmdxDocsSplatRoute: typeof LlmsDotmdxDocsSplatRoute - DocsApiRawSplatRoute: typeof DocsApiRawSplatRoute } declare module '@tanstack/react-router' { @@ -268,6 +203,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LlmsFullDottxtRouteImport parentRoute: typeof rootRouteImport } + '/$': { + id: '/$' + path: '/$' + fullPath: '/$' + preLoaderRoute: typeof SplatRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -275,20 +217,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } - '/docs/': { - id: '/docs/' - path: '/docs' - fullPath: '/docs/' - preLoaderRoute: typeof DocsIndexRouteImport - parentRoute: typeof rootRouteImport - } - '/docs/$': { - id: '/docs/$' - path: '/docs/$' - fullPath: '/docs/$' - preLoaderRoute: typeof DocsSplatRouteImport - parentRoute: typeof rootRouteImport - } '/api/search': { id: '/api/search' path: '/api/search' @@ -317,27 +245,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LlmsDotmdxDocsSplatRouteImport parentRoute: typeof rootRouteImport } - '/docs/api/search': { - id: '/docs/api/search' - path: '/docs/api/search' - fullPath: '/docs/api/search' - preLoaderRoute: typeof DocsApiSearchRouteImport - parentRoute: typeof rootRouteImport - } - '/docs/api/feedback': { - id: '/docs/api/feedback' - path: '/docs/api/feedback' - fullPath: '/docs/api/feedback' - preLoaderRoute: typeof DocsApiFeedbackRouteImport - parentRoute: typeof rootRouteImport - } - '/docs/api/404-report': { - id: '/docs/api/404-report' - path: '/docs/api/404-report' - fullPath: '/docs/api/404-report' - preLoaderRoute: typeof DocsApi404ReportRouteImport - parentRoute: typeof rootRouteImport - } '/api/raw/$': { id: '/api/raw/$' path: '/api/raw/$' @@ -345,18 +252,12 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiRawSplatRouteImport parentRoute: typeof rootRouteImport } - '/docs/api/raw/$': { - id: '/docs/api/raw/$' - path: '/docs/api/raw/$' - fullPath: '/docs/api/raw/$' - preLoaderRoute: typeof DocsApiRawSplatRouteImport - parentRoute: typeof rootRouteImport - } } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + SplatRoute: SplatRoute, LlmsFullDottxtRoute: LlmsFullDottxtRoute, LlmsDottxtRoute: LlmsDottxtRoute, RobotsDottxtRoute: RobotsDottxtRoute, @@ -364,14 +265,8 @@ const rootRouteChildren: RootRouteChildren = { Api404ReportRoute: Api404ReportRoute, ApiFeedbackRoute: ApiFeedbackRoute, ApiSearchRoute: ApiSearchRoute, - DocsSplatRoute: DocsSplatRoute, - DocsIndexRoute: DocsIndexRoute, ApiRawSplatRoute: ApiRawSplatRoute, - DocsApi404ReportRoute: DocsApi404ReportRoute, - DocsApiFeedbackRoute: DocsApiFeedbackRoute, - DocsApiSearchRoute: DocsApiSearchRoute, LlmsDotmdxDocsSplatRoute: LlmsDotmdxDocsSplatRoute, - DocsApiRawSplatRoute: DocsApiRawSplatRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/docs/$.tsx b/src/routes/$.tsx similarity index 97% rename from src/routes/docs/$.tsx rename to src/routes/$.tsx index 578fc555..311cc7e1 100644 --- a/src/routes/docs/$.tsx +++ b/src/routes/$.tsx @@ -20,6 +20,7 @@ import { SITE_NAME, TWITTER_HANDLE, } from "@/lib/metadata"; +import { buildDocsApiPath } from "@/lib/url-base"; type DocsLoaderData = { url: string; @@ -31,7 +32,7 @@ type DocsLoaderData = { pageTree: Awaited>; }; -export const Route = createFileRoute("/docs/$")({ +export const Route = createFileRoute("/$")({ component: Page, loader: async ({ params }) => { const slugs = params._splat?.split("/") ?? []; @@ -112,7 +113,7 @@ const clientLoader = browserCollections.docs.createClientLoader({ ) { async function onSendFeedback(feedback: PageFeedback): Promise { try { - await fetch("/docs/api/feedback", { + await fetch(buildDocsApiPath("feedback"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(feedback), diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index b291f58d..7705b54e 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { createRootRoute, HeadContent, Outlet, Scripts } from "@tanstack/react-router"; +import { Link, createRootRoute, HeadContent, Outlet, Scripts } from "@tanstack/react-router"; import * as React from "react"; import appCss from "@/styles/app.css?url"; import { RootProvider } from "fumadocs-ui/provider/tanstack"; @@ -14,6 +14,31 @@ import { SITE_NAME, TWITTER_HANDLE, } from "@/lib/metadata"; +import { toRouterPath } from "@/lib/url-base"; + +type RootProviderLinkProps = React.ComponentProps<"a"> & { + href: string; + prefetch?: boolean; +}; + +function DocsLink({ href, prefetch = true, ...props }: RootProviderLinkProps) { + const normalizedTo = toRouterPath(href); + + if ( + !normalizedTo || + normalizedTo.startsWith("#") || + normalizedTo.startsWith("//") || + /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(normalizedTo) + ) { + return ; + } + + return ( + + {props.children} + + ); +} export const Route = createRootRoute({ head: () => ({ @@ -123,6 +148,9 @@ function RootDocument({ children }: { children: React.ReactNode }) { Ask AI handle404ReportPost(request), - }, - }, -}); diff --git a/src/routes/docs/api/feedback.ts b/src/routes/docs/api/feedback.ts deleted file mode 100644 index 4e47d87a..00000000 --- a/src/routes/docs/api/feedback.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { handleFeedbackPost } from "@/routes/api/feedback"; - -export const Route = createFileRoute("/docs/api/feedback")({ - server: { - handlers: { - POST: ({ request }) => handleFeedbackPost(request), - }, - }, -}); diff --git a/src/routes/docs/api/raw/$.ts b/src/routes/docs/api/raw/$.ts deleted file mode 100644 index a379f7f5..00000000 --- a/src/routes/docs/api/raw/$.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { handleRawMarkdownGet } from "@/routes/api/raw/$"; - -export const Route = createFileRoute("/docs/api/raw/$")({ - server: { - handlers: { - GET: ({ params }) => handleRawMarkdownGet(params._splat), - }, - }, -}); diff --git a/src/routes/docs/api/search.ts b/src/routes/docs/api/search.ts deleted file mode 100644 index 27ffe759..00000000 --- a/src/routes/docs/api/search.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { handleSearchGet } from "@/routes/api/search"; - -export const Route = createFileRoute("/docs/api/search")({ - server: { - handlers: { - GET: ({ request }) => handleSearchGet(request), - }, - }, -}); diff --git a/src/routes/docs/index.tsx b/src/routes/docs/index.tsx deleted file mode 100644 index 0a0c9895..00000000 --- a/src/routes/docs/index.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { createFileRoute, Link } from "@tanstack/react-router"; -import { HomeLayout } from "fumadocs-ui/layouts/home"; -import { baseOptions } from "@/lib/layout.shared"; -import { - PanelsTopLeft, - ShoppingBag, - Puzzle, - CircleHelp, - History, - AlertTriangle, - Users, -} from "lucide-react"; -import type { SVGProps, ReactNode } from "react"; -import { - buildCanonicalUrl, - DEFAULT_DESCRIPTION, - DEFAULT_SOCIAL_IMAGE_URL, - SITE_NAME, - TWITTER_HANDLE, -} from "@/lib/metadata"; - -export const Route = createFileRoute("/docs/")({ - component: DocsOverview, - head: () => { - const title = SITE_NAME; - const description = DEFAULT_DESCRIPTION; - const canonicalUrl = buildCanonicalUrl("/docs"); - - return { - meta: [ - { title }, - { name: "description", content: description }, - { property: "og:type", content: "website" }, - { property: "og:site_name", content: SITE_NAME }, - { property: "og:title", content: title }, - { property: "og:description", content: description }, - { property: "og:url", content: canonicalUrl }, - { property: "og:image", content: DEFAULT_SOCIAL_IMAGE_URL }, - { name: "twitter:card", content: "summary_large_image" }, - { name: "twitter:site", content: TWITTER_HANDLE }, - { name: "twitter:creator", content: TWITTER_HANDLE }, - { name: "twitter:title", content: title }, - { name: "twitter:description", content: description }, - { name: "twitter:image", content: DEFAULT_SOCIAL_IMAGE_URL }, - ], - links: [{ rel: "canonical", href: canonicalUrl }], - }; - }, -}); - -// ---- Custom SVG icons (matching source.ts) ---- - -function AppleIcon(props: SVGProps) { - return ( - - - - - ); -} - -function AndroidIcon(props: SVGProps) { - return ( - - - - ); -} - -function FlutterIcon(props: SVGProps) { - return ( - - - - ); -} - -function ExpoIcon(props: SVGProps) { - return ( - - - - ); -} - -// ---- Section data ---- - -interface DocCard { - title: string; - description: string; - href: string; - icon: ReactNode; -} - -const docsCards: DocCard[] = [ - { - title: "Dashboard", - description: - "Create paywalls, manage campaigns, view analytics, and configure your app settings.", - href: "/docs/dashboard", - icon: , - }, - { - title: "Web Checkout", - description: "Let customers purchase products online via Stripe, then link them to your app.", - href: "/docs/web-checkout", - icon: , - }, - { - title: "Integrations", - description: "Connect webhooks, analytics, and third-party services to Superwall.", - href: "/docs/integrations", - icon: , - }, - { - title: "Support", - description: "FAQs, troubleshooting guides, and help for common issues.", - href: "/docs/support", - icon: , - }, - { - title: "Changelog", - description: "Track recent updates and additions to Superwall.", - href: "/docs/changelog", - icon: , - }, -]; - -const sdkCards: DocCard[] = [ - { - title: "iOS", - description: "Integrate Superwall into your Swift or Objective-C app.", - href: "/docs/ios", - icon: , - }, - { - title: "Android", - description: "Integrate Superwall into your Kotlin or Java app.", - href: "/docs/android", - icon: , - }, - { - title: "Expo", - description: "Integrate Superwall into your Expo app with React Native.", - href: "/docs/expo", - icon: , - }, - { - title: "Flutter", - description: "Integrate Superwall into your Flutter app with Dart.", - href: "/docs/flutter", - icon: , - }, - { - title: "React Native (Legacy)", - description: "Legacy SDK for React Native. Migrate to the Expo SDK for new projects.", - href: "/docs/react-native", - icon: , - }, - { - title: "Community", - description: "Community-maintained SDKs for additional platforms.", - href: "/docs/community", - icon: , - }, -]; - -// ---- Components ---- - -function OverviewCard({ title, description, href, icon }: DocCard) { - return ( - -
-
- {icon} -
-

{title}

-
-

{description}

- - ); -} - -function DocsOverview() { - return ( - -
- {/* Hero */} -
-

- Superwall Documentation -

-

- Everything you need to integrate paywalls, manage subscriptions, and grow your revenue. -

-
- - {/* Docs section */} -
-

- Documentation -

-
- {docsCards.map((card) => ( - - ))} -
-
- - {/* SDKs section */} -
-

- SDKs -

-
- {sdkCards.map((card) => ( - - ))} -
-
-
-
- ); -} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index ec0b8a4b..fd35f715 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,12 +1,224 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { HomeLayout } from "fumadocs-ui/layouts/home"; +import { baseOptions } from "@/lib/layout.shared"; +import { + PanelsTopLeft, + ShoppingBag, + Puzzle, + CircleHelp, + History, + AlertTriangle, + Users, +} from "lucide-react"; +import type { SVGProps, ReactNode } from "react"; +import { + buildCanonicalUrl, + DEFAULT_DESCRIPTION, + DEFAULT_SOCIAL_IMAGE_URL, + SITE_NAME, + TWITTER_HANDLE, +} from "@/lib/metadata"; +import { buildDocsPath, toRouterPath } from "@/lib/url-base"; export const Route = createFileRoute("/")({ - beforeLoad: () => { - throw redirect({ to: "/docs" }); + component: DocsOverview, + head: () => { + const title = SITE_NAME; + const description = DEFAULT_DESCRIPTION; + const canonicalUrl = buildCanonicalUrl("/docs"); + + return { + meta: [ + { title }, + { name: "description", content: description }, + { property: "og:type", content: "website" }, + { property: "og:site_name", content: SITE_NAME }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:url", content: canonicalUrl }, + { property: "og:image", content: DEFAULT_SOCIAL_IMAGE_URL }, + { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:site", content: TWITTER_HANDLE }, + { name: "twitter:creator", content: TWITTER_HANDLE }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: description }, + { name: "twitter:image", content: DEFAULT_SOCIAL_IMAGE_URL }, + ], + links: [{ rel: "canonical", href: canonicalUrl }], + }; }, - component: IndexRedirectFallback, }); -function IndexRedirectFallback() { - return null; +function AppleIcon(props: SVGProps) { + return ( + + + + + ); +} + +function AndroidIcon(props: SVGProps) { + return ( + + + + ); +} + +function FlutterIcon(props: SVGProps) { + return ( + + + + ); +} + +function ExpoIcon(props: SVGProps) { + return ( + + + + ); +} + +interface DocCard { + title: string; + description: string; + href: string; + icon: ReactNode; +} + +const docsCards: DocCard[] = [ + { + title: "Dashboard", + description: + "Create paywalls, manage campaigns, view analytics, and configure your app settings.", + href: buildDocsPath("dashboard"), + icon: , + }, + { + title: "Web Checkout", + description: "Let customers purchase products online via Stripe, then link them to your app.", + href: buildDocsPath("web-checkout"), + icon: , + }, + { + title: "Integrations", + description: "Connect webhooks, analytics, and third-party services to Superwall.", + href: buildDocsPath("integrations"), + icon: , + }, + { + title: "Support", + description: "FAQs, troubleshooting guides, and help for common issues.", + href: buildDocsPath("support"), + icon: , + }, + { + title: "Changelog", + description: "Track recent updates and additions to Superwall.", + href: buildDocsPath("changelog"), + icon: , + }, +]; + +const sdkCards: DocCard[] = [ + { + title: "iOS", + description: "Integrate Superwall into your Swift or Objective-C app.", + href: buildDocsPath("ios"), + icon: , + }, + { + title: "Android", + description: "Integrate Superwall into your Kotlin or Java app.", + href: buildDocsPath("android"), + icon: , + }, + { + title: "Expo", + description: "Integrate Superwall into your Expo app with React Native.", + href: buildDocsPath("expo"), + icon: , + }, + { + title: "Flutter", + description: "Integrate Superwall into your Flutter app with Dart.", + href: buildDocsPath("flutter"), + icon: , + }, + { + title: "React Native (Legacy)", + description: "Legacy SDK for React Native. Migrate to the Expo SDK for new projects.", + href: buildDocsPath("react-native"), + icon: , + }, + { + title: "Community", + description: "Community-maintained SDKs for additional platforms.", + href: buildDocsPath("community"), + icon: , + }, +]; + +function OverviewCard({ title, description, href, icon }: DocCard) { + return ( + +
+
+ {icon} +
+

{title}

+
+

{description}

+ + ); +} + +function DocsOverview() { + return ( + +
+
+

+ Superwall Documentation +

+

+ Everything you need to integrate paywalls, manage subscriptions, and grow your revenue. +

+
+ +
+

+ Documentation +

+
+ {docsCards.map((card) => ( + + ))} +
+
+ +
+

+ SDKs +

+
+ {sdkCards.map((card) => ( + + ))} +
+
+
+
+ ); } diff --git a/src/start.ts b/src/start.ts index 1778e23d..91837ae2 100644 --- a/src/start.ts +++ b/src/start.ts @@ -2,81 +2,72 @@ import { createMiddleware, createStart } from "@tanstack/react-start"; import { rewritePath } from "fumadocs-core/negotiation"; import { redirect } from "@tanstack/react-router"; import { buildRedirectRouteRules } from "./lib/redirect-route-rules"; +import { DOCS_BASE } from "./lib/url-base"; -const { rewrite: rewriteLLM } = rewritePath("/docs{/*path}.mdx", "/llms.mdx/docs{/*path}"); +const { rewrite: rewriteLLM } = rewritePath( + `${DOCS_BASE}{/*path}.mdx`, + `${DOCS_BASE}/llms.mdx/docs{/*path}`, +); const legacyRedirectRules = buildRedirectRouteRules(); +const redirectBypassPrefixes = [`${DOCS_BASE}/_serverFn`, `${DOCS_BASE}/api`, `${DOCS_BASE}/assets`]; +const staticAssetPathPattern = /\.[a-z0-9]{2,8}$/i; -function normalizeRequestPath(pathname: string): string { - const trimmed = pathname.trim(); - if (!trimmed || trimmed === "/") return "/"; - return `/${trimmed.replace(/^\/+/, "").replace(/\/+$/, "")}`; +function normalizePathname(pathname: string): string { + const normalized = pathname.replace(/\/+$/, ""); + return normalized || "/"; } -function wouldSelfRedirect(normalizedPath: string, destination: string): boolean { - const normalizedDestinationPath = normalizeRequestPath(new URL(destination, "https://example.com").pathname); - const docsStrippedDestinationPath = - normalizedDestinationPath === "/docs" - ? "/" - : normalizedDestinationPath.startsWith("/docs/") - ? normalizedDestinationPath.slice("/docs".length) - : normalizedDestinationPath; - - return ( - normalizedPath !== "/" && - (normalizedDestinationPath === normalizedPath || docsStrippedDestinationPath === normalizedPath) - ); +function isSelfRedirect(requestUrl: URL, destination: string): boolean { + const targetUrl = new URL(destination, requestUrl); + if (targetUrl.origin !== requestUrl.origin) return false; + return normalizePathname(requestUrl.pathname) === normalizePathname(targetUrl.pathname); } -function resolveDeprecatedSdkPath(normalizedPath: string): string | undefined { - if (normalizedPath === "/sdk" || normalizedPath === "/docs/sdk") { - return "/docs/ios"; +function shouldBypassLegacyRedirect(pathname: string): boolean { + if (!pathname.startsWith(DOCS_BASE)) return true; + if (redirectBypassPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))) { + return true; } + return staticAssetPathPattern.test(pathname); +} - if (normalizedPath.startsWith("/sdk/")) { - return `/docs/ios/${normalizedPath.slice("/sdk/".length)}`; +function resolveDeprecatedSdkPath(normalizedPath: string): string | undefined { + if (normalizedPath === `${DOCS_BASE}/sdk`) { + return `${DOCS_BASE}/ios`; } - if (normalizedPath.startsWith("/docs/sdk/")) { - return `/docs/ios/${normalizedPath.slice("/docs/sdk/".length)}`; + if (normalizedPath.startsWith(`${DOCS_BASE}/sdk/`)) { + return `${DOCS_BASE}/ios/${normalizedPath.slice(`${DOCS_BASE}/sdk/`.length)}`; } return undefined; } -function resolveLegacyRedirect(pathname: string): string | undefined { - if (pathname === "/docs/docs") return "/docs"; - if (pathname.startsWith("/docs/docs/")) { - return pathname.replace(/^\/docs\/docs/, "/docs"); - } +function resolveLegacyRedirect(requestUrl: URL): string | undefined { + if (shouldBypassLegacyRedirect(requestUrl.pathname)) return undefined; - const normalizedPath = normalizeRequestPath(pathname); + const normalizedPath = normalizePathname(requestUrl.pathname); const sdkFallback = resolveDeprecatedSdkPath(normalizedPath); - if (sdkFallback && !wouldSelfRedirect(normalizedPath, sdkFallback)) { + if (sdkFallback && !isSelfRedirect(requestUrl, sdkFallback)) { return sdkFallback; } const direct = legacyRedirectRules[normalizedPath]?.redirect.to; - if (direct && !wouldSelfRedirect(normalizedPath, direct)) return direct; - - // Also try stripping /docs prefix for links rendered with the base URL - if (normalizedPath.startsWith("/docs/")) { - const withoutDocs = normalizedPath.replace(/^\/docs/, ""); - const fallback = legacyRedirectRules[withoutDocs]?.redirect.to; - if (fallback && !wouldSelfRedirect(normalizedPath, fallback)) return fallback; - } + if (direct && !isSelfRedirect(requestUrl, direct)) return direct; return undefined; } const legacyRedirectMiddleware = createMiddleware().server(({ next, request }) => { const url = new URL(request.url); - const destination = resolveLegacyRedirect(url.pathname); + const destination = resolveLegacyRedirect(url); if (destination) { const targetUrl = new URL(destination, url); if (!targetUrl.search && url.search) { targetUrl.search = url.search; } + throw redirect({ href: targetUrl.toString(), statusCode: 308, @@ -91,7 +82,10 @@ const llmMiddleware = createMiddleware().server(({ next, request }) => { const path = rewriteLLM(url.pathname); if (path) { - throw redirect(new URL(path, url)); + throw redirect({ + href: new URL(path, url).toString(), + statusCode: 307, + }); } return next(); diff --git a/vite.config.ts b/vite.config.ts index 8c3f3f4d..51bce371 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -29,12 +29,11 @@ export default defineConfig({ viteEnvironment: { name: "ssr" }, }), tanstackStart({ - serverFns: { - base: "/docs/_serverFn", + router: { + basepath: "/docs", }, prerender: { enabled: true, - filter: (page) => page.path.startsWith("/docs/"), }, }), react(), From 2f8ae8798bf4054e098ad98b678d222d20eff158 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Mar 2026 11:58:54 +0100 Subject: [PATCH 2/3] fix --- package.json | 3 ++- src/start.ts | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 935583a6..6b2b1e93 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,12 @@ "prebuild": "bun run generate:changelog && bun run scripts/copy-docs-images.cjs", "build": "NODE_OPTIONS=--max-old-space-size=8192 vite build", "build:cf": "bun run build", + "build:cf:staging": "CLOUDFLARE_ENV=staging vite build", "sync:mixedbread": "mxbai vs sync $MIXEDBREAD_STORE_ID './content/docs' --ci", "preview": "vite preview", "preview:cf": "bun run build:cf && vite preview", "deploy": "bun run build:cf && wrangler deploy", - "deploy:staging": "bun run build:cf && wrangler deploy --env staging", + "deploy:staging": "bun run build:cf:staging && wrangler deploy", "cf:typegen": "wrangler types", "types:check": "fumadocs-mdx && tsc --noEmit", "postinstall": "fumadocs-mdx", diff --git a/src/start.ts b/src/start.ts index 91837ae2..ddad76e5 100644 --- a/src/start.ts +++ b/src/start.ts @@ -60,7 +60,13 @@ function resolveLegacyRedirect(requestUrl: URL): string | undefined { const legacyRedirectMiddleware = createMiddleware().server(({ next, request }) => { const url = new URL(request.url); - const destination = resolveLegacyRedirect(url); + let destination: string | undefined; + + try { + destination = resolveLegacyRedirect(url); + } catch { + return next(); + } if (destination) { const targetUrl = new URL(destination, url); @@ -79,7 +85,13 @@ const legacyRedirectMiddleware = createMiddleware().server(({ next, request }) = const llmMiddleware = createMiddleware().server(({ next, request }) => { const url = new URL(request.url); - const path = rewriteLLM(url.pathname); + let path: string | null; + + try { + path = rewriteLLM(url.pathname); + } catch { + return next(); + } if (path) { throw redirect({ From 90bd7a433cd2d9fbcc4f71bd2d513f4eb7687c0e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Mar 2026 12:05:25 +0100 Subject: [PATCH 3/3] fix --- package.json | 2 +- vite.config.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 6b2b1e93..382bedec 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "prebuild": "bun run generate:changelog && bun run scripts/copy-docs-images.cjs", "build": "NODE_OPTIONS=--max-old-space-size=8192 vite build", "build:cf": "bun run build", - "build:cf:staging": "CLOUDFLARE_ENV=staging vite build", + "build:cf:staging": "NODE_OPTIONS=--max-old-space-size=8192 CLOUDFLARE_ENV=staging vite build", "sync:mixedbread": "mxbai vs sync $MIXEDBREAD_STORE_ID './content/docs' --ci", "preview": "vite preview", "preview:cf": "bun run build:cf && vite preview", diff --git a/vite.config.ts b/vite.config.ts index 51bce371..4c32255a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ }, prerender: { enabled: true, + concurrency: 3, }, }), react(),