- 原生 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>
98 lines
5.6 KiB
TypeScript
98 lines
5.6 KiB
TypeScript
import { cp, lstat, mkdir, readFile, readdir, realpath, rm, unlink, writeFile } from 'node:fs/promises';
|
|
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
import { parse } from 'dotenv';
|
|
|
|
const environment = process.argv[2] || 'production';
|
|
if (!['development', 'test', 'production'].includes(environment)) throw new Error('Unknown environment');
|
|
const buildDirectory = environment === 'production' ? '.next' : environment === 'test' ? '.next-test' : '.next-dev';
|
|
const destination = resolve('dist', environment);
|
|
const readConfig = async (file: string) => parse(await readFile(file).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === 'ENOENT') return Buffer.alloc(0);
|
|
throw error;
|
|
}));
|
|
const config = { ...await readConfig(resolve('config', environment + '.env')), ...await readConfig(resolve('config', environment + '.local.env')), ...process.env };
|
|
const sitemapDirectory = resolve(config.SITEMAP_DIR?.trim() || './template/pages/sitemaps');
|
|
const inside = (base: string, target: string) => {
|
|
const suffix = relative(base, target);
|
|
return suffix === '' || (suffix !== '..' && !suffix.startsWith('../') && !isAbsolute(suffix));
|
|
};
|
|
// dist is replaceable build output. Never let packaging remove a backend writer's directory.
|
|
const canonicalSitemapDirectory = await realpath(sitemapDirectory).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === 'ENOENT') return sitemapDirectory;
|
|
throw error;
|
|
});
|
|
if (inside(destination, sitemapDirectory) || inside(destination, canonicalSitemapDirectory))
|
|
throw new Error('SITEMAP_DIR must live outside the replaceable standalone package directory');
|
|
for (const directory of [resolve('dist'), destination]) {
|
|
const info = await lstat(directory).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === 'ENOENT') return null;
|
|
throw error;
|
|
});
|
|
if (info && (!info.isDirectory() || info.isSymbolicLink() || await realpath(directory) !== directory))
|
|
throw new Error('Standalone output must be an ordinary build directory');
|
|
}
|
|
await readFile(resolve(buildDirectory, 'BUILD_ID'));
|
|
await rm(destination, { recursive: true, force: true });
|
|
await mkdir(destination, { recursive: true });
|
|
await cp(resolve(buildDirectory, 'standalone'), destination, { recursive: true });
|
|
// Next 16.4 may copy Node Proxy's trace without applying route tracing exclusions.
|
|
// Remove generated XML only from this copied release, never through a backend mount.
|
|
const copiedSitemaps: string[] = [];
|
|
const ordinaryDirectory = async (suffix: string) => {
|
|
let directory = destination;
|
|
for (const segment of ['', ...suffix.split(/[\\/]/).filter(Boolean)]) {
|
|
directory = resolve(directory, segment);
|
|
const info = await lstat(directory).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === 'ENOENT') return null;
|
|
throw error;
|
|
});
|
|
if (!info) return false;
|
|
if (!info.isDirectory() || info.isSymbolicLink() || await realpath(directory) !== directory)
|
|
throw new Error('Sitemap cleanup requires ordinary directories inside the standalone package');
|
|
}
|
|
return true;
|
|
};
|
|
const stageRemoval = async (file: string) => {
|
|
const info = await lstat(file).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === 'ENOENT') return null;
|
|
throw error;
|
|
});
|
|
if (!info) return;
|
|
if (!info.isFile() && !info.isSymbolicLink())
|
|
throw new Error('Packaged sitemap XML must be a file, not a directory or special device');
|
|
copiedSitemaps.push(file);
|
|
};
|
|
for (const suffix of ['template/pages/sitemaps', 'data/sitemaps']) {
|
|
if (!await ordinaryDirectory(suffix)) continue;
|
|
const directory = resolve(destination, suffix);
|
|
for (const name of await readdir(directory)) {
|
|
if (name === 'sitemap.xml' || /^sitemap-[a-z0-9-]+\.xml$/.test(name))
|
|
await stageRemoval(resolve(directory, name));
|
|
}
|
|
}
|
|
const fallback = resolve(destination, 'template/pages/sitemap.xml');
|
|
if (await ordinaryDirectory(relative(destination, dirname(fallback)))) await stageRemoval(fallback);
|
|
// unlink does not follow file symlinks and cannot recursively remove a directory.
|
|
for (const file of copiedSitemaps) await unlink(file);
|
|
await cp(resolve('public'), resolve(destination, 'public'), { recursive: true });
|
|
await cp(resolve(buildDirectory, 'static'), resolve(destination, buildDirectory, 'static'), { recursive: true });
|
|
await mkdir(resolve(destination, 'config'), { recursive: true });
|
|
await cp(resolve('config', environment + '.env'), resolve(destination, 'config', environment + '.env'));
|
|
await cp(resolve('node_modules/dotenv'), resolve(destination, 'node_modules/dotenv'), { recursive: true });
|
|
await writeFile(resolve(destination, 'start.mjs'), `import { readFileSync, existsSync } from 'node:fs';
|
|
import { resolve, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { parse } from 'dotenv';
|
|
process.chdir(dirname(fileURLToPath(import.meta.url)));
|
|
// Next checks the deprecated root static/ before public/static/. An old Go
|
|
// deployment in this directory would silently override the bundled assets.
|
|
if (existsSync(resolve('static'))) throw new Error('Standalone must run in an isolated directory without a legacy static/ folder; it overrides public/static/.');
|
|
const environment = ${JSON.stringify(environment)};
|
|
const read = file => existsSync(file) ? parse(readFileSync(file)) : {};
|
|
const config = { ...read(resolve('config', environment + '.env')), ...read(resolve('config', environment + '.local.env')), ...process.env, APP_ENV: environment, NODE_ENV: 'production' };
|
|
if (!config.UPSTREAM_API_URL?.trim()) throw new Error('UPSTREAM_API_URL is required');
|
|
Object.assign(process.env, config);
|
|
await import('./server.js');
|
|
`);
|
|
console.log(`Standalone package ready: ${destination}; run node start.mjs inside it.`);
|