Files
web_huakui_web/scripts/package-standalone.ts
谢宇宁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

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.`);