Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions docs/docs/public/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
User-agent: *
Allow: /

Sitemap: https://react-native-swipe-deck.pages.dev/sitemap.xml
76 changes: 76 additions & 0 deletions docs/plugins/seo.ts
Original file line number Diff line number Diff line change
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return ` <url><loc>${escaped}</loc></url>`;
});

await writeFile(
path.resolve(config.outDir ?? 'doc_build', 'sitemap.xml'),
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.join('\n')}\n</urlset>\n`,
);
},
};
}
7 changes: 7 additions & 0 deletions docs/rspress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand All @@ -19,6 +25,7 @@ export default defineConfig({
versioned: true,
},
ssg: true,
plugins: [seoPlugin()],
markdown: {
showLineNumbers: true,
defaultWrapCode: false,
Expand Down
34 changes: 33 additions & 1 deletion docs/theme/index.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof BasicLayout>) {
const { page } = usePage();
const alternatives = (page.seoAlternates ?? []) as AlternateLink[];

useHead({
link:
page.pageType === '404'
? []
: [
{
rel: 'canonical',
href: withSiteOrigin(withBase(normalizeHrefInRuntime(page.routePath))),
},
...alternatives,
],
});

return <BasicLayout {...props} />;
}

function getGuideRedirectPath(pathname: string) {
const parts = pathname.split('/').filter(Boolean);
const prefix: string[] = [];
Expand Down Expand Up @@ -62,6 +94,6 @@ function NotFoundLayout() {
return <BasicNotFoundLayout />;
}

export { HomeLayout, NotFoundLayout };
export { HomeLayout, Layout, NotFoundLayout };
// oxlint-disable-next-line import/export
export * from '@rspress/core/theme-original';
Loading