feat(docs): add comprehensive SEO optimization for static docs site

Add per-page metadata, Open Graph / Twitter tags, canonical URLs,
hreflang, sitemap generation, robots.txt, and JSON-LD structured data
for the bilingual (en/cn) documentation site.
This commit is contained in:
t8y2 2026-05-28 00:31:34 +08:00
parent bd78436ab1
commit 4b9f770534
10 changed files with 391 additions and 7 deletions

View File

@ -1,6 +1,8 @@
import { LandingNav } from '@/components/landing/LandingNav';
import { ChangelogRuntime } from '@/components/landing/ChangelogRuntime';
import { fetchChangelog } from '@/lib/changelog';
import { buildMetadata } from '@/lib/metadata';
import type { Metadata } from 'next';
const i18n = {
en: {
@ -13,6 +15,23 @@ const i18n = {
},
};
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
}): Promise<Metadata> {
const { lang } = await params;
const l = lang === 'cn' ? 'cn' : 'en';
const t = i18n[l];
return buildMetadata({
title: t.title,
description: t.desc,
path: `/${l}/changelog`,
lang: l,
});
}
export default async function ChangelogPage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const l = lang === 'cn' ? 'cn' : 'en';

View File

@ -1,4 +1,6 @@
import { LandingNav } from '@/components/landing/LandingNav';
import { buildMetadata } from '@/lib/metadata';
import type { Metadata } from 'next';
const channels = [
{
@ -58,6 +60,23 @@ const i18n = {
},
};
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
}): Promise<Metadata> {
const { lang } = await params;
const l = lang === 'cn' ? 'cn' : 'en';
const t = i18n[l];
return buildMetadata({
title: t.title,
description: t.desc,
path: `/${l}/community`,
lang: l,
});
}
export default async function CommunityPage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const l = lang === 'cn' ? 'cn' : 'en';

View File

