公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。
后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
266
frontend/lib/jsonLd.ts
Normal file
266
frontend/lib/jsonLd.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { absoluteCanonical, resolveSiteOrigin, type QueryBag } from "./siteUrl.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) {
|
||||
const url = person.id ? absoluteCanonical(`/u/${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;
|
||||
}): Record<string, unknown> | null {
|
||||
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}/post/${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[];
|
||||
}): Record<string, unknown> | null {
|
||||
const entityUrl = absoluteCanonical(`/post/${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) } : {}),
|
||||
});
|
||||
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),
|
||||
...(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[];
|
||||
}): Record<string, unknown> | null {
|
||||
const entityUrl = absoluteCanonical(`/post/${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),
|
||||
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) } : {}),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function profileJsonLd(input: {
|
||||
id: number;
|
||||
name: string;
|
||||
username: string;
|
||||
signature?: string;
|
||||
pageUrl?: string;
|
||||
}): Record<string, unknown> | null {
|
||||
const personUrl = absoluteCanonical(`/u/${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 },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user