diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ae78b3b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,20 @@ +# Documentation site + +Run `pnpm -C docs dev` from the repository root for development, `pnpm docs:build` +for a production build, and `pnpm -C docs preview` to preview the built site. + +## SEO + +`rspress.config.ts` defines the production origin and enables clean URLs to match +Cloudflare Pages. `plugins/seo.ts` generates `sitemap.xml` from document routes +and supplies language alternatives for matching pages within the same version. +`theme/index.tsx` uses Rspress's `useHead` to update canonical and hreflang links +during client-side navigation as well as static rendering. + +English and Korean homepage descriptions are defined in their frontmatter. +`docs/public/robots.txt` advertises the sitemap; the 404 page and public HTML +verification file are excluded from it. + +This uses the public [Rspress plugin hooks](https://rspress.rs/plugin/system/plugin-api) +and [useHead API](https://rspress.rs/ui/hooks/use-head) supported by the installed +Rspress 2.0.23, without additional dependencies. diff --git a/docs/docs/public/robots.txt b/docs/docs/public/robots.txt new file mode 100644 index 0000000..c388d63 --- /dev/null +++ b/docs/docs/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://react-native-swipe-deck.pages.dev/sitemap.xml diff --git a/docs/plugins/seo.ts b/docs/plugins/seo.ts new file mode 100644 index 0000000..c64623f --- /dev/null +++ b/docs/plugins/seo.ts @@ -0,0 +1,76 @@ +import type { RouteMeta, RspressPlugin, UserConfig } from '@rspress/core'; + +import { normalizeHref, withBase, withSiteOrigin } from '@rspress/core'; +import { writeFile } from 'node:fs/promises'; +import * as path from 'node:path'; + +export type AlternateLink = { + rel: 'alternate'; + hreflang: string; + href: string; +}; + +export function seoPlugin(): RspressPlugin { + let siteConfig: UserConfig = {}; + let routes: RouteMeta[] = []; + const pageUrl = (routePath: string) => + withSiteOrigin( + withBase( + normalizeHref(routePath, siteConfig.route?.cleanUrls ?? false), + siteConfig.base ?? '/', + ), + siteConfig.siteOrigin, + ); + + return { + name: 'docs-seo', + config(config) { + siteConfig = config; + return config; + }, + routeGenerated(generatedRoutes) { + routes = generatedRoutes.filter((route) => route.routePath !== '/404'); + }, + extendPageData(page) { + const current = routes.find((route) => route.routePath === page.routePath); + const alternatives = current + ? routes.filter( + (route) => + route.version === current.version && + route.pureRoutePath === current.pureRoutePath && + route.lang, + ) + : []; + + // Compiled MDX replaces frontmatter, so runtime metadata must stay at the top level. + Object.assign(page, { + seoAlternates: + alternatives.length > 1 + ? alternatives.map((route): AlternateLink => ({ + rel: 'alternate', + hreflang: route.lang.replace(/_/g, '-'), + href: pageUrl(route.routePath), + })) + : [], + }); + }, + async afterBuild(config, isProd) { + if (!isProd) return; + + if (!config.siteOrigin) { + throw new Error('A siteOrigin is required to generate the sitemap.'); + } + + const urls = [...new Set(routes.map((route) => pageUrl(route.routePath)))].sort(); + const entries = urls.map((url) => { + const escaped = url.replace(/&/g, '&').replace(//g, '>'); + return ` ${escaped}`; + }); + + await writeFile( + path.resolve(config.outDir ?? 'doc_build', 'sitemap.xml'), + `\n\n${entries.join('\n')}\n\n`, + ); + }, + }; +} diff --git a/docs/rspress.config.ts b/docs/rspress.config.ts index f4014ec..04ff096 100644 --- a/docs/rspress.config.ts +++ b/docs/rspress.config.ts @@ -2,8 +2,14 @@ import { defineConfig } from '@rspress/core'; import { transformerNotationDiff, transformerNotationHighlight } from '@shikijs/transformers'; import * as path from 'node:path'; +import { seoPlugin } from './plugins/seo'; + export default defineConfig({ root: path.join(__dirname, 'docs'), + siteOrigin: 'https://react-native-swipe-deck.pages.dev', + route: { + cleanUrls: true, + }, lang: 'en', title: 'React Native Swipe Deck', description: 'High-performance Tinder-style swipe deck documentation for React Native apps.', @@ -19,6 +25,7 @@ export default defineConfig({ versioned: true, }, ssg: true, + plugins: [seoPlugin()], markdown: { showLineNumbers: true, defaultWrapCode: false, diff --git a/docs/theme/index.tsx b/docs/theme/index.tsx index b97968c..cae7f72 100644 --- a/docs/theme/index.tsx +++ b/docs/theme/index.tsx @@ -1,14 +1,46 @@ import './index.css'; +import type { ComponentProps } from 'react'; + +import { + normalizeHrefInRuntime, + useHead, + usePage, + withBase, + withSiteOrigin, +} from '@rspress/core/runtime'; import { HomeLayout as BasicHomeLayout, + Layout as BasicLayout, NotFoundLayout as BasicNotFoundLayout, PackageManagerTabs, } from '@rspress/core/theme-original'; import { useEffect } from 'react'; +import type { AlternateLink } from '../plugins/seo'; + const GUIDE_SECTION_NAMES = new Set(['getting-started', 'usage']); const INSTALL_PACKAGES = '@react-native-motion-kit/swipe-deck'; +function Layout(props: ComponentProps) { + const { page } = usePage(); + const alternatives = (page.seoAlternates ?? []) as AlternateLink[]; + + useHead({ + link: + page.pageType === '404' + ? [] + : [ + { + rel: 'canonical', + href: withSiteOrigin(withBase(normalizeHrefInRuntime(page.routePath))), + }, + ...alternatives, + ], + }); + + return ; +} + function getGuideRedirectPath(pathname: string) { const parts = pathname.split('/').filter(Boolean); const prefix: string[] = []; @@ -62,6 +94,6 @@ function NotFoundLayout() { return ; } -export { HomeLayout, NotFoundLayout }; +export { HomeLayout, Layout, NotFoundLayout }; // oxlint-disable-next-line import/export export * from '@rspress/core/theme-original';