191 lines
5.5 KiB
TypeScript
191 lines
5.5 KiB
TypeScript
import type {
|
||
Blockquote,
|
||
Heading,
|
||
List,
|
||
ListItem,
|
||
Paragraph,
|
||
Root,
|
||
RootContent,
|
||
ThematicBreak,
|
||
} from "mdast";
|
||
import { toMarkdown } from "mdast-util-to-markdown";
|
||
import { absoluteUrl } from "./url.js";
|
||
import type {
|
||
CitableSection,
|
||
FaqItem,
|
||
JsonLdNode,
|
||
LlmsSiteConfig,
|
||
SiteSeoConfig,
|
||
} from "./types.js";
|
||
import { faqJsonLd } from "./jsonld.js";
|
||
|
||
function heading(depth: 1 | 2 | 3, value: string): Heading {
|
||
return { type: "heading", depth, children: [{ type: "text", value }] };
|
||
}
|
||
|
||
function paragraph(value: string): Paragraph {
|
||
return { type: "paragraph", children: [{ type: "text", value }] };
|
||
}
|
||
|
||
function bullets(items: string[]): List {
|
||
return {
|
||
type: "list",
|
||
ordered: false,
|
||
spread: false,
|
||
children: items.map(
|
||
(item): ListItem => ({
|
||
type: "listItem",
|
||
spread: false,
|
||
children: [paragraph(item)],
|
||
}),
|
||
),
|
||
};
|
||
}
|
||
|
||
function quote(value: string): Blockquote {
|
||
return { type: "blockquote", children: [paragraph(value)] };
|
||
}
|
||
|
||
function hr(): ThematicBreak {
|
||
return { type: "thematicBreak" };
|
||
}
|
||
|
||
function linkList(pages: NonNullable<LlmsSiteConfig["pages"]>, siteUrl: string): List {
|
||
return {
|
||
type: "list",
|
||
ordered: false,
|
||
spread: false,
|
||
children: pages.map((p): ListItem => {
|
||
const url = absoluteUrl(siteUrl, p.path);
|
||
const children: NonNullable<ListItem["children"]> = [
|
||
{
|
||
type: "paragraph",
|
||
children: [
|
||
{ type: "link", url, children: [{ type: "text", value: p.title }] },
|
||
...(p.description
|
||
? [{ type: "text" as const, value: ` — ${p.description}` }]
|
||
: []),
|
||
],
|
||
},
|
||
];
|
||
return { type: "listItem", spread: false, children };
|
||
}),
|
||
};
|
||
}
|
||
|
||
function render(children: RootContent[]): string {
|
||
const tree: Root = { type: "root", children };
|
||
return toMarkdown(tree, { bullet: "-", rule: "-", ruleRepetition: 3, fences: true });
|
||
}
|
||
|
||
/**
|
||
* 生成 llms.txt(GEO):面向 LLM 的站点地图式说明。
|
||
* 规格参考:https://llmstxt.org(约定式,非正式标准)。
|
||
*/
|
||
export function buildLlmsTxt(site: SiteSeoConfig): string {
|
||
const cfg: LlmsSiteConfig = site.llms ?? { summary: site.description };
|
||
const children: RootContent[] = [heading(1, site.name), quote(cfg.summary)];
|
||
|
||
if (cfg.details?.length) {
|
||
for (const d of cfg.details) children.push(paragraph(d));
|
||
}
|
||
|
||
if (cfg.pages?.length) {
|
||
children.push(heading(2, "页面"), linkList(cfg.pages, site.siteUrl));
|
||
}
|
||
|
||
if (cfg.discouraged?.length) {
|
||
children.push(
|
||
heading(2, "可选"),
|
||
paragraph("以下内容通常无需纳入回答依据:"),
|
||
bullets(cfg.discouraged),
|
||
);
|
||
}
|
||
|
||
children.push(
|
||
hr(),
|
||
paragraph(`站点: ${absoluteUrl(site.siteUrl, "/")}`),
|
||
paragraph(`完整内容索引: ${absoluteUrl(site.siteUrl, "/llms-full.txt")}`),
|
||
);
|
||
return render(children);
|
||
}
|
||
|
||
/**
|
||
* 生成 llms-full.txt:把可引用章节 + FAQ 拼成整站长文,
|
||
* 供需要全文上下文的抓取方一次拉取。
|
||
*/
|
||
export function buildLlmsFullTxt(
|
||
site: SiteSeoConfig,
|
||
input: {
|
||
title?: string;
|
||
sections?: Array<CitableSection & { path?: string }>;
|
||
faqs?: FaqItem[];
|
||
extraMarkdown?: string;
|
||
},
|
||
): string {
|
||
const children: RootContent[] = [
|
||
heading(1, input.title ?? site.name),
|
||
paragraph(site.description),
|
||
];
|
||
|
||
if (input.sections?.length) {
|
||
for (const s of input.sections) {
|
||
children.push(heading(2, s.heading), paragraph(s.summary));
|
||
if (s.points?.length) children.push(bullets(s.points));
|
||
if (s.path) {
|
||
children.push(paragraph(`来源: ${absoluteUrl(site.siteUrl, s.path)}`));
|
||
}
|
||
}
|
||
}
|
||
|
||
if (input.faqs?.length) {
|
||
children.push(heading(2, "常见问题"));
|
||
for (const f of input.faqs) {
|
||
children.push(heading(3, f.question), paragraph(f.answer));
|
||
}
|
||
}
|
||
|
||
if (input.extraMarkdown) {
|
||
children.push(paragraph(input.extraMarkdown.trim()));
|
||
}
|
||
|
||
children.push(
|
||
hr(),
|
||
paragraph(`由 ${site.name} 提供 · ${absoluteUrl(site.siteUrl, "/llms.txt")}`),
|
||
);
|
||
return render(children);
|
||
}
|
||
|
||
/** FAQ 同时产出 JSON-LD(SEO)与 Markdown 片段(GEO)。 */
|
||
export function faqToJsonLdAndMarkdown(faqs: FaqItem[]): {
|
||
jsonLd: JsonLdNode;
|
||
markdown: string;
|
||
} {
|
||
const children: RootContent[] = [];
|
||
for (const f of faqs) {
|
||
children.push(
|
||
{
|
||
type: "paragraph",
|
||
children: [
|
||
{
|
||
type: "strong",
|
||
children: [{ type: "text", value: `Q: ${f.question}` }],
|
||
},
|
||
],
|
||
},
|
||
paragraph(f.answer),
|
||
);
|
||
}
|
||
return {
|
||
jsonLd: faqJsonLd(faqs),
|
||
markdown: render(children),
|
||
};
|
||
}
|
||
|
||
/** 将可引用章节渲染为紧凑 Markdown(嵌入文章底部利于摘录)。 */
|
||
export function citableSectionMarkdown(section: CitableSection): string {
|
||
const children: RootContent[] = [heading(2, section.heading), paragraph(section.summary)];
|
||
if (section.points?.length) children.push(bullets(section.points));
|
||
return render(children);
|
||
}
|