公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。
后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
22
frontend/lib/canonicalMeta.ts
Normal file
22
frontend/lib/canonicalMeta.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { headers } from "next/headers";
|
||||
import { canonicalFields, metadataBaseURL, type QueryBag } from "@/lib/siteUrl";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
/**
|
||||
* 请求时再读 SITE_URL,避免预渲染把构建环境的 origin 打进 HTML。
|
||||
* 使用 headers() 而非 connection():后者会把 metadata 推迟到 </head> 之后,
|
||||
* 插件只扫 head 时仍显示 Missing。
|
||||
* 根布局只设 metadataBase,各公开页再调本函数写入自身 Canonical。
|
||||
*/
|
||||
export async function requestCanonical(
|
||||
pathname: string,
|
||||
query?: QueryBag
|
||||
): Promise<Pick<Metadata, "alternates">> {
|
||||
await headers();
|
||||
return canonicalFields(pathname, query);
|
||||
}
|
||||
|
||||
export async function requestMetadataBase(): Promise<URL | undefined> {
|
||||
await headers();
|
||||
return metadataBaseURL();
|
||||
}
|
||||
67
frontend/lib/jsonLd.test.ts
Normal file
67
frontend/lib/jsonLd.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import { discussionJsonLd, questionJsonLd, serializeJsonLd } from "./jsonLd.ts";
|
||||
|
||||
const prev = {
|
||||
SITE_URL: process.env.SITE_URL,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (prev.SITE_URL === undefined) delete process.env.SITE_URL;
|
||||
else process.env.SITE_URL = prev.SITE_URL;
|
||||
process.env.NODE_ENV = prev.NODE_ENV;
|
||||
});
|
||||
|
||||
describe("serializeJsonLd", () => {
|
||||
it("尖括号不会提前闭合 script", () => {
|
||||
const raw = serializeJsonLd({ text: `a</script><script>alert(1)` });
|
||||
assert.equal(raw.includes("</script>"), false);
|
||||
assert.equal(raw.includes("\\u003c"), true);
|
||||
const parsed = JSON.parse(raw);
|
||||
assert.equal(parsed.text, "a</script><script>alert(1)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("questionJsonLd", () => {
|
||||
it("采纳答案不重复进 suggestedAnswer", () => {
|
||||
process.env.SITE_URL = "https://bbs.example.com";
|
||||
process.env.NODE_ENV = "production";
|
||||
const data = questionJsonLd({
|
||||
title: "怎么排序",
|
||||
text: "正文",
|
||||
createdAt: "2026-09-01T00:00:00Z",
|
||||
author: { id: 3, name: "甲" },
|
||||
postId: 9,
|
||||
accepted: { id: 4, content: "用索引", created_at: "2026-09-02T00:00:00Z", author: { id: 5, name: "乙" } },
|
||||
answers: [
|
||||
{ id: 4, content: "用索引", created_at: "2026-09-02T00:00:00Z", author: { id: 5, name: "乙" } },
|
||||
{ id: 6, content: "先 EXPLAIN", created_at: "2026-09-03T00:00:00Z" },
|
||||
],
|
||||
});
|
||||
assert.ok(data);
|
||||
const q = data.mainEntity as { acceptedAnswer: { text: string }; suggestedAnswer: { text: string }[] };
|
||||
assert.equal(q.acceptedAnswer.text, "用索引");
|
||||
assert.deepEqual(q.suggestedAnswer.map((a) => a.text), ["先 EXPLAIN"]);
|
||||
assert.equal(data["@type"], "QAPage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("discussionJsonLd", () => {
|
||||
it("评论分页不改讨论实体地址", () => {
|
||||
process.env.SITE_URL = "https://bbs.example.com";
|
||||
process.env.NODE_ENV = "production";
|
||||
const data = discussionJsonLd({
|
||||
title: "闲聊",
|
||||
createdAt: "2026-09-01T00:00:00Z",
|
||||
author: { id: 1, name: "丙" },
|
||||
postId: 8,
|
||||
pageUrl: "https://bbs.example.com/post/8?page=2",
|
||||
comments: [{ id: 2, content: "可见楼层", created_at: "2026-09-01T01:00:00Z" }],
|
||||
});
|
||||
assert.equal(data?.url, "https://bbs.example.com/post/8");
|
||||
const page = data?.mainEntityOfPage as { url: string };
|
||||
assert.equal(page.url, "https://bbs.example.com/post/8?page=2");
|
||||
assert.equal((data?.comment as { text: string }[])[0].text, "可见楼层");
|
||||
});
|
||||
});
|
||||
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 },
|
||||
};
|
||||
}
|
||||
117
frontend/lib/siteUrl.test.ts
Normal file
117
frontend/lib/siteUrl.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import {
|
||||
absoluteCanonical,
|
||||
canonicalPage,
|
||||
canonicalPath,
|
||||
homeCanonicalQuery,
|
||||
isTrackingParam,
|
||||
parseSiteOrigin,
|
||||
resolveSiteOrigin,
|
||||
} from "./siteUrl.ts";
|
||||
|
||||
const prev = {
|
||||
SITE_URL: process.env.SITE_URL,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (prev.SITE_URL === undefined) delete process.env.SITE_URL;
|
||||
else process.env.SITE_URL = prev.SITE_URL;
|
||||
process.env.NODE_ENV = prev.NODE_ENV;
|
||||
});
|
||||
|
||||
describe("parseSiteOrigin", () => {
|
||||
it("去掉尾斜杠并保留端口", () => {
|
||||
assert.equal(parseSiteOrigin("https://bbs.example.com/"), "https://bbs.example.com");
|
||||
assert.equal(parseSiteOrigin("http://localhost:3000/"), "http://localhost:3000");
|
||||
});
|
||||
it("拒绝 API / 容器名 / 带路径 / 非 http", () => {
|
||||
assert.equal(parseSiteOrigin("http://api:3001"), null);
|
||||
assert.equal(parseSiteOrigin("https://bbs.example.com/forum"), null);
|
||||
assert.equal(parseSiteOrigin("ftp://bbs.example.com"), null);
|
||||
assert.equal(parseSiteOrigin("not-a-url"), null);
|
||||
assert.equal(parseSiteOrigin(""), null);
|
||||
});
|
||||
it("生产态把容器 API 地址视为非法", () => {
|
||||
process.env.SITE_URL = "http://api:3001";
|
||||
process.env.NODE_ENV = "production";
|
||||
assert.equal(resolveSiteOrigin(), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSiteOrigin", () => {
|
||||
it("开发态无配置回退 localhost:3000", () => {
|
||||
delete process.env.SITE_URL;
|
||||
process.env.NODE_ENV = "development";
|
||||
assert.equal(resolveSiteOrigin(), "http://localhost:3000");
|
||||
});
|
||||
it("生产态缺失不回退 localhost", () => {
|
||||
delete process.env.SITE_URL;
|
||||
process.env.NODE_ENV = "production";
|
||||
assert.equal(resolveSiteOrigin(), null);
|
||||
});
|
||||
it("生产态使用 SITE_URL", () => {
|
||||
process.env.SITE_URL = "https://bbs.example.com/";
|
||||
process.env.NODE_ENV = "production";
|
||||
assert.equal(resolveSiteOrigin(), "https://bbs.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalPath", () => {
|
||||
it("首页无尾斜杠参数", () => {
|
||||
assert.equal(canonicalPath("/"), "/");
|
||||
assert.equal(canonicalPath("/post/12/"), "/post/12");
|
||||
});
|
||||
it("省略空值并丢掉追踪参数", () => {
|
||||
assert.equal(
|
||||
canonicalPath("/post/12", { utm_source: "x", fbclid: "1", page: 2, q: "" }),
|
||||
"/post/12?page=2"
|
||||
);
|
||||
});
|
||||
it("保留内容参数", () => {
|
||||
assert.equal(canonicalPath("/", { board: 3, sort: "new", page: 2 }), "/?board=3&page=2&sort=new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalPage", () => {
|
||||
it("第一页与非法页码省略", () => {
|
||||
assert.equal(canonicalPage(undefined), undefined);
|
||||
assert.equal(canonicalPage("1"), undefined);
|
||||
assert.equal(canonicalPage("0"), undefined);
|
||||
assert.equal(canonicalPage("foo"), undefined);
|
||||
assert.equal(canonicalPage("2"), 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTrackingParam", () => {
|
||||
it("识别 utm 与常见点击 id", () => {
|
||||
assert.equal(isTrackingParam("utm_source"), true);
|
||||
assert.equal(isTrackingParam("gclid"), true);
|
||||
assert.equal(isTrackingParam("page"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("absoluteCanonical", () => {
|
||||
it("拼出绝对地址", () => {
|
||||
process.env.SITE_URL = "https://bbs.example.com";
|
||||
process.env.NODE_ENV = "production";
|
||||
assert.equal(absoluteCanonical("/post/9", { page: 2 }), "https://bbs.example.com/post/9?page=2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("homeCanonicalQuery", () => {
|
||||
it("默认排序与第一页省略;搜索丢掉 board", () => {
|
||||
assert.deepEqual(homeCanonicalQuery({}, true), {});
|
||||
assert.deepEqual(homeCanonicalQuery({ sort: "latest", page: "1" }, true), {});
|
||||
assert.deepEqual(homeCanonicalQuery({ sort: "new" }, false), {});
|
||||
assert.deepEqual(
|
||||
homeCanonicalQuery({ q: " go ", board: "3", page: "2" }, true),
|
||||
{ q: "go", page: 2 }
|
||||
);
|
||||
assert.deepEqual(
|
||||
homeCanonicalQuery({ board: "3", sort: "new", page: "2" }, true),
|
||||
{ board: 3, page: 2, sort: "new" }
|
||||
);
|
||||
});
|
||||
});
|
||||
151
frontend/lib/siteUrl.ts
Normal file
151
frontend/lib/siteUrl.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
/**
|
||||
* 公开站点 origin(访客浏览器地址栏),与 Go CORS 的 SITE_URL 同源。
|
||||
* 不使用 BACKEND_URL / NEXT_PUBLIC_API_URL(那是 Go API,可能是容器内网)。
|
||||
*/
|
||||
|
||||
const TRACKING_KEYS = new Set([
|
||||
"gclid",
|
||||
"gbraid",
|
||||
"wbraid",
|
||||
"dclid",
|
||||
"fbclid",
|
||||
"msclkid",
|
||||
"twclid",
|
||||
"ttclid",
|
||||
"yclid",
|
||||
"_ga",
|
||||
"_gl",
|
||||
"mc_cid",
|
||||
"mc_eid",
|
||||
]);
|
||||
|
||||
export type QueryValue = string | number | undefined | null;
|
||||
export type QueryBag = Record<string, QueryValue>;
|
||||
export type FeedSort = "latest" | "new" | "recommended";
|
||||
|
||||
function envOriginRaw(): string {
|
||||
return (process.env.SITE_URL || "").trim();
|
||||
}
|
||||
|
||||
/** 解析为无尾斜杠的 http(s) origin;路径/账号/内部服务名视为非法 */
|
||||
export function parseSiteOrigin(raw: string | undefined | null): string | null {
|
||||
const s = raw?.trim() ?? "";
|
||||
if (!s) return null;
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(s);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||
if (u.username || u.password) return null;
|
||||
const host = u.hostname.toLowerCase();
|
||||
if (!host || host === "api" || host.endsWith(".internal")) return null;
|
||||
const path = u.pathname.replace(/\/+$/, "");
|
||||
if (path !== "") return null;
|
||||
return `${u.protocol}//${u.host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时公开 origin。
|
||||
* 开发态未配置时用 http://localhost:3000;生产态缺失或非法则返回 null(不回退 localhost)。
|
||||
*/
|
||||
export function resolveSiteOrigin(): string | null {
|
||||
const parsed = parseSiteOrigin(envOriginRaw());
|
||||
if (parsed) return parsed;
|
||||
if (process.env.NODE_ENV !== "production") return "http://localhost:3000";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function metadataBaseURL(): URL | undefined {
|
||||
const origin = resolveSiteOrigin();
|
||||
return origin ? new URL(`${origin}/`) : undefined;
|
||||
}
|
||||
|
||||
export function isTrackingParam(key: string): boolean {
|
||||
const k = key.toLowerCase();
|
||||
return k.startsWith("utm_") || TRACKING_KEYS.has(k);
|
||||
}
|
||||
|
||||
/** 路径规范化:保证以 / 开头,除首页外去掉尾斜杠 */
|
||||
export function canonicalPath(pathname: string, query?: QueryBag): string {
|
||||
let path = pathname.trim() || "/";
|
||||
if (!path.startsWith("/")) path = `/${path}`;
|
||||
if (path.length > 1) path = path.replace(/\/+$/, "");
|
||||
const params = new URLSearchParams();
|
||||
if (query) {
|
||||
const keys = Object.keys(query).sort();
|
||||
for (const key of keys) {
|
||||
if (isTrackingParam(key)) continue;
|
||||
const raw = query[key];
|
||||
if (raw == null || raw === "") continue;
|
||||
params.set(key, String(raw));
|
||||
}
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
}
|
||||
|
||||
export function absoluteCanonical(pathname: string, query?: QueryBag): string | undefined {
|
||||
const origin = resolveSiteOrigin();
|
||||
if (!origin) return undefined;
|
||||
return `${origin}${canonicalPath(pathname, query)}`;
|
||||
}
|
||||
|
||||
/** 写入 generateMetadata 的 alternates.canonical;origin 不可用时不加字段 */
|
||||
export function canonicalFields(pathname: string, query?: QueryBag): Pick<Metadata, "alternates"> {
|
||||
const href = absoluteCanonical(pathname, query);
|
||||
if (!href) return {};
|
||||
return { alternates: { canonical: href } };
|
||||
}
|
||||
|
||||
/** 页码:非法或 ≤1 视为首页(省略 page) */
|
||||
export function canonicalPage(raw: string | undefined): number | undefined {
|
||||
const n = parseInt(raw || "1", 10);
|
||||
const page = Number.isFinite(n) ? Math.max(1, n) : 1;
|
||||
return page > 1 ? page : undefined;
|
||||
}
|
||||
|
||||
/** 板块 id:≤0 或非法则省略 */
|
||||
export function canonicalBoard(raw: string | undefined): number | undefined {
|
||||
const n = parseInt(raw || "0", 10);
|
||||
if (!Number.isFinite(n) || n <= 0) return undefined;
|
||||
return n;
|
||||
}
|
||||
|
||||
export function defaultFeedSort(allowComments: boolean): FeedSort {
|
||||
return allowComments ? "latest" : "new";
|
||||
}
|
||||
|
||||
/** 与首页 page.tsx 的排序解析一致 */
|
||||
export function normalizeFeedSort(v: string | undefined, allowComments: boolean): FeedSort {
|
||||
if (v === "recommended") return "recommended";
|
||||
if (v === "new") return "new";
|
||||
if (!allowComments) return "new";
|
||||
if (v === "latest") return "latest";
|
||||
return "latest";
|
||||
}
|
||||
|
||||
/**
|
||||
* 首页 query 归一化:保留 q / 非默认 sort / 有效 board / page>1;
|
||||
* 搜索时 board 不参与列表查询,故省略。
|
||||
*/
|
||||
export function homeCanonicalQuery(
|
||||
sp: { q?: string; page?: string; sort?: string; board?: string },
|
||||
allowComments: boolean
|
||||
): QueryBag {
|
||||
const q = sp.q?.trim() || "";
|
||||
const bag: QueryBag = {};
|
||||
if (q) bag.q = q;
|
||||
const page = canonicalPage(sp.page);
|
||||
if (page) bag.page = page;
|
||||
const sort = normalizeFeedSort(sp.sort, allowComments);
|
||||
if (sort !== defaultFeedSort(allowComments)) bag.sort = sort;
|
||||
if (!q) {
|
||||
const board = canonicalBoard(sp.board);
|
||||
if (board) bag.board = board;
|
||||
}
|
||||
return bag;
|
||||
}
|
||||
Reference in New Issue
Block a user