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';