Files
jiang13-bbs/frontend/lib/jsonLd.ts
freefire 7a44c1f2cf feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
2026-09-27 04:01:42 +08:00

275 lines
8.1 KiB
TypeScript

import { absoluteCanonical, resolveSiteOrigin, type QueryBag } from "./siteUrl.ts";
import { postPath, userPath, type UrlStyle } from "./urlStyle.ts";
/** 防止用户正文里的 </script> 提前闭合 JSON-LD 脚本 */
export function serializeJsonLd(data: unknown): string {
return JSON.stringify(data)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
export type JsonLdPerson = {
id?: number;
name: string;
};
function personNode(person: JsonLdPerson, urlStyle: UrlStyle = "default") {
const url = person.id ? absoluteCanonical(userPath(urlStyle, person.id)) : undefined;
return {
"@type": "Person" as const,
name: person.name,
...(url ? { url } : {}),
};
}
function websiteId(): string | undefined {
const origin = resolveSiteOrigin();
return origin ? `${origin}/#website` : undefined;
}
export function websiteNode(siteName: string, description?: string) {
const origin = resolveSiteOrigin();
const id = websiteId();
if (!origin || !id) return null;
return {
"@type": "WebSite" as const,
"@id": id,
name: siteName,
url: `${origin}/`,
...(description ? { description } : {}),
inLanguage: "zh-CN",
};
}
export function listingJsonLd(input: {
siteName: string;
siteDescription?: string;
pageName: string;
path: string;
query?: QueryBag;
items: { id: number; name: string }[];
page: number;
pageSize: number;
urlStyle?: UrlStyle;
}): Record<string, unknown> | null {
const urlStyle = input.urlStyle ?? "default";
const site = websiteNode(input.siteName, input.siteDescription);
const pageUrl = absoluteCanonical(input.path, input.query);
const origin = resolveSiteOrigin();
if (!site || !pageUrl || !origin) return null;
return {
"@context": "https://schema.org",
"@graph": [
site,
{
"@type": "CollectionPage",
"@id": pageUrl,
url: pageUrl,
name: input.pageName,
isPartOf: { "@id": site["@id"] },
inLanguage: "zh-CN",
mainEntity: {
"@type": "ItemList",
numberOfItems: input.items.length,
itemListElement: input.items.map((item, i) => ({
"@type": "ListItem",
position: (input.page - 1) * input.pageSize + i + 1,
name: item.name,
url: `${origin}${postPath(urlStyle, item.id)}`,
})),
},
},
],
};
}
export type JsonLdVisibleComment = {
id: number;
content: string;
created_at: string;
author?: JsonLdPerson;
};
function commentUrl(entityUrl: string, id: number): string {
return `${entityUrl}#comment-${id}`;
}
/** 问答帖:一问多答。只收录当前页可见楼层,不把折叠回复或未展示评论写进去。 */
export function questionJsonLd(input: {
title: string;
text?: string;
createdAt: string;
updatedAt?: string;
author: JsonLdPerson;
postId: number;
pageUrl?: string;
accepted?: JsonLdVisibleComment | null;
answers: JsonLdVisibleComment[];
urlStyle?: UrlStyle;
}): Record<string, unknown> | null {
const urlStyle = input.urlStyle ?? "default";
const entityUrl = absoluteCanonical(postPath(urlStyle, input.postId));
const origin = resolveSiteOrigin();
const site = websiteId();
if (!entityUrl || !origin || !site) return null;
const pageUrl = input.pageUrl || entityUrl;
const acceptedId = input.accepted?.id;
const suggested = input.answers.filter(
(a) => a.content.trim() && a.id !== acceptedId
);
const answerNode = (a: JsonLdVisibleComment) => ({
"@type": "Answer" as const,
text: a.content,
dateCreated: a.created_at,
url: commentUrl(entityUrl, a.id),
...(a.author ? { author: personNode(a.author, urlStyle) } : {}),
});
return {
"@context": "https://schema.org",
"@type": "QAPage",
"@id": pageUrl,
url: pageUrl,
inLanguage: "zh-CN",
isPartOf: { "@id": site },
mainEntity: {
"@type": "Question",
"@id": entityUrl,
name: input.title,
url: entityUrl,
...(input.text ? { text: input.text } : {}),
dateCreated: input.createdAt,
...(input.updatedAt ? { dateModified: input.updatedAt } : {}),
author: personNode(input.author, urlStyle),
...(input.accepted && input.accepted.content.trim()
? { acceptedAnswer: answerNode(input.accepted) }
: {}),
...(suggested.length ? { suggestedAnswer: suggested.map(answerNode) } : {}),
},
};
}
/** 普通讨论帖。讨论实体地址不含评论分页;分页页用 mainEntityOfPage 指向当前页。 */
export function discussionJsonLd(input: {
title: string;
text?: string;
createdAt: string;
updatedAt?: string;
author: JsonLdPerson;
postId: number;
pageUrl?: string;
commentCount?: number;
viewCount?: number;
comments: JsonLdVisibleComment[];
urlStyle?: UrlStyle;
}): Record<string, unknown> | null {
const urlStyle = input.urlStyle ?? "default";
const entityUrl = absoluteCanonical(postPath(urlStyle, input.postId));
const site = websiteId();
if (!entityUrl || !site) return null;
const pageUrl = input.pageUrl || entityUrl;
const comments = input.comments.filter((c) => c.content.trim());
return {
"@context": "https://schema.org",
"@type": "DiscussionForumPosting",
"@id": entityUrl,
headline: input.title,
url: entityUrl,
...(input.text ? { articleBody: input.text } : {}),
datePublished: input.createdAt,
...(input.updatedAt ? { dateModified: input.updatedAt } : {}),
inLanguage: "zh-CN",
author: personNode(input.author, urlStyle),
isPartOf: { "@id": site },
...(pageUrl !== entityUrl
? {
mainEntityOfPage: {
"@type": "WebPage",
"@id": pageUrl,
url: pageUrl,
},
}
: {}),
...(input.commentCount != null ? { commentCount: input.commentCount } : {}),
...(input.viewCount != null
? {
interactionStatistic: {
"@type": "InteractionCounter",
interactionType: { "@type": "ReadAction" },
userInteractionCount: input.viewCount,
},
}
: {}),
...(comments.length
? {
comment: comments.map((c) => ({
"@type": "Comment",
text: c.content,
dateCreated: c.created_at,
url: commentUrl(entityUrl, c.id),
...(c.author ? { author: personNode(c.author, urlStyle) } : {}),
})),
}
: {}),
};
}
export function profileJsonLd(input: {
id: number;
name: string;
username: string;
signature?: string;
pageUrl?: string;
urlStyle?: UrlStyle;
}): Record<string, unknown> | null {
const personUrl = absoluteCanonical(userPath(input.urlStyle ?? "default", input.id));
if (!personUrl) return null;
const pageUrl = input.pageUrl || personUrl;
return {
"@context": "https://schema.org",
"@type": "ProfilePage",
"@id": pageUrl,
url: pageUrl,
name: input.name,
inLanguage: "zh-CN",
mainEntity: {
"@type": "Person",
name: input.name,
url: personUrl,
alternateName: input.username,
...(input.signature ? { description: input.signature } : {}),
},
};
}
export function webPageJsonLd(input: {
type?: "WebPage" | "Article";
name: string;
description?: string;
path: string;
datePublished?: string;
dateModified?: string;
articleSection?: string;
keywords?: string;
}): Record<string, unknown> | null {
const url = absoluteCanonical(input.path);
const site = websiteId();
if (!url || !site) return null;
return {
"@context": "https://schema.org",
"@type": input.type || "WebPage",
name: input.name,
...(input.type === "Article" ? { headline: input.name } : {}),
url,
...(input.description ? { description: input.description } : {}),
...(input.datePublished ? { datePublished: input.datePublished } : {}),
...(input.dateModified ? { dateModified: input.dateModified } : {}),
...(input.articleSection ? { articleSection: input.articleSection } : {}),
...(input.keywords ? { keywords: input.keywords } : {}),
inLanguage: "zh-CN",
isPartOf: { "@id": site },
};
}