Files
ai-directory-platform/packages/core/scripts/generate-site.mjs
T
AI Directory Platform 14af0aaa3a feat: 四项体验修复(字号/品牌登录模板/第三方登录/免邮箱验证)
① 全局字号提升一档:body 基准 16.8px,14 个核心组件 99 处 text-xs/text-sm 放大
② 五站五套登录模板:FormShell 品牌分栏布局(左品牌色侧栏+右表单),
   每站独立 accent(近黑/橙红/纸墨棕/终端绿/紫),站点字标替换 Toolso.AI Logo;
   AuthLayout 简化为容器,布局职责下沉 FormShell
③ 第三方登录按钮:未配置 OAuth 凭据时显示 Google/GitHub 按钮,
   走 /api/auth/mock-social 一键创建演示 session(生产配置凭据后自动 403 切真实流程);
   按钮重设计为全宽方形(替换胶囊 Button)
④ 开发阶段免邮箱验证:better-auth databaseHooks 注册即 emailVerified=true,
   sendOnSignUp 仅生产开启;注册成功直接回跳原页面(跳过 check-email)

回归验证:字号 16.8px / 五站品牌色各异 / mock 登录闭环(投票→登录→回原页)/
注册免验证直接进站(emailVerified=t + 跳首页)全部 PASS
2026-09-09 06:02:16 +00:00