@ -1,5 +1,6 @@
import { source } from '@/lib/source';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import {
DocsPage,
DocsBody,
@ -15,6 +16,28 @@ import { ImageZoom } from 'fumadocs-ui/components/image-zoom';
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import type { MDXContent } from 'mdx/types';
import type { TOCItemType } from 'fumadocs-core/toc';
import { buildMetadata, SITE_URL } from '@/lib/metadata';
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string; slug?: string[] }>;
}): Promise<Metadata> {
const { lang, slug } = await params;
const page = source.getPage(slug, lang);
if (!page) return {};
const title = page.data.title as string;
const description = (page.data.description as string) || undefined;
return buildMetadata({
title,
description: description ?? '',
path: slug ? `/${lang}/docs/${slug.join('/')}` : `/${lang}/docs`,
lang,
ogType: 'article',
});
}
const mdxComponents = {
...defaultMdxComponents,
@ -44,10 +67,51 @@ export default async function Page({
toc: TOCItemType[];
};
const title = page.data.title as string;
const description = (page.data.description as string) || '';
const docPath = slug ? `/${lang}/docs/${slug.join('/')}` : `/${lang}/docs`;
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
{ '@type': 'ListItem', position: 2, name: 'Docs', item: `${SITE_URL}/${lang}/docs` },
...(slug
? slug.map((segment, i) => ({
'@type': 'ListItem' as const,
position: i + 3,
name: segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
item: `${SITE_URL}/${lang}/docs/${slug.slice(0, i + 1).join('/')}`,
}))
: []),
],
};
const articleJsonLd = {
'@context': 'https://schema.org',
'@type': 'TechArticle',
headline: title,
description,
inLanguage: lang === 'cn' ? 'zh-CN' : 'en',
isPartOf: {
'@type': 'WebSite',
url: SITE_URL,
},
};
return (
<DocsPage toc={toc}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
/>
<DocsTitle>{title}</DocsTitle>
<DocsDescription>{description}</DocsDescription>
<DocsBody>
<MDX components={mdxComponents} />
</DocsBody>

View File

@ -1,6 +1,52 @@
import type { ReactNode } from 'react';
import type { Metadata } from 'next';
import { RootProvider } from 'fumadocs-ui/provider/next';
import { i18nUI } from '@/lib/i18n';
import { SITE_URL, SITE_NAME, DEFAULT_DESCRIPTION } from '@/lib/metadata';
const LOCALE_MAP: Record<string, { locale: string; title: string; description: string }> = {
en: {
locale: 'en_US',
title: 'DBX - 15 MB to manage 35+ databases',
description: DEFAULT_DESCRIPTION,
},
cn: {
locale: 'zh_CN',
title: 'DBX - 15MB管理35+种数据库',
description: '25+ 种数据库,仅 15 MB。支持桌面与 Docker 自托管,内置 AI 助手。',
},
};
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
}): Promise<Metadata> {
const { lang } = await params;
const l = lang === 'cn' ? 'cn' : 'en';
const meta = LOCALE_MAP[l];
return {
title: {
default: meta.title,
template: `%s | ${SITE_NAME}`,
},
description: meta.description,
openGraph: {
locale: meta.locale,
siteName: SITE_NAME,
url: `${SITE_URL}/${l}`,
},
alternates: {
canonical: `${SITE_URL}/${l}`,
languages: {
en: `${SITE_URL}/en`,
zh: `${SITE_URL}/cn`,
'x-default': `${SITE_URL}/en`,
},
},
};
}
export default async function LangLayout({
params,

View File

@ -1,4 +1,5 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import type { CSSProperties } from 'react';
import { HeroProductStage } from '@/components/aceternity/HeroProductStage';
import { InfiniteMovingCards } from '@/components/aceternity/InfiniteMovingCards';
@ -366,6 +367,39 @@ const i18nText = {
},
};
import { buildMetadata } from '@/lib/metadata';
const landingMeta = {
en: {
title: 'DBX - 15 MB to manage 35+ databases!',
description:
'DBX brings connections, SQL editing, data grids, schema tools, AI assistance, and self-hosted access into one lightweight product.',
},
cn: {
title: 'DBX - 15MB管理35+种数据库!',
description:
'DBX 将连接管理、SQL 编辑、数据表格、结构工具、AI 助手和自托管访问放进一个轻量产品里。',
},
};
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
}): Promise<Metadata> {
const { lang } = await params;
const l = lang === 'cn' ? 'cn' : 'en';
const meta = landingMeta[l];
return buildMetadata({
title: meta.title,
description: meta.description,
path: `/${l}`,
lang: l,
ogType: 'website',
});
}
export default async function LandingPage({
params,
}: {

View File

@ -1,22 +1,54 @@
import './global.css';
import type { ReactNode } from 'react';
import type { Metadata } from 'next';
import { SITE_URL, SITE_NAME, DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE } from '@/lib/metadata';
export const metadata = {
export const metadata: Metadata = {
title: {
default: 'DBX',
template: '%s | DBX',
default: SITE_NAME,
template: `%s | ${SITE_NAME}`,
},
description: '25+ databases in 15 MB. Desktop & Docker self-hosting, with built-in AI assistant.',
description: DEFAULT_DESCRIPTION,
metadataBase: new URL(SITE_URL),
icons: {
icon: '/favicon.png',
shortcut: '/favicon.png',
apple: '/logo.png',
},
openGraph: {
siteName: SITE_NAME,
images: [{ url: DEFAULT_OG_IMAGE }],
},
twitter: {
card: 'summary_large_image',
},
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className="dark" suppressHydrationWarning>
<html className="dark" suppressHydrationWarning>
<head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: SITE_NAME,
url: SITE_URL,
description: DEFAULT_DESCRIPTION,
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: `${SITE_URL}/search?q={search_term_string}`,
},
'query-input': 'required name=search_term_string',
},
}),
}}
/>
</head>
<body className="flex min-h-screen flex-col">{children}</body>
</html>
);

75
docs/lib/metadata.ts Normal file
View File

