Files
谢宇宁andClaude Opus 5.5 d2ddb1c321 [Ai] feat: 花魁短剧 Next.js 站点初始提交
- 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。
- 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。
- 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。
- 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。
- 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。
- 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:33:31 +07:00

228 lines
12 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { runInNewContext } from "node:vm";
import { createElement, Fragment } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { NextRequest } from "next/server";
import { createMetadataElements } from "next/dist/lib/metadata/metadata-elements";
import {
unstable_doesMiddlewareMatch as doesProxyMatch,
} from "next/experimental/testing/server";
import nextConfig from "../next.config";
import manifest from "../src/app/manifest";
import { pwaConfig, pwaViewport as viewport } from "../src/lib/pwa/config";
import { config as proxyConfig, proxy } from "../src/proxy";
import { createContext } from "../src/server/context";
import { enrichSEO, toNextMetadata } from "../src/server/seo";
function pngDimensions(source: string): [number, number] {
assert.match(source, /^\/static\/pwa\/[\w-]+\.png$/);
const bytes = readFileSync(new URL("../public" + source, import.meta.url));
assert.deepEqual(bytes.subarray(0, 8), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
assert.equal(bytes.toString("ascii", 12, 16), "IHDR");
return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
}
test("native manifest installs the Chinese brand at the root with fullscreen and standalone support", () => {
const data = manifest();
assert.equal(data.name, "花魁短剧");
assert.equal(data.short_name, "花魁短剧");
assert.equal(data.lang, "zh-CN");
assert.equal(data.id, "/");
assert.equal(data.start_url, "/");
assert.equal(data.scope, "/");
assert.equal(data.display, "fullscreen");
assert.deepEqual(data.display_override, ["fullscreen", "standalone"]);
assert.equal(data.orientation, "any");
assert.equal(data.theme_color, pwaConfig.themeColor);
assert.equal(data.background_color, pwaConfig.themeColor);
assert.equal(pwaConfig.manifest, "/manifest.webmanifest");
assert.equal(pwaConfig.worker, "/sw.js");
});
test("installation icons are real 192/512 PNGs with an independent maskable asset and 180 Apple icon", () => {
const icons = manifest().icons || [];
for (const size of [192, 512]) {
const icon = icons.find((item) => item.sizes === `${size}x${size}` && item.purpose === "any");
assert.ok(icon, `${size}px icon`);
assert.equal(icon.type, "image/png");
assert.deepEqual(pngDimensions(icon.src), [size, size]);
}
const maskable = icons.find((item) => item.purpose === "maskable");
const regular = icons.find((item) => item.sizes === "512x512" && item.purpose === "any");
assert.ok(maskable && regular);
assert.equal(maskable.sizes, "512x512");
assert.equal(maskable.type, "image/png");
assert.deepEqual(pngDimensions(maskable.src), [512, 512]);
assert.notEqual(maskable.src, regular.src);
assert.notDeepEqual(
readFileSync(new URL("../public" + maskable.src, import.meta.url)),
readFileSync(new URL("../public" + regular.src, import.meta.url)),
);
assert.deepEqual(pngDimensions(pwaConfig.icons.apple), [180, 180]);
});
test("global viewport and Apple metadata cover the installed screen on public, private and error pages", () => {
assert.equal(viewport.width, "device-width");
assert.equal(viewport.initialScale, 1);
assert.equal(viewport.viewportFit, "cover");
assert.equal(viewport.themeColor, pwaConfig.themeColor);
const site = "https://canonical.drama.test";
const appConfig = { foreverUrlAddr: site, seoInfo: { webSiteName: "花魁短剧" } };
for (const [route, status] of [["/", 200], ["/drama/story/", 200], ["/account/settings/", 200], ["/missing/", 404]] as const) {
const data = enrichSEO(createContext(new Request(site + route)), { appConfig }, { status });
const metadata = toNextMetadata(data);
assert.equal(metadata.applicationName, "花魁短剧", route);
assert.equal(metadata.manifest, "/manifest.webmanifest", route);
assert.deepEqual(metadata.appleWebApp, {
capable: true,
title: "花魁短剧",
statusBarStyle: "black-translucent",
}, route);
assert.equal(metadata.other?.["apple-mobile-web-app-capable"], "yes", route);
// Exercise Next's actual output: appleWebApp emits the generic capability,
// while Safari still requires the separate Apple-prefixed capability.
const elements = createMetadataElements({
appleWebApp: metadata.appleWebApp,
other: metadata.other,
} as Parameters<typeof createMetadataElements>[0]);
const head = renderToStaticMarkup(createElement(Fragment, null, ...elements));
for (const name of ["mobile-web-app-capable", "apple-mobile-web-app-capable"]) {
assert.equal((head.match(new RegExp(`name="${name}"`, "g")) || []).length, 1, route + " " + name);
assert.match(head, new RegExp(`<meta name="${name}" content="yes"`));
}
assert.deepEqual((metadata.icons as { apple: unknown }).apple, {
url: pwaConfig.icons.apple,
sizes: "180x180",
type: "image/png",
}, route);
}
// Apple capability is generated once through Metadata, never duplicated by a player-only tag.
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
assert.doesNotMatch(layout, /name\s*=\s*["']apple-mobile-web-app-capable["']/);
});
test("PWA metadata preserves canonical, indexing policy, discovery markup and configured verification tags", () => {
const site = "https://canonical.drama.test";
const appConfig = { foreverUrlAddr: site, seoInfo: { title: "花魁短剧", webSiteName: "花魁短剧" } };
const page = enrichSEO(createContext(new Request("http://internal:3000/dramas/all/0/1/page/2/?dc=discard")), {
appConfig,
pagination: { Page: 2, Visible: true, HasPrev: true, PrevURL: "/list/", HasNext: true, NextURL: "/list/all/0/1/page/3/" },
list: [{ id: 41, slug: "story", title: "故事" }, { id: 42, slug: "another", title: "另一部故事" }],
});
const metadata = toNextMetadata(page);
assert.equal(metadata.alternates?.canonical, site + "/dramas/all/0/1/page/2/");
assert.deepEqual(metadata.pagination, { previous: site + "/dramas/", next: site + "/dramas/all/0/1/page/3/" });
assert.equal(metadata.robots, "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1");
assert.equal(metadata.other?.googlebot, page.robots);
assert.equal(metadata.verification?.google, "Y_ldHPiw-yBVgVw_jZ4UG5WJw-xjO31mBMfhflqMGw8");
assert.deepEqual(metadata.verification?.yandex, ["56acf08532be3060", "acaf652efa6cd920"]);
assert.equal(metadata.verification?.other?.["msvalidate.01"], "57A5999E9B5167EE9039265EBCA2AE3A");
assert.ok(JSON.parse(page.jsonLD)["@graph"].some((node: { "@type": string }) => node["@type"] === "ItemList"));
for (const [route, extra, status, robots] of [
["/feedback/", {}, 200, "noindex,nofollow"],
["/navigation/", { isNavPage: true }, 200, "noindex,follow"],
["/missing/", {}, 404, "noindex,follow"],
["/dramas/", {}, 503, "noindex,follow"],
] as const) {
const ctx = createContext(new Request(site + route));
const data = enrichSEO(ctx, { appConfig, ...extra }, { status });
const result = toNextMetadata(data);
assert.equal(result.robots, robots, route);
assert.equal(result.alternates, undefined, route);
assert.equal(data.jsonLD, undefined, route);
assert.equal(ctx.responseHeaders.get("x-robots-tag"), robots.replace(/,/g, ", "), route);
if (route === "/navigation/") {
assert.equal(result.verification?.google, "Y_ldHPiw-yBVgVw_jZ4UG5WJw-xjO31mBMfhflqMGw8");
assert.deepEqual(result.verification?.yandex, ["f5f965a1c5d31a69", "acaf652efa6cd920"]);
}
}
});
test("Next's actual matcher excludes installation assets while retaining normal pages, APIs and signed media", () => {
for (const url of ["/manifest.webmanifest", "/manifest.webmanifest?v=1", "/sw.js", "/sw.js?v=1", "/static/pwa/icon-192.png", "/_next/static/chunk.js"])
assert.equal(doesProxyMatch({ config: proxyConfig, nextConfig, url }), false, url);
for (const url of ["/", "/dramas/", "/account/login/", "/feedback/", "/api/auth/guest", "/h5/m3u8/story.m3u8?ticket=a%2Bb%3D", "/enkey?ticket=1", "/manifest.webmanifest/child", "/sw.js/child"])
assert.equal(doesProxyMatch({ config: proxyConfig, nextConfig, url }), true, url);
});
test("manifest and worker runtime bypass never create guest sessions, cookies or SSR handoffs, including navigation-only mode", async () => {
const originalFetch = globalThis.fetch;
const originalNavigation = process.env.NAVIGATION_ONLY;
let requests = 0;
globalThis.fetch = async () => {
requests++;
throw new Error("Installation assets must not contact the upstream");
};
try {
for (const navigation of ["false", "true"]) {
process.env.NAVIGATION_ONLY = navigation;
for (const path of ["/manifest.webmanifest?dc=pwa-test", "/sw.js?v=pwa-test"])
for (const method of ["GET", "HEAD"]) {
const request = new NextRequest("https://site.test" + path, { method });
const response = await proxy(request);
assert.equal(response.status, 200, navigation + path + method);
assert.equal(response.headers.get("x-middleware-next"), "1");
assert.deepEqual(response.headers.getSetCookie(), []);
assert.equal(response.headers.has("location"), false);
assert.equal(response.headers.has("x-middleware-rewrite"), false);
assert.equal(response.headers.has("x-middleware-override-headers"), false);
for (const name of response.headers.keys())
assert.equal(name.includes("x-hk-"), false, "Unexpected handoff: " + name);
assert.equal(request.headers.get("cookie"), null);
}
}
assert.equal(requests, 0);
} finally {
globalThis.fetch = originalFetch;
if (originalNavigation === undefined) delete process.env.NAVIGATION_ONLY;
else process.env.NAVIGATION_ONLY = originalNavigation;
}
});
test("the generated worker activates without fetch interception, network calls or Cache Storage access", async () => {
type Event = { waitUntil: (promise: Promise<unknown>) => void };
const listeners = new Map<string, (event: Event) => void>();
let waiting = 0;
let claimed = 0;
const worker = {
addEventListener(name: string, listener: (event: Event) => void) {
assert.equal(listeners.has(name), false, "Duplicate worker listener");
listeners.set(name, listener);
},
skipWaiting: async () => { waiting++; },
clients: { claim: async () => { claimed++; } },
};
const forbidden = (name: string) => () => {
assert.fail("The installation worker must not access " + name);
};
const sandbox = { self: worker };
for (const target of [worker, sandbox]) {
Object.defineProperty(target, "caches", { get: forbidden("Cache Storage") });
Object.defineProperty(target, "fetch", { get: forbidden("fetch") });
}
runInNewContext(readFileSync(new URL("../public/sw.js", import.meta.url), "utf8"), sandbox, { timeout: 1000 });
assert.deepEqual([...listeners.keys()].sort(), ["activate", "install"]);
for (const name of ["install", "activate"]) {
const promises: Promise<unknown>[] = [];
listeners.get(name)!({ waitUntil: (promise) => { promises.push(Promise.resolve(promise)); } });
assert.equal(promises.length, 1, name + " must wait for its lifecycle operation");
await Promise.all(promises);
}
assert.equal(waiting, 1);
assert.equal(claimed, 1);
});
test("the worker is served as JavaScript without persistent HTTP caching and with root scope", async () => {
const rules = await nextConfig.headers!();
const worker = rules.find((rule) => rule.source === "/sw.js");
assert.ok(worker);
const headers = new Headers(worker.headers.map(({ key, value }): [string, string] => [key, value]));
assert.match(headers.get("content-type") || "", /^(?:application|text)\/javascript\b/i);
assert.match(headers.get("cache-control") || "", /\bno-store\b/);
assert.match(headers.get("cache-control") || "", /\bno-cache\b/);
assert.equal(headers.get("service-worker-allowed"), "/");
});