From 2879fb4a0a6ad20a4d3a2ff47aa00f77dcbaf10b Mon Sep 17 00:00:00 2001 From: harang Date: Tue, 27 Oct 2026 23:19:53 +0900 Subject: [PATCH 1/3] feat(docs): improve SEO metadata and sitemap --- .github/workflows/ci.yml | 3 ++ docs/docs/public/robots.txt | 4 ++ docs/package.json | 1 + docs/plugins/sitemap.ts | 48 +++++++++++++++++++ docs/rspress.config.ts | 7 +++ docs/tests/seo.test.mjs | 92 +++++++++++++++++++++++++++++++++++++ docs/theme/index.tsx | 30 +++++++++++- 7 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 docs/docs/public/robots.txt create mode 100644 docs/plugins/sitemap.ts create mode 100644 docs/tests/seo.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23de96e..17e5133 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,3 +68,6 @@ jobs: - name: Build docs run: pnpm docs:build + + - name: Check docs SEO output + run: pnpm -C docs test:seo 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/package.json b/docs/package.json index f25f137..bcaaa03 100644 --- a/docs/package.json +++ b/docs/package.json @@ -7,6 +7,7 @@ "build": "rspress build", "dev": "rspress dev", "preview": "rspress preview", + "test:seo": "node --test tests/seo.test.mjs", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/docs/plugins/sitemap.ts b/docs/plugins/sitemap.ts new file mode 100644 index 0000000..bb846b4 --- /dev/null +++ b/docs/plugins/sitemap.ts @@ -0,0 +1,48 @@ +import type { RspressPlugin } from '@rspress/core'; + +import { normalizeHref, withBase, withSiteOrigin } from '@rspress/core'; +import { writeFile } from 'node:fs/promises'; +import * as path from 'node:path'; + +export function sitemapPlugin(): RspressPlugin { + let routePaths: string[] = []; + + return { + name: 'docs-sitemap', + routeGenerated(routes) { + routePaths = routes.map((route) => 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( + routePaths + .filter((routePath) => routePath !== '/404') + .map((routePath) => + withSiteOrigin( + withBase( + normalizeHref(routePath, config.route?.cleanUrls ?? false), + config.base ?? '/', + ), + config.siteOrigin, + ), + ), + ), + ].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..60673ed 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 { sitemapPlugin } from './plugins/sitemap'; + 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: [sitemapPlugin()], markdown: { showLineNumbers: true, defaultWrapCode: false, diff --git a/docs/tests/seo.test.mjs b/docs/tests/seo.test.mjs new file mode 100644 index 0000000..f65dd77 --- /dev/null +++ b/docs/tests/seo.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { readFile, readdir } from 'node:fs/promises'; +import test from 'node:test'; + +const origin = 'https://react-native-swipe-deck.pages.dev'; +const output = new URL('../doc_build/', import.meta.url); +const publicFiles = new Set(await readdir(new URL('../docs/public/', import.meta.url))); +const files = (await readdir(output, { recursive: true })).filter( + (file) => file.endsWith('.html') && file !== '404.html' && !publicFiles.has(file), +); + +function links(html, rel) { + return [...html.matchAll(/]*>/g)] + .map(([tag]) => + Object.fromEntries( + [...tag.matchAll(/([\w-]+)="([^"]*)"/g)].map(([, key, value]) => [key, value]), + ), + ) + .filter((attributes) => attributes.rel === rel); +} + +const pages = await Promise.all( + files.map(async (file) => { + const html = await readFile(new URL(file, output), 'utf8'); + const pathname = `/${file}`.replace(/index\.html$/, '').replace(/\.html$/, ''); + return { file, html, url: `${origin}${pathname}` }; + }), +); + +test('every document has one absolute self-canonical using the deployed clean URL', () => { + assert.ok(pages.length > 0, 'Build the documentation before running SEO checks.'); + + for (const page of pages) { + assert.deepEqual( + links(page.html, 'canonical'), + [{ rel: 'canonical', href: page.url }], + page.file, + ); + } +}); + +test('English and Korean hreflang links are absolute, self-inclusive, and reciprocal', () => { + const pagesByUrl = new Map(pages.map((page) => [page.url, page])); + + for (const page of pages) { + const lang = page.html.match(/]*\blang="([^"]+)"/)?.[1]; + assert.ok(lang === 'en' || lang === 'ko', page.file); + const pathname = new URL(page.url).pathname; + const englishPath = lang === 'ko' ? pathname.slice('/ko'.length) : pathname; + const expected = [ + { rel: 'alternate', hreflang: 'en', href: `${origin}${englishPath}` }, + { rel: 'alternate', hreflang: 'ko', href: `${origin}/ko${englishPath}` }, + ]; + const alternates = links(page.html, 'alternate').filter((link) => link.hreflang); + assert.deepEqual(alternates, expected, page.file); + + for (const alternate of alternates) { + const target = pagesByUrl.get(alternate.href); + assert.ok(target, `Missing document for ${alternate.href}`); + assert.ok( + links(target.html, 'alternate').some( + (link) => link.hreflang === lang && link.href === page.url, + ), + `Missing reciprocal link from ${target.file} to ${page.file}`, + ); + } + } +}); + +test('sitemap contains exactly the canonical document URLs and is advertised in robots.txt', async () => { + const sitemap = await readFile(new URL('sitemap.xml', output), 'utf8'); + assert.match(sitemap, /^<\?xml version="1\.0" encoding="UTF-8"\?>/); + assert.match(sitemap, //); + assert.match(sitemap, /<\/urlset>\s*$/); + const urls = [...sitemap.matchAll(/([^<]+)<\/loc>/g)].map(([, url]) => url); + assert.equal(new Set(urls).size, urls.length, 'Sitemap URLs must be unique.'); + assert.deepEqual(urls.sort(), pages.map((page) => page.url).sort()); + + const robots = await readFile(new URL('robots.txt', output), 'utf8'); + assert.ok(robots.includes(`Sitemap: ${origin}/sitemap.xml`)); +}); + +test('404 and public verification assets are excluded from SEO document metadata', async () => { + const notFound = await readFile(new URL('404.html', output), 'utf8'); + assert.deepEqual(links(notFound, 'canonical'), []); + + const verification = 'google72c92fa3f3368c37.html'; + assert.equal( + await readFile(new URL(verification, output), 'utf8'), + await readFile(new URL(`../docs/public/${verification}`, import.meta.url), 'utf8'), + ); +}); diff --git a/docs/theme/index.tsx b/docs/theme/index.tsx index b97968c..a32eb65 100644 --- a/docs/theme/index.tsx +++ b/docs/theme/index.tsx @@ -1,6 +1,16 @@ 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'; @@ -9,6 +19,24 @@ import { useEffect } from 'react'; 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(); + + useHead({ + link: + page.pageType === '404' + ? [] + : [ + { + rel: 'canonical', + href: withSiteOrigin(withBase(normalizeHrefInRuntime(page.routePath))), + }, + ], + }); + + return ; +} + function getGuideRedirectPath(pathname: string) { const parts = pathname.split('/').filter(Boolean); const prefix: string[] = []; @@ -62,6 +90,6 @@ function NotFoundLayout() { return ; } -export { HomeLayout, NotFoundLayout }; +export { HomeLayout, Layout, NotFoundLayout }; // oxlint-disable-next-line import/export export * from '@rspress/core/theme-original'; From 13e32448a9ed4db9622ec0c7510c0005ff97fc52 Mon Sep 17 00:00:00 2001 From: harang Date: Tue, 27 Oct 2026 23:26:26 +0900 Subject: [PATCH 2/3] chore(docs): remove SEO output tests --- .github/workflows/ci.yml | 3 -- docs/package.json | 1 - docs/tests/seo.test.mjs | 92 ---------------------------------------- 3 files changed, 96 deletions(-) delete mode 100644 docs/tests/seo.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17e5133..23de96e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,3 @@ jobs: - name: Build docs run: pnpm docs:build - - - name: Check docs SEO output - run: pnpm -C docs test:seo diff --git a/docs/package.json b/docs/package.json index bcaaa03..f25f137 100644 --- a/docs/package.json +++ b/docs/package.json @@ -7,7 +7,6 @@ "build": "rspress build", "dev": "rspress dev", "preview": "rspress preview", - "test:seo": "node --test tests/seo.test.mjs", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/docs/tests/seo.test.mjs b/docs/tests/seo.test.mjs deleted file mode 100644 index f65dd77..0000000 --- a/docs/tests/seo.test.mjs +++ /dev/null @@ -1,92 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile, readdir } from 'node:fs/promises'; -import test from 'node:test'; - -const origin = 'https://react-native-swipe-deck.pages.dev'; -const output = new URL('../doc_build/', import.meta.url); -const publicFiles = new Set(await readdir(new URL('../docs/public/', import.meta.url))); -const files = (await readdir(output, { recursive: true })).filter( - (file) => file.endsWith('.html') && file !== '404.html' && !publicFiles.has(file), -); - -function links(html, rel) { - return [...html.matchAll(/]*>/g)] - .map(([tag]) => - Object.fromEntries( - [...tag.matchAll(/([\w-]+)="([^"]*)"/g)].map(([, key, value]) => [key, value]), - ), - ) - .filter((attributes) => attributes.rel === rel); -} - -const pages = await Promise.all( - files.map(async (file) => { - const html = await readFile(new URL(file, output), 'utf8'); - const pathname = `/${file}`.replace(/index\.html$/, '').replace(/\.html$/, ''); - return { file, html, url: `${origin}${pathname}` }; - }), -); - -test('every document has one absolute self-canonical using the deployed clean URL', () => { - assert.ok(pages.length > 0, 'Build the documentation before running SEO checks.'); - - for (const page of pages) { - assert.deepEqual( - links(page.html, 'canonical'), - [{ rel: 'canonical', href: page.url }], - page.file, - ); - } -}); - -test('English and Korean hreflang links are absolute, self-inclusive, and reciprocal', () => { - const pagesByUrl = new Map(pages.map((page) => [page.url, page])); - - for (const page of pages) { - const lang = page.html.match(/]*\blang="([^"]+)"/)?.[1]; - assert.ok(lang === 'en' || lang === 'ko', page.file); - const pathname = new URL(page.url).pathname; - const englishPath = lang === 'ko' ? pathname.slice('/ko'.length) : pathname; - const expected = [ - { rel: 'alternate', hreflang: 'en', href: `${origin}${englishPath}` }, - { rel: 'alternate', hreflang: 'ko', href: `${origin}/ko${englishPath}` }, - ]; - const alternates = links(page.html, 'alternate').filter((link) => link.hreflang); - assert.deepEqual(alternates, expected, page.file); - - for (const alternate of alternates) { - const target = pagesByUrl.get(alternate.href); - assert.ok(target, `Missing document for ${alternate.href}`); - assert.ok( - links(target.html, 'alternate').some( - (link) => link.hreflang === lang && link.href === page.url, - ), - `Missing reciprocal link from ${target.file} to ${page.file}`, - ); - } - } -}); - -test('sitemap contains exactly the canonical document URLs and is advertised in robots.txt', async () => { - const sitemap = await readFile(new URL('sitemap.xml', output), 'utf8'); - assert.match(sitemap, /^<\?xml version="1\.0" encoding="UTF-8"\?>/); - assert.match(sitemap, //); - assert.match(sitemap, /<\/urlset>\s*$/); - const urls = [...sitemap.matchAll(/([^<]+)<\/loc>/g)].map(([, url]) => url); - assert.equal(new Set(urls).size, urls.length, 'Sitemap URLs must be unique.'); - assert.deepEqual(urls.sort(), pages.map((page) => page.url).sort()); - - const robots = await readFile(new URL('robots.txt', output), 'utf8'); - assert.ok(robots.includes(`Sitemap: ${origin}/sitemap.xml`)); -}); - -test('404 and public verification assets are excluded from SEO document metadata', async () => { - const notFound = await readFile(new URL('404.html', output), 'utf8'); - assert.deepEqual(links(notFound, 'canonical'), []); - - const verification = 'google72c92fa3f3368c37.html'; - assert.equal( - await readFile(new URL(verification, output), 'utf8'), - await readFile(new URL(`../docs/public/${verification}`, import.meta.url), 'utf8'), - ); -}); From f7ff5e5337d7ef4530e130e0182d744316fcaff1 Mon Sep 17 00:00:00 2001 From: harang Date: Tue, 27 Oct 2026 23:41:13 +0900 Subject: [PATCH 3/3] fix(docs): update language links during navigation --- docs/README.md | 20 +++++++++++ docs/plugins/seo.ts | 76 +++++++++++++++++++++++++++++++++++++++++ docs/plugins/sitemap.ts | 48 -------------------------- docs/rspress.config.ts | 4 +-- docs/theme/index.tsx | 4 +++ 5 files changed, 102 insertions(+), 50 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/plugins/seo.ts delete mode 100644 docs/plugins/sitemap.ts 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/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/plugins/sitemap.ts b/docs/plugins/sitemap.ts deleted file mode 100644 index bb846b4..0000000 --- a/docs/plugins/sitemap.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { RspressPlugin } from '@rspress/core'; - -import { normalizeHref, withBase, withSiteOrigin } from '@rspress/core'; -import { writeFile } from 'node:fs/promises'; -import * as path from 'node:path'; - -export function sitemapPlugin(): RspressPlugin { - let routePaths: string[] = []; - - return { - name: 'docs-sitemap', - routeGenerated(routes) { - routePaths = routes.map((route) => 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( - routePaths - .filter((routePath) => routePath !== '/404') - .map((routePath) => - withSiteOrigin( - withBase( - normalizeHref(routePath, config.route?.cleanUrls ?? false), - config.base ?? '/', - ), - config.siteOrigin, - ), - ), - ), - ].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 60673ed..04ff096 100644 --- a/docs/rspress.config.ts +++ b/docs/rspress.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from '@rspress/core'; import { transformerNotationDiff, transformerNotationHighlight } from '@shikijs/transformers'; import * as path from 'node:path'; -import { sitemapPlugin } from './plugins/sitemap'; +import { seoPlugin } from './plugins/seo'; export default defineConfig({ root: path.join(__dirname, 'docs'), @@ -25,7 +25,7 @@ export default defineConfig({ versioned: true, }, ssg: true, - plugins: [sitemapPlugin()], + plugins: [seoPlugin()], markdown: { showLineNumbers: true, defaultWrapCode: false, diff --git a/docs/theme/index.tsx b/docs/theme/index.tsx index a32eb65..cae7f72 100644 --- a/docs/theme/index.tsx +++ b/docs/theme/index.tsx @@ -16,11 +16,14 @@ import { } 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: @@ -31,6 +34,7 @@ function Layout(props: ComponentProps) { rel: 'canonical', href: withSiteOrigin(withBase(normalizeHrefInRuntime(page.routePath))), }, + ...alternatives, ], });