Files
ai-directory-platform/packages/core/scripts/generate-site.mjs
T
AI Directory Platform 0eed5183b2 feat: 本地 codex 为五站生成品牌 logo 与 favicon
- 每站独立的 SVG 品牌资产(public/icon.svg favicon + public/logo.svg 横版
  lockup),由本地 codex CLI 按各站设计语言手工矢量绘制:
  · AI Stack:海军蓝渐变 + 层叠方块(SaaS)
  · LaunchAI:橙渐变 + 上升火箭(社区)
  · The AI Ledger:墨黑方形 + 衬线 L + 双细线(报纸)
  · DevAI.tools:深底绿色 >_ 终端提示符(CLI)
  · findai:紫底放大镜 + 光点(极简搜索)
- header 品牌区:字母方块 → 生成的 icon.svg 图标(img)
- 生成器:public 目录纳入重建保留清单(codex 品牌资产不再被清空),
  favicon 改为已存在则不覆盖
- 验证:10 个 SVG XML 有效、五站 header 图标全部加载、favicon 200
2026-09-10 12:09:41 +00:00

837 lines
32 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": {
themeId: "ai-stack",
authVariant: "glass",
themeClass: "site-ai-stack",
brandZh: {
name: "AI Stack",
tagline: "由真实用户排名的专业 AI 工具目录。",
headline: "找到最好的 AI 工具,由真实用户排名",
subheadline: "14 个分类、1000+ AI 工具。对比投票、评论与定价,选出最适合你的。",
nav: ["发现", "用例", "最佳清单", "排行榜", "博客", "提交工具"],
footer: ["发现", "社区", "法律"],
searchPlaceholder: "搜索 1000+ AI 工具...",
},
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: {
themeId: "launchai",
authVariant: "gradient",
themeClass: "site-launchai",
brandZh: {
name: "LaunchAI",
tagline: "发现并投票今日的 AI 新品。",
headline: "今日 AI 产品发布",
subheadline: "每天上新。为最好的产品投票,冲上排行榜。",
nav: ["今日", "排行榜", "分类", "合集", "博客", "提交产品"],
footer: ["社区", "探索", "关于"],
searchPlaceholder: "搜索 AI 产品...",
},
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": {
themeId: "the-ai-ledger",
authVariant: "editorial",
themeClass: "site-the-ai-ledger",
brandZh: {
name: "The AI Ledger",
tagline: "独立 AI 报道,附社区排名目录。",
headline: "AI 工具,评测与排名",
subheadline: "指南、对比,以及由社区驱动的最佳 AI 工具目录。",
nav: ["文章", "指南", "最佳推荐", "目录", "排行榜", "提交"],
footer: ["阅读", "目录", "公司"],
searchPlaceholder: "搜索目录...",
},
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": {
themeId: "devai-tools",
authVariant: "terminal",
themeClass: "site-devai-tools",
brandZh: {
name: "DevAI.tools",
tagline: "面向开发者的 AI 工具——精选、实测、社区排名。",
headline: "开发者的 AI 工具",
subheadline: "代码助手、运维自动化、测试副驾驶,你上手的一切。",
nav: ["工具", "工作流", "用例", "排行榜", "提交"],
footer: ["目录", "构建", "法律"],
searchPlaceholder: "搜索开发者工具...",
},
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: {
themeId: "findai",
authVariant: "minimal",
themeClass: "site-findai",
brandZh: {
name: "findai",
tagline: "极简 AI 工具搜索引擎。",
headline: "你想完成什么?",
subheadline: "",
nav: ["搜索", "用例", "排行榜", "提交"],
footer: ["搜索", "关于"],
searchPlaceholder: "你需要什么?",
},
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 });
// 清空生成物,但保留本地环境、依赖与品牌资产(public 内含 codex 生成的 icon/logo)
for (const entry of fs.readdirSync(siteRoot)) {
if (entry === ".env.local" || entry === "node_modules" || entry === "public") 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.brand.headline)}, description: ${JSON.stringify(site.brand.subheadline || site.seo.en.description)} },
common: { brand: { name: ${JSON.stringify(site.brand.name)}, tagline: ${JSON.stringify(site.brand.tagline)} } },
navigation: {
main: { tools: ${JSON.stringify(site.navItems[0]?.label ?? "Discover")}, blog: ${JSON.stringify(site.navItems.find((n) => n.href === "/blog")?.label ?? "Blog")} },
site: { items: ${JSON.stringify(site.navItems.map((n) => n.label))}, hrefs: ${JSON.stringify(site.navItems.map((n) => n.href))} },
},
siteFooter: { groups: ${JSON.stringify(site.footerGroups)} },
siteHeader: { searchPlaceholder: ${JSON.stringify(site.searchPlaceholder)} },
},
zh: {
seo: { home: ${JSON.stringify({ title: site.seo.zh.title, description: site.seo.zh.description, ogImage: "/og-image.png" })} },
hero: { title: ${JSON.stringify(site.brandZh?.headline ?? site.seo.zh.title)}, description: ${JSON.stringify(site.brandZh?.subheadline || site.seo.zh.description)} },
common: { brand: { name: ${JSON.stringify(site.brandZh?.name ?? site.brand.name)}, tagline: ${JSON.stringify(site.brandZh?.tagline ?? site.brand.tagline)} } },
navigation: {
main: { tools: ${JSON.stringify(site.brandZh?.nav?.[0] ?? site.navItems[0]?.label ?? "发现")}, blog: ${JSON.stringify(site.brandZh?.nav?.[site.navItems.findIndex((n) => n.href === "/blog")] ?? "博客")} },
site: { items: ${JSON.stringify(site.brandZh?.nav ?? site.navItems.map((n) => n.label))}, hrefs: ${JSON.stringify(site.navItems.map((n) => n.href))} },
},
siteFooter: { groups: ${JSON.stringify(site.footerGroups.map((g, gi) => ({ ...g, title: (site.brandZh?.footer ?? [])[gi] ?? g.title })))} },
siteHeader: { searchPlaceholder: ${JSON.stringify(site.brandZh?.searchPlaceholder ?? site.searchPlaceholder)} },
},
} 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: locale === "zh" ? (site.brandZh?.headline ?? site.seo.zh.title) : site.brand.headline,
description: locale === "zh" ? (site.brandZh?.subheadline || site.seo.zh.description) : (site.brand.subheadline || site.seo.en.description),
badge: locale === "zh" ? "" : base.hero?.badge ?? "",
};
if (locale === "zh") delete base.hero.badge;
// 站点导航/页脚/搜索文案(供 ThemeChrome 消费)
base.navigation = {
...(base.navigation ?? {}),
site: {
items: locale === "zh" ? (site.brandZh?.nav ?? site.navItems.map((n) => n.label)) : site.navItems.map((n) => n.label),
hrefs: site.navItems.map((n) => n.href),
},
};
base.siteFooter = {
groups: site.footerGroups.map((g, gi) => ({
...g,
title: locale === "zh" ? ((site.brandZh?.footer ?? [])[gi] ?? g.title) : g.title,
})),
};
base.siteHeader = {
searchPlaceholder: locale === "zh" ? (site.brandZh?.searchPlaceholder ?? site.searchPlaceholder) : site.searchPlaceholder,
};
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 ----------
// cpSync 前备份 codex 品牌资产(icon.svg / logo.svg),拷贝后恢复
const brandAssets = ["icon.svg", "logo.svg"];
const backupDir = path.join(siteRoot, ".brand-backup");
for (const asset of brandAssets) {
const assetPath = path.join(siteRoot, "public", asset);
if (fs.existsSync(assetPath)) {
fs.mkdirSync(backupDir, { recursive: true });
fs.copyFileSync(assetPath, path.join(backupDir, asset));
}
}
fs.cpSync(path.join(CORE_ROOT, "public"), path.join(siteRoot, "public"), { recursive: true });
for (const asset of brandAssets) {
const backupPath = path.join(backupDir, asset);
if (fs.existsSync(backupPath)) {
fs.copyFileSync(backupPath, path.join(siteRoot, "public", asset));
fs.rmSync(backupDir, { recursive: true, force: true });
}
}
// 品牌首字母 favicon(SVG,无 Emoji)
const initial = site.brand.name.slice(0, 1).toUpperCase();
// favicon:保留已生成的品牌 icon.svg(codex 生成),不存在时才写字母占位
if (!fs.existsSync(path.join(siteRoot, "public", "icon.svg"))) {
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_VARIANT="${site.authVariant}"
# 站点主题标识(<html data-theme>)与默认深浅(light | dark,用户可切换)
NEXT_PUBLIC_THEME_ID="${site.themeId}"
NEXT_PUBLIC_DEFAULT_THEME="${site.defaultTheme}"
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);
}