import { absoluteCanonical, resolveSiteOrigin, type QueryBag } from "./siteUrl.ts"; /** 防止用户正文里的 提前闭合 JSON-LD 脚本 */ export function serializeJsonLd(data: unknown): string { return JSON.stringify(data) .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 | 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 | 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 | 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 | 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 | 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 }, }; }