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:
parent
bd78436ab1
commit
4b9f770534
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"postbuild": "node scripts/generate-sitemap.mjs",
|
||||
"start": "next start",
|
||||
"postinstall": "fumadocs-mdx"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://dbxio.com/sitemap.xml
|
||||
|
|
@ -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`);
|
||||
Loading…
Reference in New Issue