718 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # 脚本 generate-site
* 说明:从底座生成一个独立网站应用(sites/<id>/)。
* - src/app/**:全部为薄路由(re-export core/src/pages 的同名模块)
* - (marketing)/layout + 首页:站点组合器(品牌 + 站点首页组件)
* - messages/:core 基础文案 + 站点品牌 SEO 覆盖
* - 配置:独立 package.json / next.config / tsconfig / tailwind / postcss
*
* 用法:bun scripts/generate-site.mjs <siteKey>
* 站点定义见本文件 SITES 常量。
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CORE_ROOT = path.resolve(__dirname, "..");
const REPO_ROOT = path.resolve(CORE_ROOT, "..", "..");
const SITES_ROOT = path.join(REPO_ROOT, "sites");
// ====================================================================
// 站点定义:一个条目 = 一个独立网站(品牌 / 导航 / 页脚 / 首页 / 端口)
// ====================================================================
const SITES = {
"ai-stack": {
accent: "222 47% 11%", // 近黑(专业)
port: 3001,
theme: "classic",
brand: {
name: "AI Stack",
tagline: "The professional AI tools directory — ranked by the community.",
headline: "Find the best AI tools, ranked by real users",
subheadline:
"1,000+ AI tools across 14 categories. Compare votes, reviews and pricing to pick the right one.",
},
navItems: [
{ label: "Discover", href: "/tools" },
{ label: "Use Cases", href: "/ai-tools-for" },
{ label: "Best Lists", href: "/best" },
{ label: "Rankings", href: "/rankings" },
{ label: "Blog", href: "/blog" },
{ label: "Submit", href: "/submit" },
],
footerGroups: [
{
title: "Discover",
links: [
{ label: "All Tools", href: "/tools" },
{ label: "Use Cases", href: "/ai-tools-for" },
{ label: "Best Lists", href: "/best" },
{ label: "Rankings", href: "/rankings" },
],
},
{
title: "Community",
links: [
{ label: "Submit a Tool", href: "/submit" },
{ label: "Blog", href: "/blog" },
{ label: "Dashboard", href: "/dashboard" },
],
},
{
title: "Legal",
links: [
{ label: "Terms", href: "/terms" },
{ label: "Privacy", href: "/privacy" },
{ label: "Contact", href: "/contact" },
],
},
],
searchPlaceholder: "Search AI tools...",
seo: {
en: {
title: "AI Stack — Find the Best AI Tools, Ranked by Real Users",
description:
"The professional AI tools directory. 1,000+ AI tools across 14 categories ranked by community votes, reviews and engagement.",
},
zh: {
title: "AI Stack — 由真实用户排名的 AI 工具目录",
description: "专业 AI 工具导航:14 个分类、1000+ 工具,由社区投票、评论与互动数据实时排名。",
},
},
},
launchai: {
accent: "16 85% 55%", // 橙红(产品社区)
port: 3002,
theme: "product",
brand: {
name: "LaunchAI",
tagline: "Discover and upvote today's AI products.",
headline: "Today's AI product launches",
subheadline: "Fresh AI products, every day. Upvote the best ones to the top.",
},
navItems: [
{ label: "Today", href: "/" },
{ label: "Leaderboard", href: "/rankings" },
{ label: "Categories", href: "/tools" },
{ label: "Collections", href: "/best" },
{ label: "Blog", href: "/blog" },
{ label: "Submit", href: "/submit" },
],
footerGroups: [
{
title: "Community",
links: [
{ label: "Submit a Product", href: "/submit" },
{ label: "Leaderboard", href: "/rankings" },
{ label: "Dashboard", href: "/dashboard" },
],
},
{
title: "Explore",
links: [
{ label: "All Products", href: "/tools" },
{ label: "Use Cases", href: "/ai-tools-for" },
{ label: "Collections", href: "/best" },
],
},
{
title: "About",
links: [
{ label: "Blog", href: "/blog" },
{ label: "Terms", href: "/terms" },
{ label: "Privacy", href: "/privacy" },
],
},
],
searchPlaceholder: "Search AI products...",
seo: {
en: {
title: "LaunchAI — Discover and Upvote Today's AI Products",
description:
"A daily launch feed for AI products. Upvote the best AI tools, follow makers and climb the leaderboard.",
},
zh: {
title: "LaunchAI — 发现并投票今日 AI 新品",
description: "AI 产品每日发布社区:为最好的 AI 工具投票,登上社区排行榜。",
},
},
},
"the-ai-ledger": {
accent: "36 70% 40%", // 纸墨棕(编辑部)
port: 3003,
theme: "editorial",
brand: {
name: "The AI Ledger",
tagline: "Independent journalism on AI tools, with a community-ranked directory.",
headline: "AI tools, reviewed and ranked",
subheadline:
"Guides, comparisons and a community-powered directory of the best AI tools.",
},
navItems: [
{ label: "Stories", href: "/blog" },
{ label: "Guides", href: "/ai-tools-for" },
{ label: "Best Picks", href: "/best" },
{ label: "Directory", href: "/tools" },
{ label: "Rankings", href: "/rankings" },
{ label: "Submit", href: "/submit" },
],
footerGroups: [
{
title: "Read",
links: [
{ label: "Latest Stories", href: "/blog" },
{ label: "Buying Guides", href: "/best" },
{ label: "Use Case Guides", href: "/ai-tools-for" },
],
},
{
title: "Directory",
links: [
{ label: "All Tools", href: "/tools" },
{ label: "Community Rankings", href: "/rankings" },
{ label: "Submit a Tool", href: "/submit" },
],
},
{
title: "Company",
links: [
{ label: "Contact", href: "/contact" },
{ label: "Terms", href: "/terms" },
{ label: "Privacy", href: "/privacy" },
],
},
],
searchPlaceholder: "Search the directory...",
seo: {
en: {
title: "The AI Ledger — AI Tools, Reviewed and Ranked",
description:
"Independent journalism on AI: buying guides, comparisons and a community-ranked directory of the best AI tools.",
},
zh: {
title: "The AI Ledger — AI 工具评测与排名",
description: "独立 AI 媒体:深度指南、横向对比,以及由社区排名的 AI 工具目录。",
},
},
},
"devai-tools": {
accent: "150 60% 30%", // 终端绿(开发者)
port: 3004,
theme: "niche",
brand: {
name: "DevAI.tools",
tagline: "AI tools for developers — curated, tested, ranked by the community.",
headline: "AI tools for developers",
subheadline:
"Code assistants, DevOps automation, testing copilots and everything else you ship with.",
},
navItems: [
{ label: "Tools", href: "/tools" },
{ label: "Workflows", href: "/best" },
{ label: "Use Cases", href: "/ai-tools-for" },
{ label: "Rankings", href: "/rankings" },
{ label: "Submit", href: "/submit" },
],
footerGroups: [
{
title: "Directory",
links: [
{ label: "All Tools", href: "/tools" },
{ label: "Rankings", href: "/rankings" },
{ label: "Use Cases", href: "/ai-tools-for" },
],
},
{
title: "Build",
links: [
{ label: "Workflows", href: "/best" },
{ label: "Submit a Tool", href: "/submit" },
{ label: "Blog", href: "/blog" },
],
},
{
title: "Legal",
links: [
{ label: "Terms", href: "/terms" },
{ label: "Privacy", href: "/privacy" },
],
},
],
searchPlaceholder: "Search dev tools...",
seo: {
en: {
title: "DevAI.tools — AI Tools for Developers",
description:
"Code assistants, DevOps automation and testing copilots — the AI tools developers actually upvote, ranked daily.",
},
zh: {
title: "DevAI.tools — 开发者 AI 工具",
description: "面向开发者的 AI 工具目录:代码助手、运维自动化、测试副驾驶,由社区每日排名。",
},
},
},
findai: {
accent: "260 70% 52%", // 紫(极简搜索)
port: 3005,
theme: "minimal",
brand: {
name: "findai",
tagline: "The minimal AI tool search engine.",
headline: "What are you trying to do?",
subheadline: "",
},
navItems: [
{ label: "Search", href: "/tools" },
{ label: "Use Cases", href: "/ai-tools-for" },
{ label: "Rankings", href: "/rankings" },
{ label: "Submit", href: "/submit" },
],
footerGroups: [
{
title: "Search",
links: [
{ label: "All Tools", href: "/tools" },
{ label: "Use Cases", href: "/ai-tools-for" },
{ label: "Rankings", href: "/rankings" },
],
},
{
title: "About",
links: [
{ label: "Submit a Tool", href: "/submit" },
{ label: "Privacy", href: "/privacy" },
{ label: "Terms", href: "/terms" },
],
},
],
searchPlaceholder: "What do you need?",
seo: {
en: {
title: "findai — The Minimal AI Tool Search Engine",
description:
"One search box, every AI tool. Describe what you're trying to do and find the right AI tool, ranked by the community.",
},
zh: {
title: "findai — 极简 AI 工具搜索引擎",
description: "一个搜索框,找遍全部 AI 工具。说出你想做的事,社区排名替你选。",
},
},
},
};
// ====================================================================
// 生成逻辑
// ====================================================================
/** 遍历 core/src/pages 下所有 page.tsx / route.ts,返回相对路由信息 */
function collectRoutes() {
const pagesRoot = path.join(CORE_ROOT, "src", "pages");
const routes = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (
entry.name === "page.tsx" ||
entry.name === "route.ts" ||
entry.name === "layout.tsx" ||
entry.name === "loading.tsx" ||
entry.name === "not-found.tsx" ||
entry.name === "manifest.ts" ||
entry.name === "sitemap.ts"
) {
const rel = path.relative(pagesRoot, full);
const kind =
entry.name === "route.ts"
? "route"
: entry.name === "layout.tsx"
? "layout"
: "page";
routes.push({ rel, kind, full });
}
}
}
walk(pagesRoot);
return routes;
}
/** 从 core 页面模块源码中提取可 re-export 的导出名 */
/**
* 从 core 页面模块源码中提取导出:
* - 函数导出(default/generateMetadata/generateStaticParams...)→ re-export
* - 路由段配置常量(revalidate/dynamic...)→ 内联字面量(Next 要求静态可解析,不能 re-export)
*/
function extractExports(file, kind) {
const srcText = fs.readFileSync(file, "utf8");
const names = new Set();
const inlineConfigs = [];
if (kind !== "route" && /export\s+default/.test(srcText)) {
names.add("default");
}
const fnRe = /export\s+(?:async\s+)?function\s+([A-Za-z0-9_]+)/g;
for (const m of srcText.matchAll(fnRe)) names.add(m[1]);
const configNames = ["revalidate", "dynamic", "runtime", "fetchCache", "maxDuration", "dynamicParams"];
const constRe = /export\s+const\s+([A-Za-z0-9_]+)\s*=\s*([^;\n]+);/g;
for (const m of srcText.matchAll(constRe)) {
const full = m[0];
const name = m[1];
if (configNames.includes(name)) {
inlineConfigs.push(full);
}
}
// route 的解构导出(如 export const { POST, GET } = ...)→ 整句 re-export
const destructureRe = /export\s+const\s*\{([^}]+)\}\s*=[^;\n]+;/g;
for (const m of srcText.matchAll(destructureRe)) {
for (const raw of m[1].split(",")) {
const name = raw.trim().split(":")[0].trim();
if (/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/.test(name)) names.add(name);
}
}
return {
names: [...names].filter((n) => !n.startsWith("type")),
inlineConfigs,
};
}
function writeFileSafe(file, content) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
}
function generateSite(siteKey) {
const site = SITES[siteKey];
if (!site) throw new Error(`Unknown site: ${siteKey}. 可选: ${Object.keys(SITES).join(", ")}`);
const siteRoot = path.join(SITES_ROOT, siteKey);
console.log(`\n=== 生成站点 ${siteKey} (port ${site.port}, theme ${site.theme}) ===`);
fs.mkdirSync(siteRoot, { recursive: true });
// 清空生成物,但保留本地环境与依赖
for (const entry of fs.readdirSync(siteRoot)) {
if (entry === ".env.local" || entry === "node_modules") continue;
fs.rmSync(path.join(siteRoot, entry), { recursive: true, force: true });
}
const corePkg = "@aidir/core";
// ---------- 1) 薄路由 ----------
const skipRoutes = new Set([
// 站点组合器文件由下方单独生成(营销布局 + 首页)
"[locale]/(marketing)/layout.tsx",
"[locale]/(marketing)/page.tsx",
// 根布局单独生成(需引入站点样式)
"layout.tsx",
]);
let routeCount = 0;
for (const route of collectRoutes()) {
if (skipRoutes.has(route.rel)) continue;
const rel = route.rel.replace(/\\/g, "/");
const target = path.join(siteRoot, "src", "app", rel);
const importPath = `${corePkg}/pages/${rel.replace(/\.tsx?$/, "")}`;
const { names, inlineConfigs } = extractExports(route.full, route.kind);
if (names.length === 0 && inlineConfigs.length === 0) continue;
let content = `// 自动生成:${rel} 的薄路由\n`;
for (const cfg of inlineConfigs) content += `${cfg}\n`;
if (names.length > 0) {
content += `export { ${names.join(", ")} } from "${importPath}";\n`;
}
writeFileSafe(target, content);
routeCount++;
}
// ---------- 2) 根布局(css + 透传) ----------
writeFileSafe(
path.join(siteRoot, "src/app/layout.tsx"),
`import "${corePkg}/styles/globals.css";\n\nexport { default } from "${corePkg}/pages/app-layout";\n`
);
// ---------- 3) 营销布局 + 首页(站点组合器) ----------
writeFileSafe(
path.join(siteRoot, "src/app/[locale]/(marketing)/layout.tsx"),
`import { createMarketingLayout } from "${corePkg}/kit/create-marketing-layout";\nimport { siteConfig } from "@/site/brand";\n\nexport default createMarketingLayout(siteConfig);\n`
);
writeFileSafe(
path.join(siteRoot, "src/app/[locale]/(marketing)/page.tsx"),
`import { createHomePage, createHomeMetadata } from "${corePkg}/kit/create-home";\nimport { siteConfig } from "@/site/brand";\nimport { SiteHome } from "@/site/home";\n\n// 首页内容变化不频繁,ISR 每小时再生\nexport const revalidate = 3600;\n\nexport const generateMetadata = createHomeMetadata(siteConfig);\nexport default createHomePage(siteConfig, SiteHome);\n`
);
// ---------- 4) 站点品牌 + 首页组件 ----------
const themeSrc = fs.readFileSync(
path.join(CORE_ROOT, "src/kit/site-themes", site.theme, "index.tsx"),
"utf8"
);
// 改写 import:kit 路径 + 站点品牌
let homeSrc = themeSrc
.replace(/from "[.\/]*shared\/(section|home-data)"/g, 'from "@/kit/$1"')
.replace(/from "[.\/]*shared\/theme-header"/g, 'from "@/kit/theme-header"')
.replace(/from "[.\/]*shared\/theme-footer"/g, 'from "@/kit/theme-footer"')
.replace(/import type \{ ThemeModule \} from "[.\/]*types";\n/g, "");
// 去掉主题文件里的 brand 常量与尾部主题注册导出(品牌来自 brand.tsx)
homeSrc = homeSrc.replace(/const brand = \{[\s\S]*?\};/, "");
const tailIdx = homeSrc.indexOf("\nexport const ");
if (tailIdx !== -1) homeSrc = homeSrc.slice(0, tailIdx);
// 统一导出名为 SiteHome(原函数如 ClassicHome / ProductHome...)
homeSrc = homeSrc.replace(
/(async\s+function\s+)(\w+Home)(\s*\()/,
"$1SiteHome$3"
);
homeSrc += "\nexport { SiteHome };\n";
// 未使用的 brand 引用替换
homeSrc = homeSrc.replace(/\bbrand\.name\b/g, "siteConfig.brand.name")
.replace(/\bbrand\.headline\b/g, "siteConfig.brand.headline")
.replace(/\bbrand\.subheadline\b/g, "siteConfig.brand.subheadline")
.replace(/\bbrand\.tagline\b/g, "siteConfig.brand.tagline");
// 追加 import siteConfig
const firstImport = homeSrc.indexOf('import ');
homeSrc =
homeSrc.slice(0, firstImport) +
'import { siteConfig } from "@/site/brand";\n' +
homeSrc.slice(firstImport);
homeSrc =
`// 自动生成:站点首页组件(源自底座 ${site.theme} 形态,品牌来自 @/site/brand)\n` +
homeSrc;
writeFileSafe(path.join(siteRoot, "src/site/home.tsx"), homeSrc);
// brand.tsx(SiteConfig,不含 theme 生成元数据)
const { theme: _theme, ...siteConfigData } = site;
siteConfigData.id = siteKey;
writeFileSafe(
path.join(siteRoot, "src/site/brand.tsx"),
`import type { SiteConfig } from "@/kit/types";\n\n// ${site.brand.name} —— 独立站点的全部品牌/导航/页脚配置\nexport const siteConfig: SiteConfig = ${JSON.stringify(siteConfigData, null, 2)};\n`
);
// ---------- 5) i18n request(站点文案覆盖) ----------
writeFileSafe(
path.join(siteRoot, "src/i18n/request.ts"),
`import { getRequestConfig } from "next-intl/server";
import { notFound } from "next/navigation";
import { locales, defaultLocale, type Locale } from "${corePkg}/i18n.config";
/** 站点品牌 SEO 文案覆盖(每个网站不同) */
const brandMessages = {
en: {
seo: { home: ${JSON.stringify({ title: site.seo.en.title, description: site.seo.en.description, ogImage: "/og-image.png" })} },
hero: { title: ${JSON.stringify(site.seo.en.title)}, description: ${JSON.stringify(site.seo.en.description)} },
common: { brand: { name: ${JSON.stringify(site.brand.name)}, tagline: ${JSON.stringify(site.brand.tagline)} } },
},
zh: {
seo: { home: ${JSON.stringify({ title: site.seo.zh.title, description: site.seo.zh.description, ogImage: "/og-image.png" })} },
hero: { title: ${JSON.stringify(site.seo.zh.title)}, description: ${JSON.stringify(site.seo.zh.description)} },
common: { brand: { name: ${JSON.stringify(site.brand.name)}, tagline: ${JSON.stringify(site.brand.tagline)} } },
},
} as const;
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !locales.includes(locale as Locale)) {
locale = defaultLocale;
}
try {
// 底座基础文案 + 站点品牌覆盖(浅合并到顶层命名空间)
const base = (await import(\`${corePkg}/messages/\${locale}.json\`)).default;
const override = brandMessages[locale as keyof typeof brandMessages];
const messages = { ...base };
for (const [ns, value] of Object.entries(override)) {
messages[ns] = { ...(messages[ns] ?? {}), ...value };
}
return { locale, messages };
} catch (error) {
console.error("Error loading messages for locale:", locale, error);
notFound();
}
});
`
);
// ---------- 6) proxy / mdx-components ----------
writeFileSafe(
path.join(siteRoot, "src/proxy.ts"),
`import { proxy } from "${corePkg}/proxy";\n\nexport default proxy;\n\nexport const config = {\n matcher: [\n "/",\n "/((?!api|_next|_vercel|.*\\\\..*).*)"\n ]\n};\n`
);
writeFileSafe(
path.join(siteRoot, "mdx-components.tsx"),
`export { useMDXComponents } from "${corePkg}/src/mdx-components";\n`
);
// ---------- 7) 配置文件 ----------
writeFileSafe(
path.join(siteRoot, "package.json"),
JSON.stringify(
{
name: `site-${siteKey}`,
version: "1.0.0",
description: `${site.brand.name} — AI directory site powered by @aidir/core`,
private: true,
scripts: {
dev: `next dev -p ${site.port}`,
build: "next build",
start: `next start -p ${site.port}`,
typecheck: "tsc --noEmit",
},
dependencies: {
"@aidir/core": "workspace:*",
next: "16.1.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"next-intl": "^4.7.0",
"next-themes": "^0.4.6",
"next-view-transitions": "^0.3.5",
"sonner": "^2.0.7",
"geist": "^1.3.0",
"lucide-react": "^0.562.0",
"framer-motion": "^12.43.0",
},
devDependencies: {
"@types/node": "^20",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/mdx": "^2.0.13",
"typescript": "^5",
"tailwindcss": "^3.4.1",
"postcss": "^8",
},
},
null,
2
) + "\n"
);
writeFileSafe(
path.join(siteRoot, "next.config.mjs"),
`import createSiteConfig from "@aidir/core/next-config";\n\nexport default createSiteConfig({\n requestPath: "./src/i18n/request.ts",\n});\n`
);
writeFileSafe(
path.join(siteRoot, "tsconfig.json"),
JSON.stringify(
{
compilerOptions: {
target: "ES2017",
lib: ["dom", "dom.iterable", "esnext"],
allowJs: true,
skipLibCheck: true,
strict: true,
noEmit: true,
esModuleInterop: true,
module: "esnext",
moduleResolution: "bundler",
resolveJsonModule: true,
isolatedModules: true,
jsx: "preserve",
incremental: true,
plugins: [{ name: "next" }],
paths: {
"@/*": ["./src/*", "../../packages/core/src/*"],
"@aidir/core/pages/*": ["../../packages/core/src/pages/*"],
"@aidir/core/kit/*": ["../../packages/core/src/kit/*"],
"@aidir/core/src/*": ["../../packages/core/src/*"],
"@aidir/core/proxy": ["../../packages/core/src/proxy.ts"],
"@aidir/core/i18n.config": ["../../packages/core/src/i18n.config.ts"],
"@aidir/core/styles/globals.css": ["../../packages/core/src/styles/globals.css"],
"@aidir/core/*": ["../../packages/core/src/*", "../../packages/core/*"],
},
},
include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
exclude: ["node_modules"],
},
null,
2
) + "\n"
);
writeFileSafe(
path.join(siteRoot, "postcss.config.mjs"),
`const config = {\n plugins: {\n tailwindcss: {},\n },\n};\n\nexport default config;\n`
);
// tailwind:content 扫描站点 + 底座;主题/插件来自底座 preset
writeFileSafe(
path.join(siteRoot, "tailwind.config.ts"),
`import type { Config } from "tailwindcss";\n\nconst config: Config = {\n content: [\n "./src/**/*.{js,ts,jsx,tsx,mdx}",\n "../../packages/core/src/**/*.{js,ts,jsx,tsx,mdx}",\n ],\n presets: [require("../../packages/core/tailwind.preset.cjs")],\n};\n\nexport default config;\n`
);
// ---------- 8) messages(base 由 i18n request 运行时合并;站点仍保留一份覆盖说明) ----------
// 静态拷贝 core messages 便于独立部署检查
for (const locale of ["en", "zh"]) {
const base = JSON.parse(fs.readFileSync(path.join(CORE_ROOT, "messages", `${locale}.json`), "utf8"));
// 品牌覆盖(静态合并进 messages,运行时 request.ts 仍会覆盖以双保险)
base.common = base.common ?? {};
base.common.brand = { ...(base.common.brand ?? {}), name: site.brand.name, tagline: site.brand.tagline };
base.seo = base.seo ?? {};
base.seo.home = { ...(base.seo.home ?? {}), ...site.seo[locale] };
base.hero = { ...(base.hero ?? {}), title: site.seo[locale].title, description: site.seo[locale].description };
fs.mkdirSync(path.join(siteRoot, "messages"), { recursive: true });
fs.writeFileSync(
path.join(siteRoot, "messages", `${locale}.json`),
JSON.stringify(base, null, 2) + "\n"
);
}
// 但 request.ts 目前从 core 读基础文案 —— 改为读本站 messages(保证站点内容独立可改)
let requestSrc = fs.readFileSync(path.join(siteRoot, "src/i18n/request.ts"), "utf8");
requestSrc = requestSrc.replace(
'const base = (await import(`@aidir/core/messages/${locale}.json`)).default;',
'const base = (await import(`../../messages/${locale}.json`)).default;'
);
writeFileSafe(path.join(siteRoot, "src/i18n/request.ts"), requestSrc);
// ---------- 9) public + env ----------
fs.cpSync(path.join(CORE_ROOT, "public"), path.join(siteRoot, "public"), { recursive: true });
// 品牌首字母 favicon(SVG,无 Emoji)
const initial = site.brand.name.slice(0, 1).toUpperCase();
writeFileSafe(
path.join(siteRoot, "public", "icon.svg"),
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="currentColor"/><text x="32" y="44" font-family="system-ui,sans-serif" font-size="36" font-weight="700" text-anchor="middle" fill="#fff">${initial}</text></svg>\n`
);
writeFileSafe(
path.join(siteRoot, ".env.example"),
`# ${site.brand.name} 环境变量(与底座共享同一套 PostgreSQL / Better Auth / Admin)
DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:15433/ai_directory"
BETTER_AUTH_SECRET="dev-secret-ai-directory-platform-0123456789abcdef"
BETTER_AUTH_URL="http://localhost:${site.port}"
AUTH_GOOGLE_ID=""
AUTH_GOOGLE_SECRET=""
NEXT_PUBLIC_AUTH_GOOGLE_ENABLED="false"
AUTH_GITHUB_ID=""
AUTH_GITHUB_SECRET=""
NEXT_PUBLIC_AUTH_GITHUB_ENABLED="false"
RESEND_API_KEY=""
RESEND_FROM_EMAIL="${site.brand.name} <noreply@localhost>"
NEXT_PUBLIC_APP_URL="http://localhost:${site.port}"
NEXT_PUBLIC_APP_NAME="${site.brand.name}"
# 登录页品牌模板(分栏侧栏颜色与标语)
NEXT_PUBLIC_AUTH_ACCENT="${site.accent}"
NEXT_PUBLIC_AUTH_BRAND_TAGLINE="${site.brand.tagline}"
# 开发阶段免邮箱验证(生产置 false 发送真实验证邮件)
DEV_SKIP_EMAIL_VERIFY="true"
NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY="true"
`
);
// ---------- 10) .gitignore ----------
writeFileSafe(
path.join(siteRoot, ".gitignore"),
"/node_modules\n/.next\n/.env*.local\nnext-env.d.ts\n*.tsbuildinfo\n"
);
console.log(`路由薄文件: ${routeCount + 2}(含根布局/营销布局/首页组合器)`);
console.log(`输出: sites/${siteKey}`);
}
// main
const key = process.argv[2];
if (!key || key === "all") {
for (const k of Object.keys(SITES)) generateSite(k);
} else {
generateSite(key);
}