mirror of
https://github.com/fosrl/docs-v2.git
synced 2026-09-30 17:59:10 +02:00
seo and geo optimizations
This commit is contained in:
@@ -15,6 +15,7 @@ import { appName, docsDir, getPageMarkdownUrl, gitConfig, siteUrl } from '@/lib/
|
|||||||
import { AskAIAboutPage } from '@/components/ai/ask-page';
|
import { AskAIAboutPage } from '@/components/ai/ask-page';
|
||||||
import { SiteFooter } from '@/components/site-footer';
|
import { SiteFooter } from '@/components/site-footer';
|
||||||
import { AskBar } from '@/components/ai/ask-bar';
|
import { AskBar } from '@/components/ai/ask-bar';
|
||||||
|
import { pageJsonLd } from '@/lib/json-ld';
|
||||||
|
|
||||||
export default async function Page(props: PageProps<'/[[...slug]]'>) {
|
export default async function Page(props: PageProps<'/[[...slug]]'>) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
@@ -25,20 +26,22 @@ export default async function Page(props: PageProps<'/[[...slug]]'>) {
|
|||||||
const markdownUrl = getPageMarkdownUrl(page).url;
|
const markdownUrl = getPageMarkdownUrl(page).url;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocsPage
|
<>
|
||||||
toc={page.data.toc}
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: pageJsonLd(page) }} />
|
||||||
full={page.data.full}
|
<DocsPage
|
||||||
tableOfContent={{ style: 'clerk' }}
|
toc={page.data.toc}
|
||||||
// rendered after the previous / next page links
|
full={page.data.full}
|
||||||
footer={{
|
tableOfContent={{ style: 'clerk' }}
|
||||||
children: (
|
// rendered after the previous / next page links
|
||||||
<>
|
footer={{
|
||||||
<SiteFooter />
|
children: (
|
||||||
<AskBar />
|
<>
|
||||||
</>
|
<SiteFooter />
|
||||||
),
|
<AskBar />
|
||||||
}}
|
</>
|
||||||
>
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DocsTitle>{page.data.title}</DocsTitle>
|
<DocsTitle>{page.data.title}</DocsTitle>
|
||||||
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
|
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
|
||||||
<div className="flex flex-row flex-wrap gap-2 items-center border-b pb-6">
|
<div className="flex flex-row flex-wrap gap-2 items-center border-b pb-6">
|
||||||
@@ -58,7 +61,8 @@ export default async function Page(props: PageProps<'/[[...slug]]'>) {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</DocsBody>
|
</DocsBody>
|
||||||
</DocsPage>
|
</DocsPage>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export async function GET() {
|
|||||||
`> ${siteDescription}`,
|
`> ${siteDescription}`,
|
||||||
'',
|
'',
|
||||||
`The full documentation as a single file is available at ${siteUrl}/llms-full.txt. Append \`.md\` to any page URL to get its Markdown source.`,
|
`The full documentation as a single file is available at ${siteUrl}/llms-full.txt. Append \`.md\` to any page URL to get its Markdown source.`,
|
||||||
|
`An MCP server for searching and reading this documentation is available at ${siteUrl}/mcp.`,
|
||||||
'',
|
'',
|
||||||
];
|
];
|
||||||
const listed = new Set<string>();
|
const listed = new Set<string>();
|
||||||
|
|||||||
+6
-43
@@ -1,55 +1,18 @@
|
|||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import { statSync } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import type { MetadataRoute } from 'next';
|
import type { MetadataRoute } from 'next';
|
||||||
|
import { pageLastModified } from '@/lib/last-modified';
|
||||||
import { source } from '@/lib/source';
|
import { source } from '@/lib/source';
|
||||||
import { docsDir, siteUrl } from '@/lib/shared';
|
import { siteUrl } from '@/lib/shared';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same shape as a Mintlify docs sitemap (`/docs/sitemap.xml` on Infisical):
|
* Same shape as a Mintlify docs sitemap (`/docs/sitemap.xml` on Infisical):
|
||||||
* one `<loc>` and `<lastmod>` per published page.
|
* one `<loc>` and `<lastmod>` per published page.
|
||||||
*/
|
*/
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
const dates = lastCommitDates();
|
|
||||||
|
|
||||||
return source
|
return source
|
||||||
.getPages()
|
.getPages()
|
||||||
.map((page) => {
|
.map((page) => ({
|
||||||
const file = `${docsDir}/${page.path}`;
|
url: `${siteUrl}${page.url}`,
|
||||||
return {
|
lastModified: pageLastModified(page),
|
||||||
url: `${siteUrl}${page.url}`,
|
}))
|
||||||
lastModified: dates.get(file) ?? mtime(page.absolutePath ?? path.join(process.cwd(), file)),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => a.url.localeCompare(b.url));
|
.sort((a, b) => a.url.localeCompare(b.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Newest commit that touched each file under content/docs. Empty when git is unavailable. */
|
|
||||||
function lastCommitDates(): Map<string, Date> {
|
|
||||||
const dates = new Map<string, Date>();
|
|
||||||
try {
|
|
||||||
const out = execFileSync('git', ['log', '--format=COMMIT %cI', '--name-only', '--', docsDir], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
});
|
|
||||||
let current: Date | undefined;
|
|
||||||
for (const line of out.split('\n')) {
|
|
||||||
if (line.startsWith('COMMIT ')) {
|
|
||||||
const date = new Date(line.slice('COMMIT '.length));
|
|
||||||
current = Number.isNaN(date.getTime()) ? undefined : date;
|
|
||||||
} else if (line && current && !dates.has(line)) {
|
|
||||||
dates.set(line, current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Docker builds without git fall back to file mtime.
|
|
||||||
}
|
|
||||||
return dates;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mtime(file: string): Date {
|
|
||||||
try {
|
|
||||||
return statSync(file).mtime;
|
|
||||||
} catch {
|
|
||||||
return new Date();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
import navigation from '@/lib/navigation.json';
|
||||||
|
import { pageLastModified } from '@/lib/last-modified';
|
||||||
|
import type { Page } from '@/lib/source';
|
||||||
|
import { appName, siteUrl } from '@/lib/shared';
|
||||||
|
|
||||||
|
type NavItem = string | { group: string; pages: NavItem[] };
|
||||||
|
type Crumb = { name: string; url: string };
|
||||||
|
type IdRef = { '@id': string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mintlify's per-page graph: Organization, WebSite, WebPage, BreadcrumbList,
|
||||||
|
* and TechArticle. `dateModified` is the same git date the sitemap uses.
|
||||||
|
*/
|
||||||
|
export function pageJsonLd(page: Page): string {
|
||||||
|
const url = absoluteUrl(page.url);
|
||||||
|
const dateModified = pageLastModified(page).toISOString();
|
||||||
|
const description = page.data.description || undefined;
|
||||||
|
const orgId = `${siteUrl}/#organization`;
|
||||||
|
const siteId = `${siteUrl}/#website`;
|
||||||
|
const pageId = `${url}#webpage`;
|
||||||
|
const crumbs = breadcrumb(page.slugs.length === 0 ? 'index' : page.slugs.join('/'), url, page.data.title);
|
||||||
|
|
||||||
|
const graph: Record<string, unknown>[] = [
|
||||||
|
{
|
||||||
|
'@type': 'Organization',
|
||||||
|
'@id': orgId,
|
||||||
|
name: appName,
|
||||||
|
url: siteUrl,
|
||||||
|
logo: { '@type': 'ImageObject', url: `${siteUrl}/logo/light.png` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'WebSite',
|
||||||
|
'@id': siteId,
|
||||||
|
name: appName,
|
||||||
|
url: siteUrl,
|
||||||
|
publisher: { '@id': orgId } satisfies IdRef,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'WebPage',
|
||||||
|
'@id': pageId,
|
||||||
|
url,
|
||||||
|
name: page.data.title,
|
||||||
|
description,
|
||||||
|
dateModified,
|
||||||
|
isPartOf: { '@id': siteId } satisfies IdRef,
|
||||||
|
breadcrumb: crumbs ? { '@id': `${url}#breadcrumb` } : undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (crumbs) {
|
||||||
|
graph.push({
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
'@id': `${url}#breadcrumb`,
|
||||||
|
itemListElement: crumbs.map((crumb, index) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: index + 1,
|
||||||
|
name: crumb.name,
|
||||||
|
item: crumb.url,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
graph.push({
|
||||||
|
'@type': ['Article', 'TechArticle'],
|
||||||
|
'@id': `${url}#article`,
|
||||||
|
headline: page.data.title,
|
||||||
|
name: page.data.title,
|
||||||
|
description,
|
||||||
|
url,
|
||||||
|
mainEntityOfPage: { '@id': pageId } satisfies IdRef,
|
||||||
|
dateModified,
|
||||||
|
publisher: { '@id': orgId } satisfies IdRef,
|
||||||
|
isPartOf: { '@id': siteId } satisfies IdRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
return JSON.stringify({ '@context': 'https://schema.org', '@graph': graph }).replace(/</g, '\\u003c');
|
||||||
|
}
|
||||||
|
|
||||||
|
function absoluteUrl(url: string) {
|
||||||
|
return `${siteUrl}${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two crumbs, same as Mintlify: the nearest nav group whose first page is not
|
||||||
|
* this one, then the page. The home page has no trail.
|
||||||
|
*/
|
||||||
|
function breadcrumb(navPath: string, url: string, title: string): Crumb[] | undefined {
|
||||||
|
const ancestors = findAncestors(navigation.groups as NavItem[], navPath) ?? [];
|
||||||
|
|
||||||
|
for (let i = ancestors.length - 1; i >= 0; i--) {
|
||||||
|
const group = ancestors[i];
|
||||||
|
if (!group) continue;
|
||||||
|
const leaf = firstLeaf(group.pages);
|
||||||
|
const parentUrl = leaf ? urlForNavPath(leaf) : undefined;
|
||||||
|
if (parentUrl && parentUrl !== url) {
|
||||||
|
return [
|
||||||
|
{ name: group.group, url: parentUrl },
|
||||||
|
{ name: title, url },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === absoluteUrl('/')) return undefined;
|
||||||
|
return [
|
||||||
|
{ name: appName, url: absoluteUrl('/') },
|
||||||
|
{ name: title, url },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlForNavPath(navPath: string) {
|
||||||
|
const slugs = navPath === 'index' ? [] : navPath.split('/');
|
||||||
|
return absoluteUrl(slugs.length === 0 ? '/' : `/${slugs.join('/')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstLeaf(items: NavItem[]): string | undefined {
|
||||||
|
for (const item of items) {
|
||||||
|
if (typeof item === 'string') return item;
|
||||||
|
const found = firstLeaf(item.pages);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAncestors(
|
||||||
|
items: NavItem[],
|
||||||
|
target: string,
|
||||||
|
trail: Extract<NavItem, { group: string }>[] = [],
|
||||||
|
): Extract<NavItem, { group: string }>[] | undefined {
|
||||||
|
for (const item of items) {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
if (item === target) return trail;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const found = findAncestors(item.pages, target, [...trail, item]);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { statSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { docsDir } from './shared';
|
||||||
|
|
||||||
|
type ContentFile = { path: string; absolutePath?: string };
|
||||||
|
|
||||||
|
let cached: Map<string, Date> | undefined;
|
||||||
|
|
||||||
|
/** Newest commit that touched each file under content/docs. Empty when git is unavailable. */
|
||||||
|
function commitDates(): Map<string, Date> {
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const dates = new Map<string, Date>();
|
||||||
|
try {
|
||||||
|
const out = execFileSync('git', ['log', '--format=COMMIT %cI', '--name-only', '--', docsDir], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
let current: Date | undefined;
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
if (line.startsWith('COMMIT ')) {
|
||||||
|
const date = new Date(line.slice('COMMIT '.length));
|
||||||
|
current = Number.isNaN(date.getTime()) ? undefined : date;
|
||||||
|
} else if (line && current && !dates.has(line)) {
|
||||||
|
dates.set(line, current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Docker builds without git fall back to file mtime.
|
||||||
|
}
|
||||||
|
|
||||||
|
cached = dates;
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mtime(file: string): Date {
|
||||||
|
try {
|
||||||
|
return statSync(file).mtime;
|
||||||
|
} catch {
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last time this page's MDX changed: newest commit, then file mtime. */
|
||||||
|
export function pageLastModified(page: ContentFile): Date {
|
||||||
|
const file = `${docsDir}/${page.path}`;
|
||||||
|
return commitDates().get(file) ?? mtime(page.absolutePath ?? path.join(process.cwd(), file));
|
||||||
|
}
|
||||||
@@ -42,6 +42,6 @@ export default function proxy(request: NextRequest) {
|
|||||||
export const config = {
|
export const config = {
|
||||||
// skip Next internals, API routes, generated files and static assets
|
// skip Next internals, API routes, generated files and static assets
|
||||||
matcher: [
|
matcher: [
|
||||||
'/((?!_next/|api/|og/|llms|images/|logo/|favicon|icon|apple-icon|manifest|web-app-manifest).*)',
|
'/((?!_next/|api/|og/|llms|images/|logo/|favicon|icon|apple-icon|manifest|web-app-manifest|sitemap\\.xml|robots\\.txt).*)',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user