Files
2026-07-13 13:32:05 +08:00

287 lines
9.6 KiB
TypeScript

import {
docs,
guides,
tutorials,
integrations,
changelog,
blog,
} from 'collections/server';
import { loader, type PageTreeTransformer } from 'fumadocs-core/source';
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
import { contentRouteFor, docsImageRoute, blogImageRoute } from './shared';
import { getTerm } from './lang/terms';
/**
* Docusaurus-style `sidebar_label` → override the sidebar node's name
* while leaving the page's H1 (driven by `title`) alone.
*
* The schema for this field is defined in `source.config.ts`. Pages
* without a `sidebar_label` fall through and keep their default name
* (their `title`), so this is purely additive.
*
* Typed as `PageTreeTransformer<any>` because the transformer is
* collection-agnostic — each per-section `loader()` has its own
* strongly-typed storage generic that wouldn't unify otherwise.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sidebarLabelTransformer: PageTreeTransformer<any> = {
file(node) {
const ref = node.$ref;
if (!ref) return node;
const file = this.storage.read(ref);
if (!file || file.format !== 'page') return node;
const label = (file.data as { sidebar_label?: unknown }).sidebar_label;
if (typeof label === 'string' && label.length > 0) {
node.name = label;
}
return node;
},
};
const pageTree = { transformers: [sidebarLabelTransformer] };
export const docsSource = loader({
baseUrl: '/docs',
source: docs.toFumadocsSource(),
plugins: [lucideIconsPlugin()],
pageTree,
});
export const guidesSource = loader({
baseUrl: '/guides',
source: guides.toFumadocsSource(),
plugins: [lucideIconsPlugin()],
pageTree,
});
export const tutorialsSource = loader({
baseUrl: '/tutorials',
source: tutorials.toFumadocsSource(),
plugins: [lucideIconsPlugin()],
pageTree,
});
export const integrationsSource = loader({
baseUrl: '/integrations',
source: integrations.toFumadocsSource(),
plugins: [lucideIconsPlugin()],
pageTree,
});
export const changelogSource = loader({
baseUrl: '/changelog',
source: changelog.toFumadocsSource(),
plugins: [lucideIconsPlugin()],
pageTree,
});
export const blogSource = loader({
baseUrl: '/blog',
source: blog.toFumadocsSource(),
plugins: [lucideIconsPlugin()],
pageTree,
});
// Backwards-compatible alias so scaffold-generated routes that still import
// `source` (llms.txt, llms-full.txt, og image routes, search route) keep
// targeting the primary /docs section.
export const source = docsSource;
export function getPageImage(page: (typeof source)['$inferPage']) {
const segments = [...page.slugs, 'image.png'];
return {
segments,
url: `${docsImageRoute}/${segments.join('/')}`,
};
}
/**
* Generated-OG-image URL for a blog page. Mirrors `getPageImage` but
* targets the blog's own `/og/blog/<slug>/image.png` route handler.
* Used as the per-post `og:image` fallback when a post doesn't set
* an explicit `image:` in frontmatter — without it, the blog section's
* `openGraph` override would emit no `og:image` (Next overwrites, not
* deep-merges, the root layout's `openGraph`), and scrapers like
* LinkedIn would fall back to grabbing the author avatar off the page.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getBlogPageImage(page: any) {
const segments = [...page.slugs, 'image.png'];
return {
segments,
url: `${blogImageRoute}/${segments.join('/')}`,
};
}
/**
* Build the raw-markdown URL for a page in *any* section. The section
* prefix is inferred from the page's `url` (e.g. a page at `/guides/foo`
* lives under the `guides` section), so the same helper works for docs,
* guides, tutorials, integrations, and changelog as long as each has a
* matching `/llms.mdx/<section>` route handler.
*
* The second arg is kept for backwards-compat with older callers that
* pass a source; it's ignored in favor of `page.url` which is always
* the canonical source of truth for the section prefix.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getPageMarkdownUrl(page: any, _src?: unknown) {
const segments = [...page.slugs, 'content.md'];
return {
segments,
url: `${contentRouteFor(page.url)}/${segments.join('/')}`,
};
}
/**
* Lower inline `<C id="..."/>` term components into plain inline code on
* the markdown/LLM surface.
*
* `getText('processed')` serializes mdast back to markdown but does NOT
* React-render components, so a `<C>` would survive as a literal JSX tag —
* useless to LLM crawlers (and stripped entirely by the description
* cleaner). We rewrite each tag to the term's Python spelling wrapped in
* backticks so the markdown reads identically to the old hardcoded spans.
*
* This lives on the markdown surface ONLY (not in the shared remark
* pipeline) so the page body keeps rendering the real `<C>` component,
* which is what lets it become language-reactive later. Markdown stays
* Python-only for now — per-language markdown is part of the deferred
* routing work.
*
* `getTerm` throws on unknown ids, so the statically-generated `/llms.*`
* routes fail the build loudly too.
*/
const C_TAG = /<C\s+id="([^"]+)"\s*\/>/g;
export function lowerCodeTerms(markdown: string): string {
return markdown.replace(C_TAG, (_match, id: string) => {
return `\`${getTerm(id, 'python')}\``;
});
}
export async function getLLMText(page: (typeof source)['$inferPage']) {
// `getText` is injected by fumadocs-mdx when `postprocess.includeProcessedMarkdown`
// is set (see source.config.ts) but isn't part of the static PageData type,
// so we reach for it through an explicit cast.
const data = page.data as typeof page.data & {
getText: (format: 'raw' | 'processed') => Promise<string>;
};
const processed = lowerCodeTerms(await data.getText('processed'));
return `# ${page.data.title} (${page.url})
${processed}`;
}
/**
* Extract a meta-description-sized blurb for a page, preferring explicit
* `description:` frontmatter and falling back to the first real paragraph
* of the MDX body. Matches the old Docusaurus behavior of auto-filling
* `<meta name="description">` from the first paragraph, which we lost when
* switching to Fumadocs (it leaves `page.data.description` undefined and
* does not synthesize one).
*
* The fallback path strips common MDX noise (front-of-file `import` lines,
* JSX tags, admonition fences, headings, blockquote markers, list bullets,
* link/emphasis syntax) so crawlers see prose, then truncates at a word
* boundary to ~160 chars — the sweet spot Google still tends to render in
* SERPs without cutting mid-word.
*/
const DESCRIPTION_MAX = 160;
function cleanMarkdownForDescription(md: string): string {
let text = md;
// Drop import / export lines (MDX directives at top of file).
text = text.replace(/^\s*(?:import|export)\b[^\n]*\n/gm, '');
// Drop admonition fences `:::tip[title]` / `:::` on their own lines.
text = text.replace(/^:::[^\n]*$/gm, '');
// Drop HTML/MDX comments.
text = text.replace(/<!--[\s\S]*?-->/g, '');
// Drop fenced code blocks entirely — they rarely make useful descriptions.
text = text.replace(/```[\s\S]*?```/g, '');
// Drop self-closing JSX tags like <ImageDisplayer ... /> and paired
// tags like <VideoDisplayer>...</VideoDisplayer>. Keep inner text for
// paired tags so `<Envelope>…</Envelope>` style components don't nuke
// the surrounding paragraph.
text = text.replace(/<([A-Z][\w]*)\b[^>]*\/>/g, '');
text = text.replace(/<\/?[A-Z][\w]*\b[^>]*>/g, '');
return text;
}
function extractFirstParagraph(md: string): string {
const cleaned = cleanMarkdownForDescription(md);
const blocks = cleaned
.split(/\n{2,}/)
.map((b) => b.trim())
.filter(Boolean);
for (const block of blocks) {
// Skip headings, blockquotes, horizontal rules, list-only blocks.
if (/^#{1,6}\s/.test(block)) continue;
if (/^>\s/.test(block)) continue;
if (/^-{3,}$|^\*{3,}$/.test(block)) continue;
if (/^(?:[-*+]\s|\d+\.\s)/.test(block)) continue;
// Strip inline markdown syntax and collapse whitespace.
const prose = block
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/__([^_]+)__/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/_([^_]+)_/g, '$1')
.replace(/\s+/g, ' ')
.trim();
if (prose.length > 0) return prose;
}
return '';
}
function truncateOnWord(text: string, max: number): string {
if (text.length <= max) return text;
const slice = text.slice(0, max);
const lastSpace = slice.lastIndexOf(' ');
const base = lastSpace > max * 0.6 ? slice.slice(0, lastSpace) : slice;
return `${base.replace(/[\s.,;:!?-]+$/, '')}…`;
}
export async function getPageDescription(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
page: any,
): Promise<string | undefined> {
const frontmatter = page.data?.description;
if (typeof frontmatter === 'string' && frontmatter.length > 0) {
return frontmatter;
}
const data = page.data as {
getText?: (format: 'raw' | 'processed') => Promise<string>;
};
if (typeof data.getText !== 'function') return undefined;
try {
// Lower `<C id="..."/>` to plain code first; otherwise the JSX-tag
// stripping in `cleanMarkdownForDescription` would delete the term
// text and leave gaps in the derived meta description.
const processed = lowerCodeTerms(await data.getText('processed'));
const para = extractFirstParagraph(processed);
if (!para) return undefined;
return truncateOnWord(para, DESCRIPTION_MAX);
} catch {
return undefined;
}
}