diff --git a/scripts/sync-docs-webhooks.ts b/scripts/sync-docs-webhooks.ts index 2ba0944b4..b9fc9e475 100644 --- a/scripts/sync-docs-webhooks.ts +++ b/scripts/sync-docs-webhooks.ts @@ -29,6 +29,14 @@ if (!dryRun && !webhookSecret) { ) } +if (!dryRun && webhookSecret) { + if (webhookSecret.length < 16 || /\s/.test(webhookSecret)) { + throw new Error( + 'GITHUB_WEBHOOK_SECRET must be a raw secret value, not Netlify env:get output for a secret variable.', + ) + } +} + function runGh(args: Array) { return execFileSync('gh', args, { cwd: process.cwd(), diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index de9497473..eb73a9cb9 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -33,6 +33,12 @@ import { SearchButton } from './SearchButton' import { libraries, SIDEBAR_LIBRARY_IDS, type LibrarySlim } from '~/libraries' import { useClickOutside } from '~/hooks/useClickOutside' import { GithubIcon } from '~/components/icons/GithubIcon' +import { + Dropdown, + DropdownContent, + DropdownItem, + DropdownTrigger, +} from '~/components/Dropdown' import { DiscordIcon } from '~/components/icons/DiscordIcon' import { InstagramIcon } from '~/components/icons/InstagramIcon' import { BSkyIcon } from '~/components/icons/BSkyIcon' @@ -216,40 +222,7 @@ export function Navbar({ children }: { children: React.ReactNode }) { ) - const socialLinks = ( -
- - - - - - - - - - - - - - - - - - -
- ) + const socialLinks = const navbar = (
) } + +const SOCIAL_LINKS = [ + { + label: 'GitHub', + href: 'https://github.com/TanStack', + Icon: GithubIcon, + }, + { + label: 'Discord', + href: 'https://tlinz.com/discord', + Icon: DiscordIcon, + }, + { + label: 'YouTube', + href: 'https://youtube.com/@tan_stack', + Icon: YouTubeIcon, + }, + { + label: 'X (Twitter)', + href: 'https://x.com/tan_stack', + Icon: BrandXIcon, + }, + { + label: 'Bluesky', + href: 'https://bsky.app/profile/tanstack.com', + Icon: BSkyIcon, + }, + { + label: 'Instagram', + href: 'https://instagram.com/tan_stack', + Icon: InstagramIcon, + }, +] as const + +function SocialStack() { + const stackTop = SOCIAL_LINKS.slice(0, 3) + + return ( + + + + + + {SOCIAL_LINKS.map(({ label, href, Icon }) => ( + + + + {label} + + + ))} + + + ) +} diff --git a/src/components/NotFound.tsx b/src/components/NotFound.tsx index 34c8c5106..4ca66284b 100644 --- a/src/components/NotFound.tsx +++ b/src/components/NotFound.tsx @@ -1,26 +1,446 @@ -import { Link } from '@tanstack/react-router' -import { Ghost } from 'lucide-react' +import * as React from 'react' +import { useRouterState } from '@tanstack/react-router' +import { + ArrowRight, + BookOpen, + Boxes, + Compass, + HelpCircle, + Home, + LifeBuoy, + Newspaper, + ShoppingBag, + Sparkles, + type LucideIcon, +} from 'lucide-react' +import { twMerge } from 'tailwind-merge' +import { libraries, type LibrarySlim } from '~/libraries' import { Button } from '~/ui' -export function NotFound({ children }: { children?: any }) { +type Accent = 'amber' | 'blue' | 'cyan' | 'emerald' | 'gray' | 'rose' + +type NotFoundDestination = { + key: string + label: string + description: string + href: string + icon: LucideIcon + accent: Accent + score: number +} + +const accentStyles: Record = { + amber: + 'border-amber-500/30 bg-amber-500/10 text-amber-700 hover:border-amber-500/60 dark:text-amber-300', + blue: 'border-blue-500/30 bg-blue-500/10 text-blue-700 hover:border-blue-500/60 dark:text-blue-300', + cyan: 'border-cyan-500/30 bg-cyan-500/10 text-cyan-700 hover:border-cyan-500/60 dark:text-cyan-300', + emerald: + 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 hover:border-emerald-500/60 dark:text-emerald-300', + gray: 'border-gray-300 bg-gray-100 text-gray-700 hover:border-gray-400 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-500', + rose: 'border-rose-500/30 bg-rose-500/10 text-rose-700 hover:border-rose-500/60 dark:text-rose-300', +} + +const fallbackDestinations: NotFoundDestination[] = [ + { + key: 'libraries', + label: 'All libraries', + description: 'Browse TanStack Query, Router, Table, Start, and more.', + href: '/libraries', + icon: Boxes, + accent: 'emerald', + score: 1, + }, + { + key: 'blog', + label: 'Blog', + description: 'Read release notes, deep dives, and project updates.', + href: '/blog', + icon: Newspaper, + accent: 'cyan', + score: 1, + }, + { + key: 'support', + label: 'Support', + description: 'Find paid support, community help, and project resources.', + href: '/support', + icon: LifeBuoy, + accent: 'amber', + score: 1, + }, +] + +const sectionDestinations: NotFoundDestination[] = [ + { + key: 'shop', + label: 'Shop', + description: 'Find TanStack merch and products.', + href: '/shop', + icon: ShoppingBag, + accent: 'rose', + score: 0, + }, + { + key: 'showcase', + label: 'Showcase', + description: 'Explore projects built with TanStack.', + href: '/showcase', + icon: Sparkles, + accent: 'cyan', + score: 0, + }, + { + key: 'explore', + label: 'Explore', + description: 'Sail through the TanStack ecosystem.', + href: '/explore', + icon: Compass, + accent: 'blue', + score: 0, + }, +] + +function safeDecode(value: string) { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +function getPathTokens(pathname: string) { + return new Set( + safeDecode(pathname) + .toLowerCase() + .split(/[^a-z0-9]+/g) + .filter(Boolean), + ) +} + +function compact(value: string) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, '') +} + +function getLibraryAliases(library: LibrarySlim) { + const aliases = [ + library.id, + library.name.replace(/^TanStack\s+/i, ''), + library.repo.split('/').at(-1) ?? '', + library.corePackageName ?? '', + ...(library.legacyPackages ?? []), + ] + + return aliases + .flatMap((alias) => alias.split(/[^a-zA-Z0-9]+/g)) + .map((alias) => alias.toLowerCase()) + .filter((alias) => alias.length > 1 && alias !== 'tanstack') +} + +function scoreLibraryMatch(library: LibrarySlim, pathname: string) { + const tokens = getPathTokens(pathname) + const compactPath = compact(pathname) + const aliases = getLibraryAliases(library) + let score = 0 + + for (const alias of aliases) { + if (tokens.has(alias)) { + score += 8 + continue + } + + if (alias.length > 3 && compactPath.includes(compact(alias))) { + score += 3 + } + } + + return score +} + +function getLibraryDestinations(pathname: string) { + return libraries + .filter((library) => library.visible !== false) + .map((library) => ({ + library, + score: scoreLibraryMatch(library, pathname), + })) + .filter((match) => match.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 2) + .flatMap(({ library, score }) => { + const destinations: NotFoundDestination[] = [] + + if (library.to) { + destinations.push({ + key: `library-${library.id}`, + label: library.name, + description: library.tagline, + href: library.to, + icon: BookOpen, + accent: 'emerald', + score: score + 2, + }) + } + + if (library.latestVersion) { + destinations.push({ + key: `library-docs-${library.id}`, + label: `${library.name.replace(/^TanStack\s+/i, '')} docs`, + description: 'Open the current docs for this project.', + href: `/${library.id}/latest/docs/${library.defaultDocs ?? 'overview'}`, + icon: BookOpen, + accent: 'cyan', + score: score + 1, + }) + } + + return destinations + }) +} + +function scoreSectionDestination( + destination: NotFoundDestination, + pathname: string, +) { + const tokens = getPathTokens(pathname) + const keywordScores: Record = { + blog: 8, + post: 4, + article: 4, + docs: 5, + guide: 3, + library: 5, + libraries: 6, + package: 3, + npm: 4, + shop: 8, + cart: 4, + product: 4, + merch: 6, + showcase: 8, + example: 4, + examples: 4, + explore: 8, + support: 8, + help: 6, + partner: 5, + partners: 5, + } + + let score = destination.score + for (const [keyword, value] of Object.entries(keywordScores)) { + if (!tokens.has(keyword)) continue + + if ( + destination.key.includes(keyword) || + destination.label.toLowerCase().includes(keyword) || + destination.description.toLowerCase().includes(keyword) + ) { + score += value + } + } + + return score +} + +function getSectionDestinations(pathname: string) { + const docsDestination: NotFoundDestination = { + key: 'docs', + label: 'Docs', + description: 'Pick a library, then jump into its current documentation.', + href: '/libraries', + icon: BookOpen, + accent: 'emerald', + score: 0, + } + + const helpDestination: NotFoundDestination = { + key: 'help', + label: 'Support', + description: 'Get help when the missing page was supposed to unblock you.', + href: '/support', + icon: HelpCircle, + accent: 'amber', + score: 0, + } + + return [docsDestination, helpDestination, ...sectionDestinations] + .map((destination) => ({ + ...destination, + score: scoreSectionDestination(destination, pathname), + })) + .filter((destination) => destination.score > 0) +} + +function getSuggestedDestinations(pathname: string) { + const destinations = [ + ...getLibraryDestinations(pathname), + ...getSectionDestinations(pathname), + ...fallbackDestinations, + ] + + const deduped = new Map() + + for (const destination of destinations) { + const existing = deduped.get(destination.href) + if (!existing || destination.score > existing.score) { + deduped.set(destination.href, destination) + } + } + + return Array.from(deduped.values()) + .sort((a, b) => b.score - a.score) + .slice(0, 5) +} + +function DestinationCard({ + destination, +}: { + destination: NotFoundDestination +}) { + const Icon = destination.icon + + return ( + + + + + + + {destination.label} + + + {destination.description} + + + + + ) +} + +export function NotFound({ children }: { children?: React.ReactNode }) { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }) + const decodedPathname = safeDecode(pathname) + const suggestions = React.useMemo( + () => getSuggestedDestinations(pathname), + [pathname], + ) + return ( -
-
-

- 404 Not Found -

- -

The page you are looking for does not exist.

- {children || ( -

- + - -

- )} +
+ + {children ?
{children}
: null} + + +
+
+ + +
+

+ Best matches +

+
+ {suggestions.map((suggestion) => ( + + ))} +
+
+
) diff --git a/src/components/stack/CategoryArticle.tsx b/src/components/stack/CategoryArticle.tsx new file mode 100644 index 000000000..a1b97838f --- /dev/null +++ b/src/components/stack/CategoryArticle.tsx @@ -0,0 +1,320 @@ +import * as React from 'react' +import { Link } from '@tanstack/react-router' +import { twMerge } from 'tailwind-merge' +import { + ArrowRight, + Award, + ChevronRight, + Layers, + Newspaper, + Sparkles, +} from 'lucide-react' +import { GitHub } from '~/ui' + +import type { LibrarySlim } from '~/libraries' +import { formatPublishedDate, getPostsForLibrary } from '~/utils/blog' +import { + categoryMeta, + getCategoryLibraries, + type CategorySlug, +} from './stack-categories' + +export function CategoryArticle({ slug }: { slug: CategorySlug }) { + const meta = categoryMeta[slug] + const libraries = getCategoryLibraries(slug) + const topPick = + libraries.find((lib) => lib.id === meta.topPickId) ?? libraries[0] + const relatedPosts = libraries + .flatMap((lib) => getPostsForLibrary(lib.id).map((p) => ({ post: p, lib }))) + .slice(0, 4) + + return ( +
+ {/* Breadcrumb */} +
+
+ + Home + + + + Libraries + + + + {meta.name} + +
+
+ + {/* Hero */} +
+
+

+ {meta.shortName} +

+

+ {meta.headline} +

+

+ {meta.intro} +

+
+
+ + {/* Body */} +
+
+ + + {relatedPosts.length > 0 && ( + + )} +
+
+
+ ) +} + +function TopPickBlock({ library }: { library: LibrarySlim }) { + return ( +
+ + Where to start + +

+ Start with {library.name} +

+ +
+
+

+ TanStack +

+

+ {library.name.replace('TanStack ', '')} +

+

+ {library.tagline} +

+
+ + Open the library + +
+
+
+ {library.description} +
+ + +
+ {library.frameworks.slice(0, 6).map((fw) => ( + + ))} + {library.frameworks.length > 6 && ( + + + {library.frameworks.length - 6} more frameworks + + )} + + tanstack/ + {library.repo?.split('/').pop()} + +
+
+ ) +} + +function FullListBlock({ + libraries, + topPickId, +}: { + libraries: LibrarySlim[] + topPickId: string +}) { + return ( +
+ + The full list + +

+ Every library in this category +

+
    + {libraries.map((lib, i) => ( + + ))} +
+
+ ) +} + +function LibraryEntry({ + library, + rank, + isTopPick, +}: { + library: LibrarySlim + rank: number + isTopPick: boolean +}) { + return ( +
  • +
    +
    + {rank} +
    +
    +
    +

    + TanStack +

    +

    + {library.name.replace('TanStack ', '')} +

    + {library.badge && ( + + {library.badge} + + )} + {isTopPick && ( + + Where to start + + )} +
    +

    + {library.tagline} +

    +

    + {library.description} +

    + +
    + {library.frameworks.slice(0, 5).map((fw) => ( + + ))} + {library.frameworks.length > 5 && ( + + + {library.frameworks.length - 5} more + + )} + + Open {library.name.replace('TanStack ', '')}{' '} + + +
    +
    +
    +
  • + ) +} + +function RelatedPostsBlock({ + items, +}: { + items: Array<{ + post: { slug: string; title: string; published: string; excerpt?: string } + lib: LibrarySlim + }> +}) { + return ( +
    + + From the team + +

    + Recent writing tagged with this category +

    +
      + {items.map(({ post, lib }) => ( +
    • + + + {lib.name.replace('TanStack ', '')} + +
      +

      + {post.title} +

      +

      + {formatPublishedDate(post.published)} +

      +
      + + +
    • + ))} +
    +
    + ) +} + +function SectionEyebrow({ children }: { children: React.ReactNode }) { + return ( +

    + {children} +

    + ) +} + +function FrameworkChip({ label }: { label: string }) { + return ( + + {label} + + ) +} diff --git a/src/components/stack/stack-categories.ts b/src/components/stack/stack-categories.ts new file mode 100644 index 000000000..0a9052b03 --- /dev/null +++ b/src/components/stack/stack-categories.ts @@ -0,0 +1,123 @@ +import { librariesByGroup, librariesGroupNamesMap } from '~/libraries' +import type { LibrarySlim } from '~/libraries' + +export type GroupId = keyof typeof librariesByGroup + +export type CategorySlug = + | 'framework' + | 'state' + | 'ui' + | 'performance' + | 'tooling' + +export const slugToGroup: Record = { + framework: 'framework', + state: 'state', + ui: 'headlessUI', + performance: 'performance', + tooling: 'tooling', +} + +export const groupToSlug: Record = { + framework: 'framework', + state: 'state', + headlessUI: 'ui', + performance: 'performance', + tooling: 'tooling', +} + +export const categorySlugs = Object.keys(slugToGroup) as CategorySlug[] + +export type CategoryMeta = { + slug: CategorySlug + groupId: GroupId + name: string + shortName: string + headline: string + intro: string + topPickId: string + /** Accent gradient classes for the hero / numbered chips. */ + accent: { from: string; to: string; text: string } +} + +export const categoryMeta: Record = { + framework: { + slug: 'framework', + groupId: 'framework', + name: librariesGroupNamesMap.framework, + shortName: 'Framework', + headline: 'The TanStack framework layer', + intro: + 'Type-safe routing and a full-stack framework built on top of it. Start small with Router, or go end-to-end with Start.', + topPickId: 'start', + accent: { + from: 'from-teal-500', + to: 'to-cyan-500', + text: 'text-cyan-600 dark:text-cyan-400', + }, + }, + state: { + slug: 'state', + groupId: 'state', + name: librariesGroupNamesMap.state, + shortName: 'Data & State', + headline: 'Data and state — without the ceremony', + intro: + 'Server state, async data, reactive stores, and an AI-aware layer on top. The libraries you reach for when an app needs to remember things, fetch things, and stay coherent across the screen.', + topPickId: 'query', + accent: { + from: 'from-cyan-500', + to: 'to-emerald-500', + text: 'text-cyan-600 dark:text-cyan-400', + }, + }, + ui: { + slug: 'ui', + groupId: 'headlessUI', + name: librariesGroupNamesMap.headlessUI, + shortName: 'UI & UX', + headline: 'Headless primitives for the surfaces users touch', + intro: + 'Tables, forms, keyboard shortcuts — owned by you, styled by you, validated by the compiler.', + topPickId: 'table', + accent: { + from: 'from-blue-500', + to: 'to-yellow-500', + text: 'text-blue-600 dark:text-blue-400', + }, + }, + performance: { + slug: 'performance', + groupId: 'performance', + name: librariesGroupNamesMap.performance, + shortName: 'Performance', + headline: 'Keep the long lists buttery, the noisy events tame', + intro: + 'Virtualisation, debouncing, throttling, batching — primitives that compose instead of one-off hooks.', + topPickId: 'virtual', + accent: { + from: 'from-purple-500', + to: 'to-lime-500', + text: 'text-purple-600 dark:text-purple-400', + }, + }, + tooling: { + slug: 'tooling', + groupId: 'tooling', + name: librariesGroupNamesMap.tooling, + shortName: 'Tooling', + headline: 'Devtools, scaffolds, and packaging defaults', + intro: + 'Take the boring decisions off your plate, so the interesting work stays interesting.', + topPickId: 'devtools', + accent: { + from: 'from-indigo-500', + to: 'to-orange-500', + text: 'text-indigo-600 dark:text-indigo-400', + }, + }, +} + +export function getCategoryLibraries(slug: CategorySlug): LibrarySlim[] { + return [...librariesByGroup[slugToGroup[slug]]] +} diff --git a/src/libraries/libraries.ts b/src/libraries/libraries.ts index 0f0634751..6f3f3f95e 100644 --- a/src/libraries/libraries.ts +++ b/src/libraries/libraries.ts @@ -851,13 +851,15 @@ export const libraries: LibrarySlim[] = [ ] export const librariesByGroup = { - state: [start, router, query, db, store, ai], + framework: [start, router], + state: [query, db, store, ai], headlessUI: [table, form, hotkeys], performance: [virtual, pacer], tooling: [devtools, config, cli, intent], } export const librariesGroupNamesMap = { + framework: 'Framework', state: 'Data & State Management', headlessUI: 'UI & UX', performance: 'Performance', diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index ca6a34ad8..659d12464 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -49,6 +49,7 @@ import { Route as BlogIndexRouteImport } from './routes/blog.index' import { Route as AdminIndexRouteImport } from './routes/admin/index' import { Route as AccountIndexRouteImport } from './routes/account/index' import { Route as LibraryIdIndexRouteImport } from './routes/$libraryId/index' +import { Route as StackCategoryRouteImport } from './routes/stack.$category' import { Route as ShowcaseSubmitRouteImport } from './routes/showcase/submit' import { Route as ShowcaseIdRouteImport } from './routes/showcase/$id' import { Route as ShopSearchRouteImport } from './routes/shop.search' @@ -353,6 +354,11 @@ const LibraryIdIndexRoute = LibraryIdIndexRouteImport.update({ path: '/', getParentRoute: () => LibraryIdRouteRoute, } as any) +const StackCategoryRoute = StackCategoryRouteImport.update({ + id: '/stack/$category', + path: '/stack/$category', + getParentRoute: () => rootRouteImport, +} as any) const ShowcaseSubmitRoute = ShowcaseSubmitRouteImport.update({ id: '/showcase/submit', path: '/showcase/submit', @@ -953,6 +959,7 @@ export interface FileRoutesByFullPath { '/shop/search': typeof ShopSearchRoute '/showcase/$id': typeof ShowcaseIdRoute '/showcase/submit': typeof ShowcaseSubmitRoute + '/stack/$category': typeof StackCategoryRoute '/$libraryId/': typeof LibraryIdIndexRoute '/account/': typeof AccountIndexRoute '/admin/': typeof AdminIndexRoute @@ -1090,6 +1097,7 @@ export interface FileRoutesByTo { '/shop/search': typeof ShopSearchRoute '/showcase/$id': typeof ShowcaseIdRoute '/showcase/submit': typeof ShowcaseSubmitRoute + '/stack/$category': typeof StackCategoryRoute '/$libraryId': typeof LibraryIdIndexRoute '/account': typeof AccountIndexRoute '/admin': typeof AdminIndexRoute @@ -1234,6 +1242,7 @@ export interface FileRoutesById { '/shop/search': typeof ShopSearchRoute '/showcase/$id': typeof ShowcaseIdRoute '/showcase/submit': typeof ShowcaseSubmitRoute + '/stack/$category': typeof StackCategoryRoute '/$libraryId/': typeof LibraryIdIndexRoute '/account/': typeof AccountIndexRoute '/admin/': typeof AdminIndexRoute @@ -1381,6 +1390,7 @@ export interface FileRouteTypes { | '/shop/search' | '/showcase/$id' | '/showcase/submit' + | '/stack/$category' | '/$libraryId/' | '/account/' | '/admin/' @@ -1518,6 +1528,7 @@ export interface FileRouteTypes { | '/shop/search' | '/showcase/$id' | '/showcase/submit' + | '/stack/$category' | '/$libraryId' | '/account' | '/admin' @@ -1661,6 +1672,7 @@ export interface FileRouteTypes { | '/shop/search' | '/showcase/$id' | '/showcase/submit' + | '/stack/$category' | '/$libraryId/' | '/account/' | '/admin/' @@ -1790,6 +1802,7 @@ export interface RootRouteChildren { OauthTokenRoute: typeof OauthTokenRoute ShowcaseIdRoute: typeof ShowcaseIdRoute ShowcaseSubmitRoute: typeof ShowcaseSubmitRoute + StackCategoryRoute: typeof StackCategoryRoute ShowcaseIndexRoute: typeof ShowcaseIndexRoute StatsIndexRoute: typeof StatsIndexRoute ApiApplicationStarterResolveRoute: typeof ApiApplicationStarterResolveRoute @@ -2122,6 +2135,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LibraryIdIndexRouteImport parentRoute: typeof LibraryIdRouteRoute } + '/stack/$category': { + id: '/stack/$category' + path: '/stack/$category' + fullPath: '/stack/$category' + preLoaderRoute: typeof StackCategoryRouteImport + parentRoute: typeof rootRouteImport + } '/showcase/submit': { id: '/showcase/submit' path: '/showcase/submit' @@ -3109,6 +3129,7 @@ const rootRouteChildren: RootRouteChildren = { OauthTokenRoute: OauthTokenRoute, ShowcaseIdRoute: ShowcaseIdRoute, ShowcaseSubmitRoute: ShowcaseSubmitRoute, + StackCategoryRoute: StackCategoryRoute, ShowcaseIndexRoute: ShowcaseIndexRoute, StatsIndexRoute: StatsIndexRoute, ApiApplicationStarterResolveRoute: ApiApplicationStarterResolveRoute, diff --git a/src/routes/$libraryId/$version.docs.$.tsx b/src/routes/$libraryId/$version.docs.$.tsx index 333a24b03..7d7b69a95 100644 --- a/src/routes/$libraryId/$version.docs.$.tsx +++ b/src/routes/$libraryId/$version.docs.$.tsx @@ -5,7 +5,7 @@ import { loadDocsPage, resolveDocsRedirect } from '~/utils/docs' import { findLibrary, getBranch, getLibrary } from '~/libraries' import { DocContainer } from '~/components/DocContainer' import type { ConfigSchema } from '~/utils/config' -import { docsContentNegotiationVaryHeader } from '~/utils/http' +import { getDocsCacheHeaders } from '~/utils/docs-cache-headers' import { notFound, redirect, @@ -94,39 +94,8 @@ export const Route = createFileRoute('/$libraryId/$version/docs/$')({ component: Docs, headers: ({ params }) => { const { version, libraryId } = params - const library = findLibrary(libraryId) - - const cacheTag = library - ? [ - 'docs:all', - `docs:${library.id}`, - `docs:${library.id}:branch:${getBranch(library, version)}`, - ].join(', ') - : 'docs:all' - - const isLatestVersion = - library && - (version === 'latest' || - version === library.latestVersion || - version === library.latestBranch) - if (isLatestVersion) { - return { - 'cache-control': 'public, max-age=60, must-revalidate', - 'cdn-cache-control': - 'max-age=600, stale-while-revalidate=3600, durable', - 'netlify-cache-tag': cacheTag, - vary: docsContentNegotiationVaryHeader, - } - } else { - return { - 'cache-control': 'public, max-age=3600, must-revalidate', - 'cdn-cache-control': - 'max-age=86400, stale-while-revalidate=604800, durable', - 'netlify-cache-tag': cacheTag, - vary: docsContentNegotiationVaryHeader, - } - } + return getDocsCacheHeaders({ libraryId, version }) }, }) diff --git a/src/routes/$libraryId/$version.docs.community-resources.tsx b/src/routes/$libraryId/$version.docs.community-resources.tsx index a26d64cfa..e3e0cca6a 100644 --- a/src/routes/$libraryId/$version.docs.community-resources.tsx +++ b/src/routes/$libraryId/$version.docs.community-resources.tsx @@ -6,6 +6,7 @@ import { findLibrary, getBranch, getLibrary } from '~/libraries' import { seo } from '~/utils/seo' import { ogImageUrl } from '~/utils/og' import { loadDocs } from '~/utils/docs' +import { getDocsCacheHeaders } from '~/utils/docs-cache-headers' export const Route = createFileRoute( '/$libraryId/$version/docs/community-resources', @@ -45,6 +46,11 @@ export const Route = createFileRoute( }), } }, + headers: ({ params }) => { + const { libraryId, version } = params + + return getDocsCacheHeaders({ libraryId, version }) + }, component: RouteComponent, }) diff --git a/src/routes/$libraryId/$version.docs.framework.$framework.$.tsx b/src/routes/$libraryId/$version.docs.framework.$framework.$.tsx index 26f5eee3f..aacce94eb 100644 --- a/src/routes/$libraryId/$version.docs.framework.$framework.$.tsx +++ b/src/routes/$libraryId/$version.docs.framework.$framework.$.tsx @@ -13,6 +13,7 @@ import { getBranch, getLibrary } from '~/libraries' import { capitalize } from '~/utils/utils' import { DocContainer } from '~/components/DocContainer' import type { ConfigSchema } from '~/utils/config' +import { getDocsCacheHeaders } from '~/utils/docs-cache-headers' export const Route = createFileRoute( '/$libraryId/$version/docs/framework/$framework/$', @@ -66,6 +67,11 @@ export const Route = createFileRoute( } }, component: Docs, + headers: ({ params }) => { + const { libraryId, version } = params + + return getDocsCacheHeaders({ libraryId, version }) + }, head: (ctx) => { const library = getLibrary(ctx.params.libraryId) const tail = `${library.name} ${capitalize(ctx.params.framework)} Docs` diff --git a/src/routes/$libraryId/$version.docs.framework.$framework.examples.$.tsx b/src/routes/$libraryId/$version.docs.framework.$framework.examples.$.tsx index 79594b3c8..b641d671e 100644 --- a/src/routes/$libraryId/$version.docs.framework.$framework.examples.$.tsx +++ b/src/routes/$libraryId/$version.docs.framework.$framework.examples.$.tsx @@ -24,6 +24,7 @@ import { ExampleDeployDialog, type DeployProvider, } from '~/components/ExampleDeployDialog' +import { getDocsCacheHeaders } from '~/utils/docs-cache-headers' import { stackBlitzEmbedHeaders } from '~/utils/stackblitz-embed' const renderedFileQueryOptions = ( @@ -137,7 +138,14 @@ export const Route = createFileRoute( }), } }, - headers: () => stackBlitzEmbedHeaders, + headers: ({ params }) => { + const { libraryId, version } = params + + return { + ...getDocsCacheHeaders({ libraryId, version }), + ...stackBlitzEmbedHeaders, + } + }, staleTime: 1000 * 60 * 5, // 5 minutes }) diff --git a/src/routes/$libraryId/$version.docs.framework.$framework.{$}[.]md.tsx b/src/routes/$libraryId/$version.docs.framework.$framework.{$}[.]md.tsx index fe302cbe8..429ab7f6b 100644 --- a/src/routes/$libraryId/$version.docs.framework.$framework.{$}[.]md.tsx +++ b/src/routes/$libraryId/$version.docs.framework.$framework.{$}[.]md.tsx @@ -1,6 +1,7 @@ import { findLibrary, getBranch } from '~/libraries' import { loadDocs } from '~/utils/docs' import { notFound, createFileRoute } from '@tanstack/react-router' +import { getDocsCacheHeaders } from '~/utils/docs-cache-headers' import { filterFrameworkContent } from '~/utils/markdown/filterFrameworkContent' import { getPackageManager } from '~/utils/markdown/installCommand' @@ -33,6 +34,7 @@ export const Route = createFileRoute( } const root = library.docsRoot || 'docs' + const cacheHeaders = getDocsCacheHeaders({ libraryId, version }) const doc = await loadDocs({ repo: library.repo, @@ -53,11 +55,9 @@ export const Route = createFileRoute( return new Response(markdownContent, { headers: { + ...cacheHeaders, 'Content-Type': 'text/markdown', 'Content-Disposition': `inline; filename="${filename}.md"`, - 'Cache-Control': 'public, max-age=0, must-revalidate', - 'Cdn-Cache-Control': - 'max-age=300, stale-while-revalidate=300, durable', }, }) }, diff --git a/src/routes/$libraryId/$version.docs.tsx b/src/routes/$libraryId/$version.docs.tsx index b6e860e97..8fd33ba45 100644 --- a/src/routes/$libraryId/$version.docs.tsx +++ b/src/routes/$libraryId/$version.docs.tsx @@ -8,7 +8,7 @@ import { DocsLayout } from '~/components/DocsLayout' import { findLibrary } from '~/libraries' import { seo } from '~/utils/seo' import type { ConfigSchema } from '~/utils/config' -import { docsContentNegotiationVaryHeader } from '~/utils/http' +import { getDocsCacheHeaders } from '~/utils/docs-cache-headers' export const Route = createFileRoute('/$libraryId/$version/docs')({ head: (ctx) => { @@ -27,12 +27,10 @@ export const Route = createFileRoute('/$libraryId/$version/docs')({ } }, component: DocsRoute, - headers: () => { - return { - 'cache-control': 'public, max-age=0, must-revalidate', - 'cdn-cache-control': 'max-age=300, stale-while-revalidate=300, durable', - vary: docsContentNegotiationVaryHeader, - } + headers: ({ params }) => { + const { libraryId, version } = params + + return getDocsCacheHeaders({ libraryId, version }) }, }) diff --git a/src/routes/$libraryId/$version.docs.{$}[.]md.tsx b/src/routes/$libraryId/$version.docs.{$}[.]md.tsx index 5ec537ade..9e14c6255 100644 --- a/src/routes/$libraryId/$version.docs.{$}[.]md.tsx +++ b/src/routes/$libraryId/$version.docs.{$}[.]md.tsx @@ -1,6 +1,7 @@ -import { createFileRoute } from '@tanstack/react-router' -import { getBranch, getLibrary, type LibraryId } from '~/libraries' +import { createFileRoute, notFound } from '@tanstack/react-router' +import { findLibrary, getBranch } from '~/libraries' import { loadDocs } from '~/utils/docs' +import { getDocsCacheHeaders } from '~/utils/docs-cache-headers' import { filterFrameworkContent } from '~/utils/markdown/filterFrameworkContent' import { getPackageManager } from '~/utils/markdown/installCommand' @@ -20,8 +21,14 @@ export const Route = createFileRoute('/$libraryId/$version/docs/{$}.md')({ const keepMarkers = url.searchParams.get('keep_markers') === 'true' const { libraryId, version, _splat: docsPath } = params - const library = getLibrary(libraryId as LibraryId) + const library = findLibrary(libraryId) + + if (!library) { + throw notFound() + } + const root = library.docsRoot || 'docs' + const cacheHeaders = getDocsCacheHeaders({ libraryId, version }) const doc = await loadDocs({ repo: library.repo, @@ -44,11 +51,9 @@ export const Route = createFileRoute('/$libraryId/$version/docs/{$}.md')({ return new Response(markdownContent, { headers: { + ...cacheHeaders, 'Content-Type': 'text/markdown', 'Content-Disposition': `inline; filename="${filename}.md"`, - 'Cache-Control': 'public, max-age=0, must-revalidate', - 'Cdn-Cache-Control': - 'max-age=300, stale-while-revalidate=300, durable', }, }) }, diff --git a/src/routes/blog.tsx b/src/routes/blog.tsx index 6f4bf25dd..91ba39b33 100644 --- a/src/routes/blog.tsx +++ b/src/routes/blog.tsx @@ -1,6 +1,6 @@ -import { Link, createFileRoute } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' import { seo } from '~/utils/seo' -import { Button } from '~/ui' +import { NotFound } from '~/components/NotFound' export const Route = createFileRoute('/blog')({ head: () => ({ @@ -12,20 +12,5 @@ export const Route = createFileRoute('/blog')({ }) export function PostNotFound() { - return ( -
    -

    -
    404
    -
    Not Found
    -

    -
    Post not found.
    - -
    - ) + return } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 9e16b636c..628f0a851 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -8,13 +8,14 @@ import { import discordImage from '~/images/discord-logo-white.svg' import { librariesByGroup, librariesGroupNamesMap, Library } from '~/libraries' +import { groupToSlug } from '~/components/stack/stack-categories' +import { twMerge } from 'tailwind-merge' import { NetlifyImage } from '~/components/NetlifyImage' import { TrustedByMarquee } from '~/components/TrustedByMarquee' import { ArrowRight, Code2, Layers, Shield, Zap, Play } from 'lucide-react' import { YouTubeIcon } from '~/components/icons/YouTubeIcon' import { Card } from '~/components/Card' -import LibraryCard from '~/components/LibraryCard' import { HomeApplicationStarter } from '~/components/home/HomeApplicationStarter' import { HomeDeferredSection } from '~/components/home/HomeDeferredSection' import { @@ -271,49 +272,35 @@ function Index() {

    - Open Source Libraries + Browse the stack

    +

    + Every TanStack library, organized by what it does. +

    - {Object.entries(librariesByGroup).map( - ([groupName, groupLibraries]) => ( -
    -

    - { - librariesGroupNamesMap[ - groupName as keyof typeof librariesGroupNamesMap - ] - } -

    - {/* Library Cards */} -
    - {groupLibraries.map((library, i: number) => { - return ( - - ) - })} -
    -
    - ), - )} +
    + {Object.entries(librariesByGroup).map( + ([groupName, groupLibraries]) => ( + + ), + )} +
    @@ -499,6 +486,54 @@ function Index() { ) } +function StackCategoryCard({ + groupId, + libraries, +}: { + groupId: keyof typeof librariesByGroup + libraries: Library[] +}) { + const groupName = librariesGroupNamesMap[groupId] + const categorySlug = groupToSlug[groupId] + + return ( + +

    + Category +

    +

    + {groupName} +

    +
      + {libraries.map((lib, i) => ( +
    1. + + {i + 1} + + + {lib.name.replace('TanStack ', '')} + +
    2. + ))} +
    + + Browse {groupName.toLowerCase()} + + + + ) +} + function OpenSourceUnderline() { return ( diff --git a/src/routes/stack.$category.tsx b/src/routes/stack.$category.tsx new file mode 100644 index 000000000..a4a5a0e9d --- /dev/null +++ b/src/routes/stack.$category.tsx @@ -0,0 +1,36 @@ +import { createFileRoute, notFound } from '@tanstack/react-router' + +import { CategoryArticle } from '~/components/stack/CategoryArticle' +import { + categoryMeta, + categorySlugs, + type CategorySlug, +} from '~/components/stack/stack-categories' +import { seo } from '~/utils/seo' + +function isCategorySlug(value: string): value is CategorySlug { + return (categorySlugs as readonly string[]).includes(value) +} + +export const Route = createFileRoute('/stack/$category')({ + loader: ({ params }) => { + if (!isCategorySlug(params.category)) { + throw notFound() + } + return { category: params.category, meta: categoryMeta[params.category] } + }, + head: ({ loaderData }) => ({ + meta: seo({ + title: loaderData + ? `${loaderData.meta.shortName} — TanStack libraries` + : 'TanStack libraries', + description: loaderData?.meta.intro, + }), + }), + component: StackCategoryPage, +}) + +function StackCategoryPage() { + const { category } = Route.useLoaderData() + return +} diff --git a/src/utils/docs-cache-headers.ts b/src/utils/docs-cache-headers.ts new file mode 100644 index 000000000..079a7ce86 --- /dev/null +++ b/src/utils/docs-cache-headers.ts @@ -0,0 +1,37 @@ +import { findLibrary, getBranch } from '~/libraries' +import { docsContentNegotiationVaryHeader } from './http' + +const latestDocsCdnCacheControl = + 'max-age=60, stale-while-revalidate=60, durable' +const versionedDocsCdnCacheControl = + 'max-age=300, stale-while-revalidate=300, durable' + +export function getDocsCacheHeaders({ + libraryId, + version, +}: { + libraryId: string + version: string +}) { + const library = findLibrary(libraryId) + const branch = library ? getBranch(library, version) : null + const isLatestVersion = + library && + (version === 'latest' || + version === library.latestVersion || + version === library.latestBranch) + const cacheTags = library + ? ['docs:all', `docs:${library.id}`, `docs:${library.id}:branch:${branch}`] + : ['docs:all'] + + return { + 'Cache-Control': isLatestVersion + ? 'public, max-age=60, must-revalidate' + : 'public, max-age=300, must-revalidate', + 'CDN-Cache-Control': isLatestVersion + ? latestDocsCdnCacheControl + : versionedDocsCdnCacheControl, + 'Netlify-Cache-Tag': cacheTags.join(', '), + Vary: docsContentNegotiationVaryHeader, + } +} diff --git a/src/utils/docs.functions.ts b/src/utils/docs.functions.ts index 4ca160d30..e8f3220ed 100644 --- a/src/utils/docs.functions.ts +++ b/src/utils/docs.functions.ts @@ -295,7 +295,7 @@ export const fetchDocs = createServerFn({ method: 'GET' }) frontMatter.content, ) - setDocsCacheHeaders('max-age=300, stale-while-revalidate=300, durable') + setDocsCacheHeaders('max-age=60, stale-while-revalidate=60, durable') return { content: frontMatter.content, @@ -355,7 +355,7 @@ export const fetchFile = createServerFn({ method: 'GET' }) throw notFound() } - setDocsCacheHeaders('max-age=3600, stale-while-revalidate=3600, durable') + setDocsCacheHeaders('max-age=300, stale-while-revalidate=300, durable') return file }) @@ -372,7 +372,7 @@ export const fetchRepoDirectoryContents = createServerFn({ throw notFound() } - setDocsCacheHeaders('max-age=3600, stale-while-revalidate=3600, durable') + setDocsCacheHeaders('max-age=300, stale-while-revalidate=300, durable') return githubContents }) diff --git a/src/utils/github-content-cache.server.ts b/src/utils/github-content-cache.server.ts index ab0806079..574a940f3 100644 --- a/src/utils/github-content-cache.server.ts +++ b/src/utils/github-content-cache.server.ts @@ -6,7 +6,7 @@ import { type GithubContentCache, } from '~/db/schema' -const POSITIVE_STALE_MS = 24 * 60 * 60 * 1000 +const POSITIVE_STALE_MS = 5 * 60 * 1000 const NEGATIVE_STALE_MS = 15 * 60 * 1000 // Internal sentinel paths used for non-file metadata (branch SHA lookup, @@ -130,22 +130,14 @@ function isFresh(staleAt: Date) { // markGitHubContentStale / markDocsArtifactsStale set staleAt to the epoch // (new Date(0)) as a sentinel for "forcibly invalidated" — an admin clicked -// the purge button or a push webhook fired. In that case we must NOT serve -// SWR-stale: the operator's intent is "get fresh content on the very next -// request." Natural TTL expiry (staleAt drifts past now within the normal -// window) still SWRs as before. The row stays around so the bottom of -// getCachedGitHubContent / getCachedDocsArtifact can still fall back to it -// if GitHub is unreachable. +// the purge button or a push webhook fired. Natural TTL expiry and forced +// invalidation both refresh synchronously now. The row stays around so the +// bottom of getCachedGitHubContent / getCachedDocsArtifact can still fall back +// to it if GitHub is unreachable. function isForciblyStale(staleAt: Date) { return staleAt.getTime() <= 0 } -function queueRefresh(key: string, fn: () => Promise) { - void withPendingRefresh(key, fn).catch((error) => { - console.error(`[GitHub Cache] Failed to refresh ${key}:`, error) - }) -} - function readStoredTextValue(row: GithubContentCache | undefined) { if (!row) { return undefined @@ -271,15 +263,6 @@ async function getCachedGitHubContent(opts: { if (cachedRow && isFresh(cachedRow.staleAt)) { return storedValue } - - if (storedValue !== null) { - queueRefresh(opts.cacheKey, async () => { - const value = await opts.origin() - await persist(value) - }) - - return storedValue - } } return withPendingRefresh(opts.cacheKey, async () => { @@ -414,13 +397,6 @@ export async function getCachedDocsArtifact(opts: { if (cachedRow && isFresh(cachedRow.staleAt)) { return storedValue } - - queueRefresh(cacheKey, async () => { - const payload = await opts.build() - await upsertDocsArtifact({ ...opts, payload }) - }) - - return storedValue } return withPendingRefresh(cacheKey, async () => {