Files
jiang13-bbs/frontend/lib/jsonLd.ts
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

279 lines
8.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { absoluteCanonical, resolveSiteOrigin, type QueryBag } from "./siteUrl.ts";
import { postPath, userPath, commentAnchor, 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;
/** 帖内相对楼层号;评论 URL 锚点使用它而非全局评论 ID */
floor: number;
/** 楼中楼回复需 -r{id} 精确锚定;楼层评论可缺省(默认 true) */
is_root?: boolean;
content: string;
created_at: string;
author?: JsonLdPerson;
};
function commentUrl(entityUrl: string, c: JsonLdVisibleComment): string {
return `${entityUrl}${commentAnchor(c.floor, c.is_root ?? true, c.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),
...(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),
...(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 },
};
}