@ -0,0 +1,75 @@
import type { Metadata } from 'next';
export const SITE_URL = 'https://dbxio.com';
export const SITE_NAME = 'DBX';
export const DEFAULT_DESCRIPTION =
'25+ databases in 15 MB. Desktop & Docker self-hosting, with built-in AI assistant.';
export const DEFAULT_OG_IMAGE = '/logo.png';
const LOCALE_MAP: Record<string, string> = {
en: 'en_US',
cn: 'zh_CN',
};
const HTML_LANG_MAP: Record<string, string> = {
en: 'en',
cn: 'zh-CN',
};
export function getHtmlLang(lang: string): string {
return HTML_LANG_MAP[lang] ?? 'en';
}
function swapLang(path: string, to: string): string {
return path.replace(/^\/(en|cn)/, `/${to}`);
}
interface BuildMetadataParams {
title: string;
description: string;
path: string;
lang: string;
ogType?: 'website' | 'article';
images?: string[];
lastModified?: Date;
}
export function buildMetadata({
title,
description,
path,
lang,
ogType = 'website',
images,
}: BuildMetadataParams): Metadata {
const canonical = `${SITE_URL}${path}`;
const locale = LOCALE_MAP[lang] ?? 'en_US';
return {
title,
description,
alternates: {
canonical,
languages: {
en: `${SITE_URL}${swapLang(path, 'en')}`,
zh: `${SITE_URL}${swapLang(path, 'cn')}`,
'x-default': `${SITE_URL}${swapLang(path, 'en')}`,
},
},
openGraph: {
title,
description,
url: canonical,
siteName: SITE_NAME,
type: ogType,
locale,
images: images?.map((url) => ({ url })) ?? [{ url: DEFAULT_OG_IMAGE }],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: images ?? [DEFAULT_OG_IMAGE],
},
};
}

View File

@ -4,6 +4,7 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"postbuild": "node scripts/generate-sitemap.mjs",
"start": "next start",
"postinstall": "fumadocs-mdx"
},

4
docs/public/robots.txt Normal file
View File

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://dbxio.com/sitemap.xml

View File

@ -0,0 +1,90 @@
import { readdirSync, writeFileSync } from 'fs';
import { resolve, relative } from 'path';
const OUT_DIR = resolve(import.meta.dirname, '../out');
const SITE_URL = 'https://dbxio.com';
const TODAY = new Date().toISOString().split('T')[0];
const EXCLUDE = new Set(['index.html', '404.html', '_not-found.html']);
function* walkDir(dir) {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = resolve(dir, entry.name);
if (entry.isDirectory()) {
yield* walkDir(fullPath);
} else if (entry.isFile() && entry.name.endsWith('.html')) {
yield fullPath;
}
}
}
function pathToUrl(filePath) {
const rel = relative(OUT_DIR, filePath);
return '/' + rel.replace(/\.html$/, '').replace(/\\/g, '/');
}
const htmlFiles = [...walkDir(OUT_DIR)].filter((f) => {
const basename = f.split('/').pop() ?? '';
return !EXCLUDE.has(basename);
});
const pagesByPath = new Map();
for (const file of htmlFiles) {
const url = pathToUrl(file);
const match = url.match(/^\/(en|cn)(\/.*)?$/);
if (!match) {
pagesByPath.set(url, { en: null, cn: null });
continue;
}
const relativePath = match[2] || '/';
if (!pagesByPath.has(relativePath)) {
pagesByPath.set(relativePath, { en: null, cn: null });
}
const entry = pagesByPath.get(relativePath);
entry[match[1]] = url;
pagesByPath.set(relativePath, entry);
}
const urls = [];
const seen = new Set();
for (const [, langs] of pagesByPath) {
const primary = langs.en || langs.cn;
if (!primary || seen.has(primary)) continue;
seen.add(primary);
const altLinks = [];
if (langs.en) {
altLinks.push({ lang: 'en', href: `${SITE_URL}${langs.en}` });
}
if (langs.cn) {
altLinks.push({ lang: 'zh', href: `${SITE_URL}${langs.cn}` });
}
if (altLinks.length > 1) {
altLinks.push({ lang: 'x-default', href: `${SITE_URL}${langs.en}` });
}
urls.push({ loc: `${SITE_URL}${primary}`, lastmod: TODAY, altLinks });
}
const sitemapXml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
${urls
.map(
(entry) =>
` <url>
<loc>${entry.loc}</loc>
<lastmod>${entry.lastmod}</lastmod>
${entry.altLinks.map((alt) => ` <xhtml:link rel="alternate" hreflang="${alt.lang}" href="${alt.href}" />`).join('\n')}
</url>`
)
.join('\n')}
</urlset>
`;
writeFileSync(resolve(OUT_DIR, 'sitemap.xml'), sitemapXml);
console.log(`sitemap.xml generated with ${urls.length} URLs`);