Commit 56bbab01 authored by xuchentao's avatar xuchentao

feat: 外观模版设计包架构与创建双模式

- 模版目录支持 manifest + src/ 设计包,物化时叠加到内核之上(含 _platform/content.config.ts 防护)
- 创建支持 copy(确定性直出)/ agent(深度重组)双模式,失败保留基础成品
- legacy 字段可选化不删除,6 个旧 JSON 模版行为不变
- 契约文档重写:docs/site-template-architecture.md
parent 84a1fbdf
...@@ -15,17 +15,18 @@ export const createSiteSchema = z.object({ ...@@ -15,17 +15,18 @@ export const createSiteSchema = z.object({
brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "品牌色必须是 6 位十六进制颜色"), brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "品牌色必须是 6 位十六进制颜色"),
fontId: z.string().regex(/^[a-z0-9-]+$/), fontId: z.string().regex(/^[a-z0-9-]+$/),
fontVersion: z.string().regex(/^\d+\.\d+\.\d+$/), fontVersion: z.string().regex(/^\d+\.\d+\.\d+$/),
layoutPresetId: z.string().regex(/^[a-z0-9-]+$/), layoutPresetId: z.string().regex(/^[a-z0-9-]+$/).optional(),
layoutPresetVersion: z.string().regex(/^\d+\.\d+\.\d+$/), layoutPresetVersion: z.string().regex(/^\d+\.\d+\.\d+$/).optional(),
layout: z.object({ layout: z.object({
hero: z.enum(["split", "centered"]), hero: z.enum(["split", "centered"]).optional(),
density: z.enum(["comfortable", "compact"]), density: z.enum(["comfortable", "compact"]).optional(),
radius: z.enum(["soft", "square"]), radius: z.enum(["soft", "square"]).optional(),
gradientEmphasis: z.boolean().default(false), gradientEmphasis: z.boolean().default(false),
announcementBar: z.boolean().default(false), announcementBar: z.boolean().default(false),
}), }),
}), }),
initialPrompt: z.string().trim().max(2000).optional().or(z.literal("").transform(() => undefined)), initialPrompt: z.string().trim().max(2000).optional().or(z.literal("").transform(() => undefined)),
mode: z.enum(["copy", "agent"]).optional(),
}); });
export const companyDocumentExtractSchema = z.object({ export const companyDocumentExtractSchema = z.object({
......
...@@ -32,9 +32,10 @@ export class CreateSiteService { ...@@ -32,9 +32,10 @@ export class CreateSiteService {
kernel: selection.kernel, kernel: selection.kernel,
createdFrom: { createdFrom: {
pageTemplateId: selection.template.id, pageTemplateVersion: selection.template.version, pageTemplateId: selection.template.id, pageTemplateVersion: selection.template.version,
design: { themeId: selection.theme.id, themeVersion: selection.theme.version, brandColor: input.design.brandColor, fontId: selection.font.id, fontVersion: selection.font.version, layoutPresetId: selection.layoutPreset.id, layoutPresetVersion: selection.layoutPreset.version, layout: input.design.layout }, design: { themeId: selection.theme.id, themeVersion: selection.theme.version, brandColor: input.design.brandColor, fontId: selection.font.id, fontVersion: selection.font.version, ...(selection.layoutPreset ? { layoutPresetId: selection.layoutPreset.id, layoutPresetVersion: selection.layoutPreset.version } : {}), layout: input.design.layout },
}, },
...(input.initialPrompt ? { initialPrompt: input.initialPrompt } : {}), ...(input.initialPrompt ? { initialPrompt: input.initialPrompt } : {}),
...(input.mode ? { creationMode: input.mode } : {}),
}, },
previewPort, previewUrl: getPublicPreviewUrl(tenantId, siteId), currentCommit: "", publishStatus: "unpublished", previewPort, previewUrl: getPublicPreviewUrl(tenantId, siteId), currentCommit: "", publishStatus: "unpublished",
createdAt: now, updatedAt: now, createdAt: now, updatedAt: now,
......
import assert from "node:assert/strict";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { config } from "../config.js";
import { materializeTemplateProject } from "./materialize-template-project.js";
import { SiteTemplateCatalogService } from "./site-template-catalog.js";
type Selection = Awaited<ReturnType<SiteTemplateCatalogService["resolve"]>>;
async function baseSelection(service: SiteTemplateCatalogService): Promise<Selection> {
return service.resolve("technology-corporate", "1.0.0", {
themeId: "violet-tech", themeVersion: "1.0.0", brandColor: "#7028FF",
fontId: "modern-sans", fontVersion: "1.0.0",
layoutPresetId: "modern-split", layoutPresetVersion: "1.0.0",
layout: { hero: "split", density: "comfortable", radius: "soft", gradientEmphasis: false, announcementBar: false },
});
}
function designPackageSelection(legacy: Selection, fixtureRoot: string): Selection {
return {
...legacy,
template: { ...legacy.template, content: undefined, preview: { accent: "#111111", surface: "#ffffff", mode: "light" } },
layoutPreset: undefined,
sourceDir: fixtureRoot,
hasDesignPackage: true,
};
}
const siteInfo = { name: "示例企业", industry: "企业服务", description: "这是一段足够长的企业简介,用于注入站点数据。", email: "hi@example.com" };
test("materialize overlays design package sources and injects site data", async () => {
const service = new SiteTemplateCatalogService();
const fixtureRoot = await mkdtemp(path.join(tmpdir(), "webagent-template-"));
const target = await mkdtemp(path.join(tmpdir(), "webagent-site-"));
try {
await mkdir(path.join(fixtureRoot, "src/pages"), { recursive: true });
await mkdir(path.join(fixtureRoot, "src/data"), { recursive: true });
await writeFile(path.join(fixtureRoot, "src/pages/index.astro"), "---\n---\n<h1>FIXTURE HOMEPAGE</h1>\n", "utf8");
await writeFile(path.join(fixtureRoot, "src/data/home.json"), JSON.stringify({ services: [], customSection: { title: "自定义区块" } }) + "\n", "utf8");
await materializeTemplateProject(target, {
templateDir: config.templateDir, selection: designPackageSelection(await baseSelection(service), fixtureRoot), templates: service,
brandColor: "#7028FF", layout: { gradientEmphasis: false, announcementBar: true },
site: siteInfo, services: ["咨询服务", "交付服务"],
});
assert.match(await readFile(path.join(target, "src/pages/index.astro"), "utf8"), /FIXTURE HOMEPAGE/, "模版 src 应覆盖内核同名文件");
const home = JSON.parse(await readFile(path.join(target, "src/data/home.json"), "utf8"));
assert.equal(home.services.length, 2, "services 契约注入");
assert.equal(home.services[0].title, "咨询服务");
assert.equal(home.customSection.title, "自定义区块", "模版自带字段保留");
assert.equal(home.heroTitle, undefined, "设计包模版不注入 manifest content");
const design = JSON.parse(await readFile(path.join(target, "src/data/design.json"), "utf8"));
assert.equal(design.layoutPresetId, undefined, "设计包不写布局预设");
assert.deepEqual(design.layout, { gradientEmphasis: false, announcementBar: true });
const site = JSON.parse(await readFile(path.join(target, "src/data/site.json"), "utf8"));
assert.equal(site.name, "示例企业");
assert.match(await readFile(path.join(target, "src/styles/theme.css"), "utf8"), /--color-primary: #7028ff/i);
assert.match(await readFile(path.join(target, "src/styles/fonts.css"), "utf8"), /--font-heading:/);
} finally {
await rm(fixtureRoot, { recursive: true, force: true });
await rm(target, { recursive: true, force: true });
}
});
test("materialize rejects design packages that override the platform kernel", async () => {
const service = new SiteTemplateCatalogService();
const fixtureRoot = await mkdtemp(path.join(tmpdir(), "webagent-template-"));
const target = await mkdtemp(path.join(tmpdir(), "webagent-site-"));
try {
await mkdir(path.join(fixtureRoot, "src/_platform"), { recursive: true });
await writeFile(path.join(fixtureRoot, "src/_platform/evil.ts"), "export {};\n", "utf8");
await assert.rejects(
materializeTemplateProject(target, {
templateDir: config.templateDir, selection: designPackageSelection(await baseSelection(service), fixtureRoot), templates: service,
brandColor: "#7028FF", layout: { gradientEmphasis: false, announcementBar: true },
site: siteInfo, services: ["咨询服务"],
}),
/_platform/,
);
} finally {
await rm(fixtureRoot, { recursive: true, force: true });
await rm(target, { recursive: true, force: true });
}
});
import path from "node:path"; import path from "node:path";
import { cp, readFile, rm, writeFile } from "node:fs/promises"; import { cp, readFile, rm, stat, writeFile } from "node:fs/promises";
import type { SiteDesignParameters } from "@webagent/shared"; import type { SiteDesignParameters } from "@webagent/shared";
import type { SiteTemplateCatalogService } from "./site-template-catalog.js"; import type { SiteTemplateCatalogService } from "./site-template-catalog.js";
...@@ -15,9 +15,20 @@ export interface MaterializeTemplateProjectOptions { ...@@ -15,9 +15,20 @@ export interface MaterializeTemplateProjectOptions {
services: string[]; services: string[];
} }
/** 设计包 src/ 顶层禁止出现的条目:平台内核不可覆盖。 */
const FORBIDDEN_PACKAGE_ENTRIES = ["_platform", "content.config.ts"];
export async function materializeTemplateProject(targetPath: string, options: MaterializeTemplateProjectOptions): Promise<void> { export async function materializeTemplateProject(targetPath: string, options: MaterializeTemplateProjectOptions): Promise<void> {
const { selection } = options; const { selection } = options;
await cp(options.templateDir, targetPath, { recursive: true, filter: (source) => !["node_modules", "dist", ".astro", ".git", "pnpm-lock.yaml", "examples"].includes(path.basename(source)) }); await cp(options.templateDir, targetPath, { recursive: true, filter: (source) => !["node_modules", "dist", ".astro", ".git", "pnpm-lock.yaml", "examples"].includes(path.basename(source)) });
if (selection.hasDesignPackage) {
const packageSrc = path.join(selection.sourceDir, "src");
for (const entry of FORBIDDEN_PACKAGE_ENTRIES) {
const exists = await stat(path.join(packageSrc, entry)).then(() => true).catch(() => false);
if (exists) throw new Error(`设计包不允许包含 ${entry},平台内核不可覆盖`);
}
await cp(packageSrc, path.join(targetPath, "src"), { recursive: true });
}
await rm(path.join(targetPath, "src/data/company.json"), { force: true }); await rm(path.join(targetPath, "src/data/company.json"), { force: true });
const siteDataPath = path.join(targetPath, "src/data/site.json"); const siteDataPath = path.join(targetPath, "src/data/site.json");
const siteData = JSON.parse(await readFile(siteDataPath, "utf8")) as { const siteData = JSON.parse(await readFile(siteDataPath, "utf8")) as {
...@@ -37,14 +48,16 @@ export async function materializeTemplateProject(targetPath: string, options: Ma ...@@ -37,14 +48,16 @@ export async function materializeTemplateProject(targetPath: string, options: Ma
await writeFile(siteDataPath, JSON.stringify(siteData, null, 2) + "\n", "utf8"); await writeFile(siteDataPath, JSON.stringify(siteData, null, 2) + "\n", "utf8");
const homePath = path.join(targetPath, "src/data/home.json"); const homePath = path.join(targetPath, "src/data/home.json");
const home = JSON.parse(await readFile(homePath, "utf8")) as Record<string, unknown> & { services: Array<{ number: string; title: string; description: string }> }; const home = JSON.parse(await readFile(homePath, "utf8")) as Record<string, unknown> & { services: Array<{ number: string; title: string; description: string }> };
Object.assign(home, selection.template.content); if (selection.template.content) Object.assign(home, selection.template.content);
home.services = options.services.map((service, index) => ({ number: String(index + 1).padStart(2, "0"), title: service, description: "以专业方法和可靠交付,为客户提供高质量的" + service + "服务。" })); home.services = options.services.map((service, index) => ({ number: String(index + 1).padStart(2, "0"), title: service, description: "以专业方法和可靠交付,为客户提供高质量的" + service + "服务。" }));
await writeFile(homePath, JSON.stringify(home, null, 2) + "\n", "utf8"); await writeFile(homePath, JSON.stringify(home, null, 2) + "\n", "utf8");
await writeFile(path.join(targetPath, "src/styles/theme.css"), options.templates.renderTheme(selection.theme, options.brandColor, options.layout.gradientEmphasis), "utf8"); await writeFile(path.join(targetPath, "src/styles/theme.css"), options.templates.renderTheme(selection.theme, options.brandColor, options.layout.gradientEmphasis), "utf8");
await writeFile(path.join(targetPath, "src/styles/fonts.css"), options.templates.renderFont(selection.font), "utf8"); await writeFile(path.join(targetPath, "src/styles/fonts.css"), options.templates.renderFont(selection.font), "utf8");
await writeFile(path.join(targetPath, "src/data/design.json"), JSON.stringify({ await writeFile(path.join(targetPath, "src/data/design.json"), JSON.stringify({
pageTemplate: { id: selection.template.id, version: selection.template.version }, pageTemplate: { id: selection.template.id, version: selection.template.version },
themeId: selection.theme.id, themeVersion: selection.theme.version, fontId: selection.font.id, fontVersion: selection.font.version, layoutPresetId: selection.layoutPreset.id, layoutPresetVersion: selection.layoutPreset.version, layout: options.layout, themeId: selection.theme.id, themeVersion: selection.theme.version, fontId: selection.font.id, fontVersion: selection.font.version,
...(selection.layoutPreset ? { layoutPresetId: selection.layoutPreset.id, layoutPresetVersion: selection.layoutPreset.version } : {}),
layout: options.layout,
...(selection.template.preview.heroImage ? { heroImage: selection.template.preview.heroImage } : {}), ...(selection.template.preview.heroImage ? { heroImage: selection.template.preview.heroImage } : {}),
}, null, 2) + "\n", "utf8"); }, null, 2) + "\n", "utf8");
} }
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import test from "node:test"; import test from "node:test";
import { config } from "../config.js";
import { SiteTemplateCatalogService } from "./site-template-catalog.js"; import { SiteTemplateCatalogService } from "./site-template-catalog.js";
test("catalog exposes the stable kernel and published page templates", async () => { test("catalog exposes the stable kernel and published page templates", async () => {
...@@ -15,7 +18,7 @@ test("catalog exposes the stable kernel and published page templates", async () ...@@ -15,7 +18,7 @@ test("catalog exposes the stable kernel and published page templates", async ()
assert.equal(catalog.fonts.length, 2); assert.equal(catalog.fonts.length, 2);
assert.equal(catalog.layoutPresets.length, 4); assert.equal(catalog.layoutPresets.length, 4);
assert.ok(catalog.themes[0].primary && catalog.themes[0].soft, "目录应暴露完整色板供实时预览"); assert.ok(catalog.themes[0].primary && catalog.themes[0].soft, "目录应暴露完整色板供实时预览");
assert.ok(catalog.pageTemplates[0].content.heroTitle, "目录应暴露模版默认文案供实时预览"); assert.ok(catalog.pageTemplates[0].content?.heroTitle, "目录应暴露模版默认文案供实时预览");
for (const theme of catalog.themes) { for (const theme of catalog.themes) {
for (const key of ["primary", "hover", "light", "secondary", "dark", "background", "soft", "text", "textSecondary", "muted", "border"] as const) { for (const key of ["primary", "hover", "light", "secondary", "dark", "background", "soft", "text", "textSecondary", "muted", "border"] as const) {
assert.match(theme[key], /^#[0-9a-f]{6}$/, `${theme.id}.${key} 应为合法 hex 颜色`); assert.match(theme[key], /^#[0-9a-f]{6}$/, `${theme.id}.${key} 应为合法 hex 颜色`);
...@@ -33,6 +36,9 @@ test("catalog resolves only supported template, theme and layout combinations", ...@@ -33,6 +36,9 @@ test("catalog resolves only supported template, theme and layout combinations",
assert.equal(selection.template.id, "technology-corporate"); assert.equal(selection.template.id, "technology-corporate");
assert.equal(selection.theme.id, "ocean-blue"); assert.equal(selection.theme.id, "ocean-blue");
assert.equal(selection.font.id, "modern-sans"); assert.equal(selection.font.id, "modern-sans");
assert.equal(selection.hasDesignPackage, false, "旧 JSON 模版没有设计包");
assert.ok(selection.sourceDir.endsWith(path.join("page-templates", "technology-corporate", "1.0.0")), "resolve 应返回模版源码目录");
assert.ok(selection.layoutPreset, "旧模版仍返回布局预设");
const freeCombination = await service.resolve("technology-corporate", "1.0.0", { themeId: "warm-amber", themeVersion: "1.0.0", brandColor: "#C2410C", fontId: "elegant-serif", fontVersion: "1.0.0", layoutPresetId: "modern-split", layoutPresetVersion: "1.0.0", layout: { const freeCombination = await service.resolve("technology-corporate", "1.0.0", { themeId: "warm-amber", themeVersion: "1.0.0", brandColor: "#C2410C", fontId: "elegant-serif", fontVersion: "1.0.0", layoutPresetId: "modern-split", layoutPresetVersion: "1.0.0", layout: {
hero: "split", density: "comfortable", radius: "soft", gradientEmphasis: false, announcementBar: false, hero: "split", density: "comfortable", radius: "soft", gradientEmphasis: false, announcementBar: false,
} }); } });
...@@ -72,3 +78,27 @@ test("theme renderer only emits gradients when explicitly emphasized", async () ...@@ -72,3 +78,27 @@ test("theme renderer only emits gradients when explicitly emphasized", async ()
} }); } });
assert.match(service.renderTheme(theme, "#7028FF", true), /--gradient-primary: linear-gradient/); assert.match(service.renderTheme(theme, "#7028FF", true), /--gradient-primary: linear-gradient/);
}); });
test("resolve skips layout validation for design-package templates", async () => {
const service = new SiteTemplateCatalogService();
const fixtureRoot = path.join(config.siteKitDir, "page-templates", "zz-design-package-fixture");
await mkdir(path.join(fixtureRoot, "9.9.9", "src", "pages"), { recursive: true });
await writeFile(path.join(fixtureRoot, "9.9.9", "manifest.json"), JSON.stringify({
id: "zz-design-package-fixture", version: "9.9.9", name: "设计包测试夹具", description: "测试用,勿发布", industries: ["测试"],
status: "published", compatibleKernel: "1.x",
preview: { accent: "#111111", surface: "#ffffff", mode: "light" },
defaultThemeId: "ocean-blue", defaultFontId: "modern-sans",
}) + "\n", "utf8");
try {
const selection = await service.resolve("zz-design-package-fixture", "9.9.9", {
themeId: "ocean-blue", themeVersion: "1.0.0", brandColor: "#175CD3",
fontId: "modern-sans", fontVersion: "1.0.0",
layout: { gradientEmphasis: false, announcementBar: true },
});
assert.equal(selection.hasDesignPackage, true, "含 src/ 的模版应识别为设计包");
assert.equal(selection.layoutPreset, undefined, "设计包模版不解析布局预设");
assert.ok(selection.sourceDir.endsWith(path.join("page-templates", "zz-design-package-fixture", "9.9.9")));
} finally {
await rm(fixtureRoot, { recursive: true, force: true });
}
});
import path from "node:path"; import path from "node:path";
import { readFile, readdir } from "node:fs/promises"; import { readFile, readdir, stat } from "node:fs/promises";
import type { FontPresetInfo, LayoutPresetInfo, PageTemplateInfo, SiteDesignParameters, SiteTemplateCatalog, ThemeFamilyInfo, ThemePresetInfo } from "@webagent/shared"; import type { FontPresetInfo, LayoutPresetInfo, PageTemplateInfo, SiteDesignParameters, SiteTemplateCatalog, ThemeFamilyInfo, ThemePresetInfo } from "@webagent/shared";
import { config } from "../config.js"; import { config } from "../config.js";
import { expandThemeVariant, type ThemeFamilyDefinition } from "./theme-palette.js"; import { expandThemeVariant, type ThemeFamilyDefinition } from "./theme-palette.js";
...@@ -32,13 +32,18 @@ export class SiteTemplateCatalogService { ...@@ -32,13 +32,18 @@ export class SiteTemplateCatalogService {
if (!theme) throw Object.assign(new Error("配色模板不存在"), { statusCode: 400 }); if (!theme) throw Object.assign(new Error("配色模板不存在"), { statusCode: 400 });
const font = fonts.find((item) => item.id === design.fontId && item.version === design.fontVersion); const font = fonts.find((item) => item.id === design.fontId && item.version === design.fontVersion);
if (!font) throw Object.assign(new Error("字体方案不存在"), { statusCode: 400 }); if (!font) throw Object.assign(new Error("字体方案不存在"), { statusCode: 400 });
if (!template.layoutPresetIds.includes(design.layoutPresetId)) throw Object.assign(new Error("当前官网外观模版不支持所选布局方案"), { statusCode: 400 }); const sourceDir = path.join(config.siteKitDir, "page-templates", template.id, template.version);
const layoutPreset = layouts.find((item) => item.id === design.layoutPresetId && item.version === design.layoutPresetVersion); const hasDesignPackage = await stat(path.join(sourceDir, "src")).then((value) => value.isDirectory()).catch(() => false);
if (!layoutPreset) throw Object.assign(new Error("布局方案不存在"), { statusCode: 400 }); let layoutPreset: LayoutPresetInfo | undefined;
for (const key of ["hero", "density", "radius"] as const) { if (template.layoutPresetIds?.length) {
if (design.layout[key] !== layoutPreset.parameters[key]) throw Object.assign(new Error("布局参数与所选布局方案不一致"), { statusCode: 400 }); if (!design.layoutPresetId || !template.layoutPresetIds.includes(design.layoutPresetId)) throw Object.assign(new Error("当前官网外观模版不支持所选布局方案"), { statusCode: 400 });
layoutPreset = layouts.find((item) => item.id === design.layoutPresetId && item.version === design.layoutPresetVersion);
if (!layoutPreset) throw Object.assign(new Error("布局方案不存在"), { statusCode: 400 });
for (const key of ["hero", "density", "radius"] as const) {
if (design.layout[key] !== layoutPreset.parameters[key]) throw Object.assign(new Error("布局参数与所选布局方案不一致"), { statusCode: 400 });
}
} }
return { kernel, template, theme, font, layoutPreset }; return { kernel, template, theme, font, layoutPreset, sourceDir, hasDesignPackage };
} }
renderFont(font: FontDefinition): string { renderFont(font: FontDefinition): string {
......
...@@ -104,14 +104,24 @@ export class TemplatePreviewService { ...@@ -104,14 +104,24 @@ export class TemplatePreviewService {
try { try {
const theme = catalog.themes.find((item) => item.id === template.defaultThemeId); const theme = catalog.themes.find((item) => item.id === template.defaultThemeId);
const font = catalog.fonts.find((item) => item.id === template.defaultFontId); const font = catalog.fonts.find((item) => item.id === template.defaultFontId);
const layoutPreset = catalog.layoutPresets.find((item) => item.id === template.defaultLayoutPresetId); if (!theme || !font) throw new Error("默认视觉配置缺失");
if (!theme || !font || !layoutPreset) throw new Error("默认视觉配置缺失"); let design: SiteDesignParameters;
const design: SiteDesignParameters = { if (template.defaultLayoutPresetId) {
themeId: theme.id, themeVersion: theme.version, brandColor: theme.colors[0], const layoutPreset = catalog.layoutPresets.find((item) => item.id === template.defaultLayoutPresetId);
fontId: font.id, fontVersion: font.version, if (!layoutPreset) throw new Error("默认视觉配置缺失");
layoutPresetId: layoutPreset.id, layoutPresetVersion: layoutPreset.version, design = {
layout: { ...layoutPreset.parameters, gradientEmphasis: false, announcementBar: true }, themeId: theme.id, themeVersion: theme.version, brandColor: theme.colors[0],
}; fontId: font.id, fontVersion: font.version,
layoutPresetId: layoutPreset.id, layoutPresetVersion: layoutPreset.version,
layout: { ...layoutPreset.parameters, gradientEmphasis: false, announcementBar: true },
};
} else {
design = {
themeId: theme.id, themeVersion: theme.version, brandColor: theme.colors[0],
fontId: font.id, fontVersion: font.version,
layout: { gradientEmphasis: false, announcementBar: true },
};
}
const selection = await this.templates.resolve(template.id, template.version, design); const selection = await this.templates.resolve(template.id, template.version, design);
const projectPath = path.join(this.projectsDir, template.id); const projectPath = path.join(this.projectsDir, template.id);
await rm(projectPath, { recursive: true, force: true }); await rm(projectPath, { recursive: true, force: true });
......
...@@ -9,26 +9,54 @@ ...@@ -9,26 +9,54 @@
## 目录职责 ## 目录职责
- `site-kit/kernel/manifest.json`:当前稳定内核的身份和版本。 - `site-kit/kernel/manifest.json`:当前稳定内核的身份和版本。
- `site-kit/page-templates/<id>/<version>/manifest.json`:已发布官网外观模版及其兼容范围、默认内容、默认配色与字体、兼容布局参数。配色方案与外观模版自由组合,没有从属关系。 - `site-kit/page-templates/<id>/<version>/`:一个已发布官网外观模版的一个版本,包含 `manifest.json` 与可选的 `src/` 设计包。
- `site-kit/layouts.json`:可复用、带版本的布局方案;官网外观模版仅声明兼容的布局 ID。
- `site-kit/fonts.json`:可复用、带版本的字体方案,均为免费商用字体栈;官网外观模版声明默认字体,创建时可改选。
- `site-kit/theme-families.json`:按色系组织的配色定义(色系 + 主色),后端据此推导完整语义色板。 - `site-kit/theme-families.json`:按色系组织的配色定义(色系 + 主色),后端据此推导完整语义色板。
- `site-kit/fonts.json`:可复用、带版本的字体方案,均为免费商用字体栈。
- `site-kit/layouts.json`**legacy**。仅无 `src/` 的旧模版使用的布局预设;设计包模版不声明、不使用。
- `astro-template/`:当前内核运行载体和页面渲染契约。 - `astro-template/`:当前内核运行载体和页面渲染契约。
官网外观模版发布后不可原地修改;任何调整必须创建新版本。创建服务只接受目录中已发布且与当前内核兼容的精确版本组合。 官网外观模版发布后不可原地修改;任何调整必须创建新版本。创建服务只接受目录中已发布且与当前内核兼容的精确版本组合。
## 模版格式
```
site-kit/page-templates/<id>/<version>/
├── manifest.json
└── src/ # 可选设计包;缺席时全部回退内核组件(旧模版行为)
├── pages/index.astro # 必备
├── pages/articles/index.astro # 必备:与主页同设计语言的文章列表
├── pages/articles/[...id].astro # 必备:文章详情
├── layouts/、components/、styles/
└── data/site.json + data/home.json
```
### manifest.json
- 必填:`id``version``name``description``industries``status``compatibleKernel``defaultThemeId``defaultFontId``preview.accent/surface/mode`(目录卡片展示)。
- legacy 可选(仅无 `src/` 的旧模版):`content``layoutPresetIds``defaultLayoutPresetId``preview.hero``preview.heroImage`
### 设计包契约(用 Codex 等工具转换 HTML 模版时遵守)
1. **物化模型**:建站/预览时先拷贝内核,再用模版 `src/` 覆盖同名文件;未提供的文件回退内核。
2. **禁止覆盖平台内核**`src/` 顶层不允许出现 `_platform/``content.config.ts`,物化时直接报错。
3. **token 视觉契约**:样式必须消费 `var(--color-*)``var(--font-*)`(由平台在物化时生成 `styles/theme.css``styles/fonts.css`,模版不要自带这两个文件,自带也会被生成物覆盖);配色/字体/品牌色在创建时仍可切换。密度/圆角/首屏等布局决策烘入模版设计。
4. **数据契约**
- `data/site.json` 必须完整保留内核 schema 结构(含 `seo``organization` 等嵌套对象)——建站时会原地改写 `name/industry/description/email/phone``seo.*``organization.legalName` 字段,结构缺失会导致物化报错。最稳妥的做法是从内核拷贝该文件后只改默认值。
- `data/home.json` 必须含 `services` 数组(建站时注入用户核心服务),其余字段模版自定义。
5. **文章协议**:文章列表/详情页通过 `src/_platform/content/public-content``publishedArticles()` / `entryPath("articles", id)` 取数,并保持与主页同一套 Header/Footer 与设计语言——不允许退回隔离的简易文章壳。
6. **SEO/GEO**:页面 `<head>``src/_platform/seo/` 的 metadata/schema 助手,模版不得自写 sitemap/robots/llms 端点(内核提供)。
## 模版预览宿主 ## 模版预览宿主
- 后端 `TemplatePreviewService` 为每个已发布模版构建一个真实预览站:内核 + 模版默认内容/配色/字体/布局(公告栏开启以作展示),占用 4400+ 的独立本地端口。 - 后端 `TemplatePreviewService` 为每个已发布模版物化并构建一个真实预览站:内核 + 模版设计包(或旧模版默认内容/配色/字体/布局,公告栏开启以作展示),占用 4400+ 的独立本地端口。
- 以 site-kit 与内核源码的内容哈希决定是否需要重建;构建完成后用本机 Chrome(playwright-core)截取真实首页截图。 - 以 site-kit 与内核源码的内容哈希决定是否需要重建;构建完成后用本机 Chrome(playwright-core)截取真实首页截图。
- 目录 API 为每个模版附带 `previewUrl`(独立端口)与 `screenshotUrl`(公开静态路由 `/template-previews/screenshots/`,按请求来源生成绝对地址);创建页卡片展示真实截图,「预览」按钮直达对应 URL。 - 目录 API 为每个模版附带 `previewUrl`(独立端口)与 `screenshotUrl`(公开静态路由 `/template-previews/screenshots/`,按请求来源生成绝对地址);创建页卡片展示真实截图,「预览」按钮直达对应 URL。
## 创建不变量 ## 创建不变量
- 先生成并构建不依赖模型的基础成品。 - 先生成并构建不依赖模型的基础成品:物化 = 拷贝内核 → 叠加模版 `src/` → 注入 site.json/home.json 与 theme.css/fonts.css/design.json,全程无代码生成。
- 用户未填写 Prompt 时,基础成品直接成为第一版官网。 - 创建模式 `copy`(默认):用户未填写 Prompt 时,基础成品直接成为第一版官网;填写 Prompt 时,Agent 只在站点草稿中修改并接受路径与构建校验。
- 用户填写 Prompt 时,Agent 只在站点草稿中修改并接受路径与构建校验。 - 创建模式 `agent`:基础成品之上,Agent 以模版为设计参考做深度重组(允许增删/重排区块、改造组件结构),仍须遵守 token 契约与 `_platform` 协议;失败则丢弃草稿,保留已经可用的基础成品。
- 页面视觉配置集中管理布局、配色、品牌色、密度、圆角与渐变偏好;`gradientEmphasis` 默认关闭,未明确开启时禁止 Agent 主动增加大面积渐变。
- 公司资料先在平台侧提取为受长度限制的文本,再作为首次定制上下文使用;二进制文件不进入站点源码和 Git 历史。 - 公司资料先在平台侧提取为受长度限制的文本,再作为首次定制上下文使用;二进制文件不进入站点源码和 Git 历史。
- 智能定制成功后保存为第二个 Git 版本;失败则丢弃草稿,保留已经可用的基础成品 - 页面视觉配置集中管理配色、品牌色、字体与渐变/公告栏开关;`gradientEmphasis` 默认关闭,未明确开启时禁止 Agent 主动增加大面积渐变
- `metadata/site.json` 和站点根目录的 `webagent.site.json` 记录精确创建来源;官网外观模版后续升级不会覆盖已有网站。 - `metadata/site.json` 和站点根目录的 `webagent.site.json` 记录精确创建来源(含 `creationMode`;官网外观模版后续升级不会覆盖已有网站。
This diff is collapsed.
# 独立设计包式官网外观模版 设计文档
日期:2026-07-30
状态:已确认(用户口头批准)
## 背景与动机
当前外观模版是 `site-kit/page-templates/<id>/<version>/manifest.json` 的纯 JSON(文案 + 视觉参数引用),所有模版共享内核组件,无法吸收外部下载的成品 HTML 官网模版(ThemeForest 类)。同时文章页(`ContentLayout` + 独立 `content.css`)与主页设计语言隔离。
目标:外观模版升级为「manifest + Astro 设计源码」的独立设计包,由作者在外部用 Codex 等工具把下载的 HTML 模版转换入库(**转换功能不进系统**),并统一文章页与主页的设计语言。
## 已确认的关键决策
| 决策点 | 结论 |
| --- | --- |
| 转换方式 | 作者手动用外部工具转换,系统不提供导入功能 |
| 平台能力 | 模版必须支持现有能力:文章系统、公告栏、SEO/GEO、视觉配置 |
| 文章页 | 模版自带 articles 列表/详情页,与主页同设计语言,不再用隔离的通用 ContentLayout |
| 建站方式 | 用户创建时可选 `copy`(确定性拷贝,默认)或 `agent`(Agent 深度重组) |
| 内核绑定 | 物化时注入内核:模版目录只存设计文件,内核保持单份,升级可传导 |
| 视觉契约 | token 契约:模版样式必须消费 `var(--color-*)``var(--font-*)`;创建时仍可换配色/品牌色/字体;密度/圆角/首屏等布局参数烘入模版设计,`layouts.json` 退役 |
## 模版存储格式
```
site-kit/page-templates/<id>/<version>/
├── manifest.json # 身份/状态/兼容性/默认配色字体/卡片展示参数
└── src/ # 设计包:物化时覆盖内核同名文件,未提供的文件回退内核
├── pages/
│ ├── index.astro
│ └── articles/
│ ├── index.astro # 契约要求:与主页同设计语言
│ └── [...id].astro
├── layouts/ # 模版自己的 BaseLayout 等
├── components/ # Header/Hero/Footer 等
├── styles/ # 必须消费 var(--color-*) / var(--font-*)
└── data/ # 默认内容
├── site.json # 内核 schema(name/industry/email/seo/organization)
└── home.json # 保留 services 数组契约,其余字段模版自定义
```
### manifest.json schema(精简后)
保留:`id``version``name``description``industries``status``compatibleKernel``defaultThemeId``defaultFontId``preview.accent/surface/mode`(目录卡片展示用)。
退役:`content`(默认内容改由模版 `src/data/` 承载)、`layoutPresetIds``defaultLayoutPresetId`
保留为 legacy 可选字段:`preview.heroImage`——仅对无 `src/` 的旧模版生效,继续经 design.json 传给内核 Hero 渲染占位符;带 `src/` 的新模版忽略该字段(占位符由模版自己的组件决定)。
## 物化与内核注入
`materializeTemplateProject` 改造为纯文件操作,无代码生成:
1. **拷贝内核** `astro-template/` → 目标目录(astro 配置、`src/_platform/` SEO/文章协议、content.config.ts、基础样式)
2. **叠加模版** `src/` 覆盖同名文件(缺席文件回退内核,这是旧模版兼容的关键)
3. **注入 5 个小文件**
- `src/data/site.json`:改 name/industry/description/email/phone/seo/organization 字段
- `src/data/home.json`:填入 services 数组(标题来自用户输入,描述用固定句式)
- `src/styles/theme.css`:由配色方案 + 品牌色生成约 15 行 CSS 变量
- `src/styles/fonts.css`:2 行字体变量
- `src/data/design.json` + `webagent.site.json`:来源记录(模版/配色/字体精确版本与开关)
然后 `astro build`。预览服务走同一物化链路(内核 + 模版叠加 → 构建 → 4400+ 端口独立 URL → 截图),模版源码变更经 site-kit 哈希自动触发重建。
### design.json(精简后)
保留:`pageTemplate{id,version}``themeId/themeVersion``fontId/fontVersion``brandColor``gradientEmphasis``announcementBar``heroImage`(仅 legacy 模版链路写入,见上节)。
移除:`layoutPresetId/layoutPresetVersion``layout.hero/density/radius`
`SiteTemplateCatalogService.resolve` 移除布局相关校验(layoutPreset 存在性、参数一致性),保留模版/配色/字体的存在性与内核兼容性校验。
## 双模式创建
`CreateSiteInput` 增加 `mode?: "copy" | "agent"`(默认 `copy`):
- **copy**:现有确定性链路不变。物化 → 构建 → 第一版;有 Prompt 时 Agent 在草稿上二次定制(受限路径),存为第二版。
- **agent(深度重组)**:物化出基础项目后,Agent 获得整个项目,以模版为设计参考、以用户资料/Prompt 为输入,**允许增删重排区块、改造组件结构**,但必须:
- 遵守 token 契约(不改写 `theme.css`/`fonts.css` 的变量定义,样式继续消费变量)
- 保持 `_platform` 协议与 `content.config.ts` 不动
- 产出经路径与构建校验
- **失败回退 copy 模式产物**,保证建站不失败
## 兼容与迁移
- 现有 6 个 JSON-only 模版无需立即转换:无 `src/` 时全部回退内核组件,行为与今天一致(文章页仍走隔离 ContentLayout)。
- 之后可逐个补 `src/` 完成一体化改造;每次补充会改变 site-kit 哈希,触发预览重建,属于预期。
- 旧站点不受影响:`webagent.site.json` 记录精确创建来源,已建站点不随模版/内核升级。
## 契约文档
更新 `docs/site-template-architecture.md`,作为作者用 Codex 转换模版时的参照,内容包括:
- 设计包目录结构与必备文件清单(`pages/index.astro``pages/articles/` 两个页面、消费 token 的样式、`data/site.json` + `data/home.json`
- token 变量清单(`--color-*``--font-*`)与生成时机
- site.json schema 与 home.json services 契约
- 文章协议接入方式(`_platform/content``publishedArticles`/`entryPath`
- manifest schema 与发布规则(发布后不可原地修改)
## 错误处理
- 模版 `src/` 存在语法/构建错误:预览构建失败,条目标记 `failed`,不影响其他模版与建站(建站时 `resolve` 只校验目录存在性,构建失败走现有 `status: "failed"` 链路)。
- agent 模式 Agent 产出构建失败:回退 copy 产物。
## 测试
- `site-template-catalog.test.ts` 更新:新 manifest schema、resolve 移除布局校验、无 `src/` 旧模版兼容。
- 新增物化测试:内核 + 模版叠加后文件正确性(覆盖优先、回退存在)、5 个注入文件内容。
- agent 模式回退路径测试。
This diff is collapsed.
...@@ -13,6 +13,8 @@ export interface CreateSiteInput { ...@@ -13,6 +13,8 @@ export interface CreateSiteInput {
pageTemplateVersion: string; pageTemplateVersion: string;
design: SiteDesignParameters; design: SiteDesignParameters;
initialPrompt?: string; initialPrompt?: string;
/** 创建模式:copy=确定性拷贝(默认),agent=Agent 深度重组 */
mode?: "copy" | "agent";
} }
export interface SiteDesignParameters { export interface SiteDesignParameters {
...@@ -21,15 +23,17 @@ export interface SiteDesignParameters { ...@@ -21,15 +23,17 @@ export interface SiteDesignParameters {
brandColor: string; brandColor: string;
fontId: string; fontId: string;
fontVersion: string; fontVersion: string;
layoutPresetId: string; /** legacy:仅内核渲染的旧模版需要布局预设 */
layoutPresetVersion: string; layoutPresetId?: string;
layoutPresetVersion?: string;
layout: SiteLayoutParameters; layout: SiteLayoutParameters;
} }
export interface SiteLayoutParameters { export interface SiteLayoutParameters {
hero: "split" | "centered"; /** legacy:内核组件的首屏/密度/圆角参数,设计包模版忽略 */
density: "comfortable" | "compact"; hero?: "split" | "centered";
radius: "soft" | "square"; density?: "comfortable" | "compact";
radius?: "soft" | "square";
gradientEmphasis: boolean; gradientEmphasis: boolean;
announcementBar: boolean; announcementBar: boolean;
} }
...@@ -99,7 +103,8 @@ export interface PageTemplatePreview { ...@@ -99,7 +103,8 @@ export interface PageTemplatePreview {
accent: string; accent: string;
surface: string; surface: string;
mode: "light" | "dark"; mode: "light" | "dark";
hero: "centered" | "split"; /** legacy:内核首屏渲染参数,设计包模版忽略 */
hero?: "centered" | "split";
heroImage?: TemplatePreviewImage; heroImage?: TemplatePreviewImage;
stats?: "light" | "dark"; stats?: "light" | "dark";
gallery?: { count: number; image: TemplatePreviewImage }; gallery?: { count: number; image: TemplatePreviewImage };
...@@ -126,9 +131,11 @@ export interface PageTemplateInfo { ...@@ -126,9 +131,11 @@ export interface PageTemplateInfo {
preview: PageTemplatePreview; preview: PageTemplatePreview;
defaultThemeId: string; defaultThemeId: string;
defaultFontId: string; defaultFontId: string;
layoutPresetIds: string[]; /** legacy:内核渲染模版声明的布局预设,设计包模版省略 */
defaultLayoutPresetId: string; layoutPresetIds?: string[];
content: PageTemplateContent; defaultLayoutPresetId?: string;
/** legacy:内核渲染模版的默认文案,设计包模版由 src/data/ 承载 */
content?: PageTemplateContent;
previewUrl?: string; previewUrl?: string;
screenshotUrl?: string; screenshotUrl?: string;
} }
...@@ -150,6 +157,7 @@ export interface SiteTemplateProvenance { ...@@ -150,6 +157,7 @@ export interface SiteTemplateProvenance {
design: SiteDesignParameters; design: SiteDesignParameters;
}; };
initialPrompt?: string; initialPrompt?: string;
creationMode?: "copy" | "agent";
} }
export interface SiteInfo { export interface SiteInfo {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment