- 登录回跳贯通:邮箱登录成功后按 ?redirect= 回到原页面(投票/收藏/评论的登录拦截闭环) - 收藏按钮的登录跳转补上 redirect 参数 - 营销路由组 loading.tsx 骨架屏(客户端导航即时反馈) - 投票按钮/收藏爱心按下缩放微交互 - 移动端抽屉滑入动画(keyframes + animate-drawer-in) - 工具列表空状态加'清除搜索与筛选'按钮(en/zh) - 品牌 404 页(图标 + Back to home / Browse tools 双入口)
707 lines
26 KiB
JavaScript
707 lines
26 KiB
JavaScript
/**
|
||
* # 脚本 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 === "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}"
|
||
`
|
||
);
|
||
|
||
// ---------- 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);
|
||
}
|