refactor: 主题系统 → monorepo 五个独立网站

- packages/core: @aidir/core 底座库(src/pages 页面组件体 1:1 镜像路由树 / kit 站点外壳工厂 / content/blog / scripts)
- sites/: 5 个独立 Next.js 网站(ai-stack / launchai / the-ai-ledger / devai-tools / findai)
  - 每站独立 package.json / next.config / tailwind / messages / .env / 构建
  - src/app 约 56 个薄路由文件(re-export 底座页面 + 内联路由段配置 revalidate/dynamic)
  - src/site/brand.tsx 站点配置 + src/site/home.tsx 站点首页(createMarketingLayout / createHomePage 工厂)
- generate-site.mjs: 站点生成器(新增网站 = SITES 加条目,零 Core 修改)
- start-all-sites.sh: 一键启动 3001-3005
- next-config.mjs: 底座统一 Next 配置工厂(next-intl + MDX + standalone)
- 五站共享同一套 PostgreSQL / Better Auth / Admin(设计文档 §42)
- 验证: 5 站 build OK / typecheck OK / 55 路由冒烟 200 / 品牌-内容-截图 差异确认 / 登录-投票-重复检测 e2e PASS
This commit is contained in:
AI Directory Platform
2026-09-08 19:19:33 +00:00
parent e46b4b52a3
commit 4a1a034386
699 changed files with 19829 additions and 2029 deletions
+705
View File
@@ -0,0 +1,705 @@
/**
* # 脚本 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": {
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: {
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": {
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": {
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: {
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 === "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}"
`
);
// ---------- 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);
}