Commit 467e65cb authored by xuchentao's avatar xuchentao

feat: 重构官网模板与文章发布流程

parent 56b080e4
# 网站修改规范
# 网站规范
修改前必须读完本文档
改代码前读完本文
1. `src/_platform/``src/content.config.ts` 是平台能力内核,禁止修改。
2. 网站资料和默认 TDK 在 `src/data/site.json`;首页内容在 `src/data/home.json`
3. 文章由平台外部存储,禁止在源码中创建或修改文章文件。产品或服务放 `src/content/products/*.md`。仅已发布内容公开。
4. 页面、布局、组件和样式可自由修改,但公开页面必须通过 `BaseLayout` 保留 TDK、canonical、robots、OG 和 Schema。
5. 图片只能放 `public/images/`,链接和资源路径必须兼容 `SITE_BASE_PATH`
6. 不得修改依赖、Astro 配置、构建产物或 Git 文件,不得访问站点外目录。
7. 完成后检查改动文件并执行允许的 Git diff 检查;不得部署或提交。
1. 禁改 `src/_platform/``src/content.config.ts`、Astro 配置、依赖、产物及 Git 文件。
2. 资料与 TDK 在 `src/data/site.json`;首页内容在 `src/data/home.json`
3. 文章及图片由平台外存,禁止写入源码;正文图片用 `/images/articles/文件名`
4. 样式自由;公开页须用 `BaseLayout` 保留 SEO/GEO。
5. 页面图片放 `public/images/`,路径兼容 `SITE_BASE_PATH`
6. 禁止访问站点外目录、部署或提交;完成后检查 diff。
import { defineConfig } from "astro/config";
const configuredBase = process.env.SITE_BASE_PATH || "/";
const basePath = configuredBase === "/" ? "/" : `/${configuredBase.replace(/^\/+|\/+$/g, "")}/`;
function remarkArticleImages() {
return (tree) => {
const visit = (node) => {
if (node?.type === "image" && typeof node.url === "string" && node.url.startsWith("/images/articles/")) {
node.url = `${basePath}${node.url.replace(/^\/+/, "")}`.replace(/\/+/g, "/");
}
if (Array.isArray(node?.children)) node.children.forEach(visit);
};
visit(tree);
};
}
export default defineConfig({
output: "static",
site: process.env.SITE_PUBLIC_ORIGIN || "https://example.com",
base: process.env.SITE_BASE_PATH || "/",
base: configuredBase,
markdown: { remarkPlugins: [remarkArticleImages] },
server: { host: "127.0.0.1" },
});
---
title: 用 AI 建立可持续的官网内容增长机制
summary: 从选题、撰写到发布,建立一套兼顾品牌表达、SEO 与 GEO 的内容工作流。
status: published
author: 内容团队
cover: /images/articles/ai-content-growth.svg
tags:
- AI
- 内容增长
- GEO
publishedAt: 2026-07-18
updatedAt: 2026-07-18
seo:
title: AI 官网内容增长指南
description: 了解如何用 AI 建立稳定、可复用的官网内容生产与优化流程。
ogDescription: 一套兼顾品牌、SEO 与 GEO 的官网内容增长方法。
ogImage: /images/articles/ai-content-growth.svg
noindex: false
---
官网内容不应是一次性交付物,而应该成为持续积累的品牌资产。AI 最适合承担资料整理、结构生成和初稿优化,人仍然负责事实、判断与品牌语气。
![AI 内容增长工作流](/images/articles/ai-content-growth.svg)
## 从真实问题开始
优先整理销售、客服和项目交付中反复出现的问题。每篇文章解决一个明确问题,并给出可验证的结论、过程和下一步行动。
## 同时服务搜索与阅读
标题要说明主题,摘要要直接回答核心问题,正文使用清晰的层级。补充作者、发布日期、结构化数据和开放图谱信息,可以帮助搜索引擎与 AI 系统理解内容。
## 保持稳定更新
每月复查旧文章中的数据、链接和产品描述。持续更新比盲目增加低质量页面更有价值。
---
title: 如何把产品能力写成客户愿意读的故事
summary: 一篇用于演示草稿状态、Markdown 编辑和图片插入的示例文章。
status: draft
author: 品牌团队
tags:
- 产品内容
- 品牌表达
updatedAt: 2026-07-20
seo:
noindex: true
---
客户真正关心的不是功能列表,而是产品能否解决他的具体问题。先描述使用场景,再解释方案如何工作,最后补充结果与证据。
## 推荐结构
1. 客户处于什么场景;
2. 原有方法为什么不够;
3. 产品如何完成关键任务;
4. 可以获得什么可验证的结果。
这是一篇草稿,不会出现在公开文章列表中。你可以在文章中心继续编辑并发布它。
---
title: 企业官网上线前的 SEO 检查清单
summary: 用一份简洁清单检查 TDK、网站地图、Robots、结构化数据和分享摘要。
status: published
author: 官网运营团队
cover: /images/articles/seo-checklist.svg
tags:
- SEO
- 官网
- 上线检查
publishedAt: 2026-07-12
updatedAt: 2026-07-16
seo:
title: 企业官网 SEO 上线检查清单
description: 企业官网发布前必须检查的 SEO 与 GEO 基础配置。
ogDescription: 发布官网前,用这份清单完成 SEO 与 GEO 基础检查。
ogImage: /images/articles/seo-checklist.svg
noindex: false
---
网站上线前,先确认每个可索引页面都有独立、准确的标题和摘要。首页、产品页和文章页不应复用同一组 TDK。
![官网 SEO 检查清单](/images/articles/seo-checklist.svg)
## 基础文件
- 网站地图包含所有正式页面,不包含草稿和测试地址。
- Robots 文件允许抓取正式内容,并指向网站地图。
- Favicon、Canonical 和分享图片可以正常访问。
## 内容结构
- 每页只有一个清晰的主标题。
- 标题层级连续,链接文字能表达目标内容。
- 图片包含有意义的替代文本。
## GEO 信息
为组织、网站和文章输出 Schema 结构,同时维护 `llms.txt``llms-full.txt`,让 AI 系统更容易获取站点的权威摘要与完整内容索引。
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="t d"><title id="t">AI 内容增长</title><desc id="d">紫色渐变背景上的内容增长流程示意</desc><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#30108c"/><stop offset="1" stop-color="#7755ff"/></linearGradient></defs><rect width="1200" height="630" rx="36" fill="url(#g)"/><g fill="none" stroke="#fff" stroke-width="10" opacity=".9"><circle cx="260" cy="315" r="82"/><circle cx="600" cy="315" r="82"/><circle cx="940" cy="315" r="82"/><path d="M342 315h176m164 0h176"/></g><g fill="#fff" font-family="system-ui,sans-serif" text-anchor="middle"><text x="260" y="330" font-size="38">选题</text><text x="600" y="330" font-size="38">创作</text><text x="940" y="330" font-size="38">增长</text><text x="600" y="115" font-size="54" font-weight="700">AI 内容增长工作流</text></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="t d"><title id="t">SEO 上线检查</title><desc id="d">带有勾选项的搜索优化检查清单</desc><rect width="1200" height="630" rx="36" fill="#f2efff"/><rect x="255" y="70" width="690" height="490" rx="32" fill="#fff" stroke="#6f45df" stroke-width="8"/><g fill="none" stroke="#6f45df" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"><path d="m335 190 24 26 48-62m-72 151 24 26 48-62m-72 151 24 26 48-62"/><path d="M470 188h380M470 303h310M470 418h350"/></g><text x="600" y="125" fill="#2b175f" font-family="system-ui,sans-serif" font-size="45" font-weight="700" text-anchor="middle">官网 SEO 上线检查</text></svg>
......@@ -12,6 +12,7 @@
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@types/node": "^22.15.3",
"astro": "^5.7.0",
"typescript": "^5.8.3"
}
......
......@@ -3,6 +3,7 @@ import { render, type CollectionEntry } from "astro:content";
import ContentLayout from "../../layouts/ContentLayout.astro";
import { entryPath, publishedArticles } from "../../_platform/content/public-content";
import { articleSchema, breadcrumbSchema } from "../../_platform/seo/schema";
import { withBase } from "../../_platform/seo/metadata";
export async function getStaticPaths() {
return (await publishedArticles()).map((entry) => ({ params: { id: entry.id.replace(/\.(md|mdx)$/i, "") }, props: { entry } }));
......@@ -29,6 +30,7 @@ const description = entry.data.seo.ogDescription || entry.data.seo.description |
]}
>
<h1>{entry.data.title}</h1>
{entry.data.cover && <img class="article-cover" src={withBase(entry.data.cover)} alt={entry.data.title} />}
<div class="content-meta">
{entry.data.publishedAt && <time datetime={entry.data.publishedAt.toISOString()}>{entry.data.publishedAt.toLocaleDateString("zh-CN")}</time>}
{entry.data.author && <span>{entry.data.author}</span>}
......
import type { APIRoute } from "astro";
import { publishedArticles, publishedProducts } from "../_platform/content/public-content";
import { site } from "../_platform/seo/metadata";
import { absoluteUrl, site } from "../_platform/seo/metadata";
const MAX_CHARACTERS = 200_000;
......@@ -11,7 +11,7 @@ export const GET: APIRoute = async () => {
];
const sections = [`# ${site.name}\n\n> ${site.description}`];
for (const entry of entries) {
const body = entry.body || entry.data.summary;
const body = (entry.body || entry.data.summary).replace(/\]\((\/images\/articles\/[^)]+)\)/g, (_match, image: string) => `](${absoluteUrl(image)})`);
const section = `## ${entry.data.title}\n\n${entry.data.summary}\n\n${body}`;
if (sections.join("\n\n").length + section.length > MAX_CHARACTERS) break;
sections.push(section);
......
......@@ -13,6 +13,7 @@
.content-list p { margin: 0 0 16px; color: var(--color-text-secondary); line-height: 1.75; }
.content-list a { color: var(--color-primary); font-size: 14px; font-weight: 750; }
.content-meta { display: flex; flex-wrap: wrap; gap: 10px 20px; margin: 0 0 42px; color: var(--color-text-muted); font-size: 13px; }
.article-cover { display: block; width: 100%; height: auto; margin: 0 0 28px; border-radius: 22px; }
.prose { font-size: 17px; line-height: 1.9; }
.prose h2, .prose h3 { margin: 2em 0 .7em; line-height: 1.3; }
.prose p, .prose ul, .prose ol { margin: 0 0 1.35em; }
......
......@@ -92,6 +92,7 @@ export class AgentOrchestrator {
dist = await this.builds.build(workspace, "agent_" + runId, {
basePath: getPublicPreviewUrl(run.tenantId, run.siteId), indexable: false,
articlesDirectory: this.sites.getArticlesPath(run.tenantId, run.siteId),
articleImagesDirectory: this.sites.getArticleImagesPath(run.tenantId, run.siteId),
});
buildValidation = { passed: true, buildExitCode: 0, durationMs: Date.now() - buildStarted, output: "网站构建通过" };
this.runs.transition(runId, "validating_build", { validation: buildValidation });
......@@ -107,6 +108,7 @@ export class AgentOrchestrator {
const latest = this.requireRun(runId);
const summary = (latest.summary || "更新网站").replace(/\s+/g, " ").slice(0, 120);
await this.builds.publishPreview(run.tenantId, run.siteId, dist);
await this.sites.markArticlesCompiled(run.tenantId, run.siteId);
await this.sites.update(run.tenantId, run.siteId, {
status: "ready", previewCommit: baseCommit,
draftBaseCommit: baseCommit, draftUpdatedAt: new Date().toISOString(), draftSummary: summary,
......
......@@ -15,6 +15,8 @@ test("article mutations use external content storage without creating Git-manage
const root = await mkdtemp(path.join(os.tmpdir(), "webagent-articles-"));
const project = path.join(root, "project");
const articles = path.join(root, "content", "articles");
const images = path.join(root, "content", "images");
const articleState = path.join(root, "metadata", "articles.json");
const dist = path.join(root, "dist");
await mkdir(path.join(project, "src"), { recursive: true });
await writeFile(path.join(project, "src", "content.config.ts"), "export const collections = {};\n", "utf8");
......@@ -26,6 +28,7 @@ test("article mutations use external content storage without creating Git-manage
createdAt: now, updatedAt: now,
};
let buildArticlesDirectory = "";
let buildCalls = 0;
const sites = {
get: async () => site,
update: async (_tenantId: string, _siteId: string, values: Partial<SiteInfo>) => {
......@@ -35,9 +38,18 @@ test("article mutations use external content storage without creating Git-manage
getProjectPath: () => project,
getDraftPath: () => path.join(root, "draft"),
getArticlesPath: () => articles,
getArticleImagesPath: () => images,
getArticleStatePath: () => articleState,
markArticlesCompiled: async () => {
const compiledAt = new Date().toISOString();
await mkdir(path.dirname(articleState), { recursive: true });
await writeFile(articleState, JSON.stringify({ pendingSlugs: [], lastCompiledAt: compiledAt }), "utf8");
return compiledAt;
},
} as unknown as SiteRepository;
const builds = {
build: async (_workspace: string, _taskId: string, context: SiteBuildContext) => {
buildCalls += 1;
buildArticlesDirectory = context.articlesDirectory || "";
assert.match(await readFile(path.join(buildArticlesDirectory, "hello.md"), "utf8"), /title: Hello/);
await mkdir(dist, { recursive: true });
......@@ -56,8 +68,23 @@ test("article mutations use external content storage without creating Git-manage
assert.equal(result.article?.slug, "hello");
assert.equal(await exists(path.join(articles, "hello.md")), true);
assert.equal(await exists(path.join(project, "src", "content", "articles")), false);
assert.notEqual(buildArticlesDirectory, articles);
assert.match(buildArticlesDirectory, /\.articles-next-/);
assert.equal(buildCalls, 0);
assert.equal((await new ArticleService(sites, builds, previews).list("tenant_test", "site_test")).pendingBuild, true);
const published = await new ArticleService(sites, builds, previews).update("tenant_test", "site_test", "hello", { ...article, status: "published" });
assert.equal(published.article?.status, "published");
assert.match(await readFile(path.join(articles, "hello.md"), "utf8"), /status: published/);
assert.equal(buildCalls, 0);
await new ArticleService(sites, builds, previews).compilePreview("tenant_test", "site_test");
assert.equal(buildCalls, 1);
assert.equal(buildArticlesDirectory, articles);
assert.equal((await new ArticleService(sites, builds, previews).list("tenant_test", "site_test")).pendingBuild, false);
const uploaded = await new ArticleService(sites, builds, previews).uploadImage("tenant_test", "site_test", {
filename: "pixel.png",
dataUrl: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
});
assert.match(uploaded.path, /^\/images\/articles\/pixel-[a-f0-9]{10}\.png$/);
assert.equal(await exists(path.join(images, uploaded.filename)), true);
assert.equal(await exists(path.join(project, "public", "images", "articles", uploaded.filename)), false);
} finally {
await rm(root, { recursive: true, force: true });
}
......
This diff is collapsed.
......@@ -13,6 +13,7 @@ export interface SiteBuildContext {
publicOrigin?: string;
indexable?: boolean;
articlesDirectory?: string;
articleImagesDirectory?: string;
}
export class BuildManager {
......@@ -26,6 +27,7 @@ export class BuildManager {
const options = typeof context === "string" ? { basePath: context } : context;
const publicOrigin = (options.publicOrigin || config.frontendOrigin).replace(/\/$/, "");
if (options.articlesDirectory) await mkdir(options.articlesDirectory, { recursive: true });
if (options.articleImagesDirectory) await mkdir(options.articleImagesDirectory, { recursive: true });
try {
const install = await execa("pnpm", ["install", "--store-dir", runtimePaths.pnpmStore, "--prefer-offline"], {
cwd: projectPath,
......@@ -44,6 +46,12 @@ export class BuildManager {
},
});
output += build.stdout + "\n" + build.stderr;
if (options.articleImagesDirectory) {
const target = path.join(projectPath, "dist", "images", "articles");
await rm(target, { recursive: true, force: true });
await mkdir(path.dirname(target), { recursive: true });
await cp(options.articleImagesDirectory, target, { recursive: true });
}
await writeFile(path.join(runtimePaths.logs, taskId + ".log"), output, "utf8");
return path.join(projectPath, "dist");
} catch (error) {
......
......@@ -46,6 +46,7 @@ export class DomainDeploymentService {
const dist = await this.builds.build(workspace, taskId, {
basePath: "/", publicOrigin: primary ? `https://${primary.hostname}` : undefined, indexable: true,
articlesDirectory: this.sites.getArticlesPath(tenantId, siteId),
articleImagesDirectory: this.sites.getArticleImagesPath(tenantId, siteId),
});
await this.builds.publishCustomDomain(tenantId, siteId, dist);
await this.domains.updateSite(tenantId, siteId, (domain) => domain.ownershipStatus === "verified"
......
......@@ -60,6 +60,11 @@ export const articleImportSchema = z.object({
markdown: z.string().min(1).max(256 * 1024),
});
export const articleImageUploadSchema = z.object({
filename: z.string().trim().min(1).max(180),
dataUrl: z.string().max(7 * 1024 * 1024).regex(/^data:image\/(?:png|jpeg|webp|gif);base64,/i, "仅支持 PNG、JPEG、WebP 或 GIF 图片"),
});
export const addDomainSchema = z.object({
hostname: z.string().trim().min(4, "请输入完整域名").max(253),
});
......
......@@ -12,7 +12,7 @@ import { config, runtimePaths } from "./config.js";
import { GitManager } from "./git/git-manager.js";
import { PreviewProcessManager } from "./preview/preview-process-manager.js";
import {
addDomainSchema, agentSettingsSchema, chatSchema, createSiteSchema, createTenantSchema, loginSchema, resetPasswordSchema, saveDraftSchema,
addDomainSchema, agentSettingsSchema, articleImageUploadSchema, articleImportSchema, articleInputSchema, chatSchema, createSiteSchema, createTenantSchema, loginSchema, resetPasswordSchema, saveDraftSchema,
tenantStatusSchema, updateDomainSchema, versionCommitSchema,
} from "./schemas.js";
import { CreateSiteService } from "./sites/create-site.js";
......@@ -24,12 +24,13 @@ import { DomainRepository } from "./domains/domain-repository.js";
import { DomainRoutingConfig } from "./domains/domain-routing-config.js";
import { DomainService } from "./domains/domain-service.js";
import { createDomainProvider } from "./domains/domain-provider.js";
import { ArticleService } from "./articles/article-service.js";
declare module "fastify" {
interface FastifyRequest { auth?: SessionInfo }
}
const app = Fastify({ logger: { level: process.env.LOG_LEVEL || "info" }, bodyLimit: 1024 * 1024 });
const app = Fastify({ logger: { level: process.env.LOG_LEVEL || "info" }, bodyLimit: 8 * 1024 * 1024 });
await app.register(cors, { origin: [config.frontendOrigin, "http://127.0.0.1:5173"] });
const auth = new AuthRepository();
......@@ -48,6 +49,7 @@ const domainProvider = createDomainProvider();
const domainDeployments = new DomainDeploymentService(sites, domainRepository, git, builds, domainRouting);
const domainService = new DomainService(sites, domainRepository, domainRouting, domainProvider);
const siteLifecycle = new SiteLifecycleService(sites, previews, domainRepository, domainRouting, domainProvider);
const articles = new ArticleService(sites, builds, previews);
app.addHook("onRequest", async (request, reply) => {
if (!request.url.startsWith("/api/") || request.url === "/api/health" || request.url === "/api/login") return;
......@@ -164,6 +166,31 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/publish", async (re
void domainDeployments.schedule(tenantId, request.params.siteId, result.commit).catch((error) => app.log.error(error));
return result;
});
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/articles", async (request) => (
articles.list(requireTenant(request.auth), request.params.siteId)
));
app.get<{ Params: { siteId: string; slug: string } }>("/api/sites/:siteId/articles/:slug", async (request) => (
articles.get(requireTenant(request.auth), request.params.siteId, request.params.slug)
));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/articles", async (request, reply) => (
reply.code(201).send(await articles.create(requireTenant(request.auth), request.params.siteId, articleInputSchema.parse(request.body)))
));
app.put<{ Params: { siteId: string; slug: string } }>("/api/sites/:siteId/articles/:slug", async (request) => (
articles.update(requireTenant(request.auth), request.params.siteId, request.params.slug, articleInputSchema.parse(request.body))
));
app.delete<{ Params: { siteId: string; slug: string } }>("/api/sites/:siteId/articles/:slug", async (request) => (
articles.remove(requireTenant(request.auth), request.params.siteId, request.params.slug)
));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/articles/import", async (request, reply) => {
const body = articleImportSchema.parse(request.body);
return reply.code(201).send(await articles.importMarkdown(requireTenant(request.auth), request.params.siteId, body.filename, body.markdown));
});
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/articles/compile", async (request) => (
articles.compilePreview(requireTenant(request.auth), request.params.siteId)
));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/article-images", async (request, reply) => (
reply.code(201).send(await articles.uploadImage(requireTenant(request.auth), request.params.siteId, articleImageUploadSchema.parse(request.body)))
));
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/history", async (request) => {
const tenantId = requireTenant(request.auth);
await sites.get(tenantId, request.params.siteId);
......@@ -252,7 +279,7 @@ await mkdir(runtimePaths.builds, { recursive: true });
for (let site of await sites.listAll()) {
await git.pruneWorktrees(sites.getProjectPath(site.tenantId, site.siteId));
await builds.ensureProductionPlaceholder(site.tenantId, site.siteId, site.name);
if (site.environmentVersion !== 3) {
if (site.environmentVersion !== 4) {
await versions.rebuild(site.tenantId, site.siteId).catch((error) => app.log.warn(error));
site = await sites.get(site.tenantId, site.siteId);
}
......
......@@ -28,10 +28,15 @@ export class CreateSiteService {
createdAt: now, updatedAt: now,
};
const articlesDirectory = this.sites.getArticlesPath(tenantId, siteId);
await Promise.all([mkdir(projectPath, { recursive: true }), mkdir(articlesDirectory, { recursive: true })]);
const articleImagesDirectory = this.sites.getArticleImagesPath(tenantId, siteId);
await Promise.all([mkdir(projectPath, { recursive: true }), mkdir(articlesDirectory, { recursive: true }), mkdir(articleImagesDirectory, { recursive: true })]);
await this.sites.save(site);
try {
await cp(config.templateDir, projectPath, { recursive: true, filter: (source) => !["node_modules", "dist", ".astro", ".git", "pnpm-lock.yaml"].includes(path.basename(source)) });
await cp(config.templateDir, projectPath, { recursive: true, filter: (source) => !["node_modules", "dist", ".astro", ".git", "pnpm-lock.yaml", "examples"].includes(path.basename(source)) });
await Promise.all([
cp(path.join(config.templateDir, "examples", "articles"), articlesDirectory, { recursive: true }),
cp(path.join(config.templateDir, "examples", "images"), articleImagesDirectory, { recursive: true }),
]);
await rm(path.join(projectPath, "src/data/company.json"), { force: true });
const siteDataPath = path.join(projectPath, "src/data/site.json");
const siteData = JSON.parse(await readFile(siteDataPath, "utf8")) as {
......@@ -57,12 +62,13 @@ export class CreateSiteService {
const theme = (await readFile(themePath, "utf8")).replaceAll("#7028ff", input.brandColor.toLowerCase());
await writeFile(themePath, theme, "utf8");
const dist = await this.builds.build(projectPath, "create_" + siteId, {
basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false, articlesDirectory,
basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false, articlesDirectory, articleImagesDirectory,
});
const commit = await this.git.init(projectPath);
await this.sites.update(tenantId, siteId, { currentCommit: commit });
const published = await this.builds.publishPreview(tenantId, siteId, dist);
await this.previews.start(tenantId, siteId, published, previewPort);
await this.sites.markArticlesCompiled(tenantId, siteId);
await this.builds.ensureProductionPlaceholder(tenantId, siteId, input.name);
return await this.sites.update(tenantId, siteId, { status: "ready", currentCommit: commit, previewCommit: commit, lastError: undefined });
} catch (error) {
......
......@@ -40,6 +40,24 @@ export class SiteRepository {
return path.join(this.getSiteRoot(tenantId, siteId), "content", "articles");
}
getArticleImagesPath(tenantId: string, siteId: string): string {
return path.join(this.getSiteRoot(tenantId, siteId), "content", "images");
}
getArticleStatePath(tenantId: string, siteId: string): string {
return path.join(this.getSiteRoot(tenantId, siteId), "metadata", "articles.json");
}
async markArticlesCompiled(tenantId: string, siteId: string): Promise<string> {
const destination = this.getArticleStatePath(tenantId, siteId);
const compiledAt = new Date().toISOString();
await mkdir(path.dirname(destination), { recursive: true });
const temporary = `${destination}.tmp`;
await writeFile(temporary, JSON.stringify({ pendingSlugs: [], lastCompiledAt: compiledAt }, null, 2) + "\n", "utf8");
await rename(temporary, destination);
return compiledAt;
}
getMetadataPath(tenantId: string, siteId: string): string {
return path.join(this.getSiteRoot(tenantId, siteId), "metadata", "site.json");
}
......
import crypto from "node:crypto";
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { DraftPreviewResult, PreviewVersionResult, PublishResult, SiteInfo } from "@webagent/shared";
import { getPublicPreviewUrl, getPublicProductionUrl, runtimePaths } from "../config.js";
import { BuildError, BuildManager } from "../build/build-manager.js";
......@@ -53,9 +54,11 @@ export class SiteVersionService {
const dist = await this.builds.build(workspace, taskId, {
basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false,
articlesDirectory: this.sites.getArticlesPath(tenantId, siteId),
articleImagesDirectory: this.sites.getArticleImagesPath(tenantId, siteId),
});
const published = await this.builds.publishPreview(tenantId, siteId, dist);
await this.previews.start(tenantId, siteId, published, site.previewPort);
await this.sites.markArticlesCompiled(tenantId, siteId);
await this.sites.update(tenantId, siteId, {
status: "ready", previewCommit: site.draftBaseCommit,
draftUpdatedAt: new Date().toISOString(),
......@@ -85,6 +88,7 @@ export class SiteVersionService {
const dist = await this.builds.build(workspace, taskId, {
basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false,
articlesDirectory: this.sites.getArticlesPath(tenantId, siteId),
articleImagesDirectory: this.sites.getArticleImagesPath(tenantId, siteId),
});
const draftCommit = await this.git.hasChanges(workspace)
? await this.git.commit(workspace, message)
......@@ -92,6 +96,7 @@ export class SiteVersionService {
const commit = await this.git.fastForward(project, draftCommit, site.draftBaseCommit);
const published = await this.builds.publishPreview(tenantId, siteId, dist);
await this.previews.start(tenantId, siteId, published, site.previewPort);
await this.sites.markArticlesCompiled(tenantId, siteId);
await this.sites.update(tenantId, siteId, {
status: "ready", currentCommit: commit, previewCommit: commit,
draftBaseCommit: undefined, draftUpdatedAt: undefined, draftSummary: undefined,
......@@ -133,10 +138,12 @@ export class SiteVersionService {
const dist = await this.builds.build(workspace, taskId, {
basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false,
articlesDirectory: this.sites.getArticlesPath(tenantId, siteId),
articleImagesDirectory: this.sites.getArticleImagesPath(tenantId, siteId),
});
const commit = await this.git.restoreAsCommit(project, targetCommit);
const published = await this.builds.publishPreview(tenantId, siteId, dist);
await this.previews.start(tenantId, siteId, published, site.previewPort);
await this.sites.markArticlesCompiled(tenantId, siteId);
await this.sites.update(tenantId, siteId, {
status: "ready", currentCommit: commit, previewCommit: commit,
environmentVersion: 4, lastError: undefined,
......@@ -153,6 +160,12 @@ export class SiteVersionService {
const site = await this.sites.get(tenantId, siteId);
if (site.status !== "ready") throw new Error("测试环境尚未构建成功,不能发布到生产环境");
if (site.draftBaseCommit) throw new Error("存在未保存修改,请先在“版本”中保存或放弃草稿后再发布");
const articleState = await readFile(this.sites.getArticleStatePath(tenantId, siteId), "utf8")
.then((raw) => JSON.parse(raw) as { pendingSlugs?: unknown[] })
.catch(() => ({ pendingSlugs: [] }));
if (articleState.pendingSlugs?.length) {
throw Object.assign(new Error("存在尚未编译的文章修改,请先在“文章”中统一编译测试预览"), { statusCode: 409 });
}
const taskId = "publish_" + crypto.randomBytes(4).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.sites.update(tenantId, siteId, { publishStatus: "publishing", lastPublishError: undefined });
......@@ -164,6 +177,7 @@ export class SiteVersionService {
const dist = await this.builds.build(workspace, taskId, {
basePath: getPublicProductionUrl(tenantId, siteId), indexable: true,
articlesDirectory: this.sites.getArticlesPath(tenantId, siteId),
articleImagesDirectory: this.sites.getArticleImagesPath(tenantId, siteId),
});
await this.builds.publishProduction(tenantId, siteId, dist);
const publishedAt = new Date().toISOString();
......@@ -199,9 +213,11 @@ export class SiteVersionService {
const dist = await this.builds.build(workspace, taskId, {
basePath: getPublicPreviewUrl(site.tenantId, site.siteId), indexable: false,
articlesDirectory: this.sites.getArticlesPath(site.tenantId, site.siteId),
articleImagesDirectory: this.sites.getArticleImagesPath(site.tenantId, site.siteId),
});
const published = await this.builds.publishPreview(site.tenantId, site.siteId, dist);
await this.previews.start(site.tenantId, site.siteId, published, site.previewPort);
await this.sites.markArticlesCompiled(site.tenantId, site.siteId);
await this.sites.update(site.tenantId, site.siteId, {
...metadata,
status: "ready", previewCommit: commit, environmentVersion: 4, lastError: undefined,
......
This diff is collapsed.
import type { AgentRunEvent, AgentRunInfo, AgentSettings, CreateSiteInput, CreateTenantInput, DomainBinding, DomainListResult, DraftPreviewResult, GitHistoryItem, PreviewVersionResult, PublishResult, SessionInfo, SiteInfo, TenantAdminInfo, TenantAdminSiteInfo } from "@webagent/shared";
import type { AgentRunEvent, AgentRunInfo, AgentSettings, ArticleCompileResult, ArticleDocument, ArticleImageUploadResult, ArticleInput, ArticleListResult, ArticleMutationResult, CreateSiteInput, CreateTenantInput, DomainBinding, DomainListResult, DraftPreviewResult, GitHistoryItem, PreviewVersionResult, PublishResult, SessionInfo, SiteInfo, TenantAdminInfo, TenantAdminSiteInfo } from "@webagent/shared";
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const headers = new Headers(options?.headers);
......@@ -52,6 +52,14 @@ export const api = {
restoreVersion: (siteId: string, commit: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/restore-version", { method: "POST", body: JSON.stringify({ commit }) }),
rebuild: (siteId: string) => request<{ previewUrl: string }>("/api/sites/" + siteId + "/build", { method: "POST" }),
publish: (siteId: string) => request<PublishResult>("/api/sites/" + siteId + "/publish", { method: "POST" }),
articles: (siteId: string) => request<ArticleListResult>(`/api/sites/${siteId}/articles`),
article: (siteId: string, slug: string) => request<ArticleDocument>(`/api/sites/${siteId}/articles/${slug}`),
createArticle: (siteId: string, input: ArticleInput) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles`, { method: "POST", body: JSON.stringify(input) }),
updateArticle: (siteId: string, slug: string, input: ArticleInput) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles/${slug}`, { method: "PUT", body: JSON.stringify(input) }),
deleteArticle: (siteId: string, slug: string) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles/${slug}`, { method: "DELETE" }),
importArticle: (siteId: string, filename: string, markdown: string) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles/import`, { method: "POST", body: JSON.stringify({ filename, markdown }) }),
compileArticles: (siteId: string) => request<ArticleCompileResult>(`/api/sites/${siteId}/articles/compile`, { method: "POST" }),
uploadArticleImage: (siteId: string, filename: string, dataUrl: string) => request<ArticleImageUploadResult>(`/api/sites/${siteId}/article-images`, { method: "POST", body: JSON.stringify({ filename, dataUrl }) }),
domains: (siteId: string) => request<DomainListResult>("/api/sites/" + siteId + "/domains"),
addDomain: (siteId: string, hostname: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains", { method: "POST", body: JSON.stringify({ hostname }) }),
verifyDomain: (siteId: string, domainId: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains/" + domainId + "/verify", { method: "POST" }),
......
This diff is collapsed.
......@@ -17,6 +17,9 @@ importers:
'@astrojs/check':
specifier: ^0.9.4
version: 0.9.9(prettier@3.9.5)(typescript@5.8.3)
'@types/node':
specifier: ^22.15.3
version: 22.20.1
astro:
specifier: ^5.7.0
version: 5.18.2(@types/node@22.20.1)(rollup@4.62.2)(tsx@4.23.1)(typescript@5.8.3)(yaml@2.9.0)
......
......@@ -222,6 +222,7 @@ export interface ArticleInput {
export interface ArticleSummary extends Omit<ArticleInput, "body"> {
wordCount: number;
pendingBuild: boolean;
}
export interface ArticleDocument extends ArticleInput {
......@@ -231,12 +232,33 @@ export interface ArticleDocument extends ArticleInput {
export interface ArticleListResult {
supported: boolean;
articles: ArticleSummary[];
pendingBuild: boolean;
pendingCount: number;
lastCompiledAt?: string;
}
export interface ArticleMutationResult {
article?: ArticleDocument;
deletedSlug?: string;
previewUrl: string;
pendingBuild: boolean;
}
export interface ArticleCompileResult {
previewUrl: string;
compiledAt: string;
articleCount: number;
}
export interface ArticleImageUploadInput {
filename: string;
dataUrl: string;
}
export interface ArticleImageUploadResult {
path: string;
filename: string;
size: number;
}
export type DomainOwnershipStatus = "pending" | "verified" | "failed";
......
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