Commit 56b080e4 authored by xuchentao's avatar xuchentao

feat: refactor site template and externalize articles

parent 33d48dd2
......@@ -4,3 +4,4 @@ dist/
.env
.env.*
.DS_Store
src/content/articles/
# 网站修改规范
修改前必须读完本文档。
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 检查;不得部署或提交。
......@@ -2,6 +2,7 @@ import { defineConfig } from "astro/config";
export default defineConfig({
output: "static",
site: process.env.SITE_PUBLIC_ORIGIN || "https://example.com",
base: process.env.SITE_BASE_PATH || "/",
server: { host: "127.0.0.1" },
});
......@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "astro dev",
"prebuild": "node -e \"const f=require('node:fs');for(const p of['.astro','node_modules/.astro'])f.rmSync(p,{recursive:true,force:true})\"",
"build": "astro check && astro build",
"preview": "astro preview",
"typecheck": "astro check"
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="9" fill="#7028ff"/><path d="M9 20 16 8l7 12-4 4-3-6-3 6z" fill="#fff"/></svg>
import { getCollection } from "astro:content";
export async function publishedArticles() {
const entries = await getCollection("articles", ({ data }) => data.status === "published");
return entries.sort((a, b) => (b.data.publishedAt?.getTime() || 0) - (a.data.publishedAt?.getTime() || 0));
}
export async function publishedProducts() {
return getCollection("products", ({ data }) => data.status === "published");
}
export function entryPath(collection: "articles" | "products", id: string): string {
return `/${collection}/${id.replace(/\.(md|mdx)$/i, "")}/`;
}
---
import { absoluteUrl, imageUrl, isIndexable, pageTitle, serializeJsonLd, site, withBase } from "./metadata";
interface Props {
title: string;
description: string;
canonicalPath?: string;
image?: string;
type?: "website" | "article" | "product";
noindex?: boolean;
publishedTime?: string;
updatedTime?: string;
schemas?: Record<string, unknown>[];
absoluteTitle?: boolean;
}
const {
title,
description,
canonicalPath = "/",
image,
type = "website",
noindex = false,
publishedTime,
updatedTime,
schemas = [],
absoluteTitle = false,
} = Astro.props;
const resolvedTitle = pageTitle(title, absoluteTitle);
const canonical = absoluteUrl(canonicalPath);
const socialImage = imageUrl(image);
const robots = isIndexable && !noindex ? "index,follow" : "noindex,nofollow";
---
<title>{resolvedTitle}</title>
<meta name="description" content={description} />
<meta name="robots" content={robots} />
<link rel="canonical" href={canonical} />
<link rel="icon" href={withBase(site.seo.favicon)} />
<meta property="og:locale" content={site.locale.replace("-", "_")} />
<meta property="og:site_name" content={site.name} />
<meta property="og:type" content={type} />
<meta property="og:title" content={resolvedTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
{socialImage && <meta property="og:image" content={socialImage} />}
<meta name="twitter:card" content={socialImage ? "summary_large_image" : "summary"} />
<meta name="twitter:title" content={resolvedTitle} />
<meta name="twitter:description" content={description} />
{socialImage && <meta name="twitter:image" content={socialImage} />}
{publishedTime && <meta property="article:published_time" content={publishedTime} />}
{updatedTime && <meta property="article:modified_time" content={updatedTime} />}
{schemas.map((schema) => <script is:inline type="application/ld+json" set:html={serializeJsonLd(schema)} />)}
import site from "../../data/site.json";
const origin = (process.env.SITE_PUBLIC_ORIGIN || "https://example.com").replace(/\/$/, "");
const configuredBase = process.env.SITE_BASE_PATH || "/";
const basePath = configuredBase === "/" ? "/" : `/${configuredBase.replace(/^\/+|\/+$/g, "")}/`;
export const isIndexable = process.env.SITE_INDEXABLE === "true";
export function withBase(pathname = "/"): string {
const clean = pathname.replace(/^\/+/, "");
return `${basePath}${clean}`.replace(/\/+/g, "/");
}
export function absoluteUrl(pathname = "/"): string {
return new URL(withBase(pathname), `${origin}/`).toString();
}
export function pageTitle(title: string, absolute = false): string {
if (absolute || title === site.seo.defaultTitle) return title;
return site.seo.titleTemplate.replace("%s", title);
}
export function imageUrl(image?: string): string | undefined {
const selected = image || site.seo.defaultOgImage;
if (!selected) return undefined;
if (/^https?:\/\//.test(selected)) return selected;
return absoluteUrl(selected);
}
export function serializeJsonLd(schema: Record<string, unknown>): string {
return JSON.stringify(schema).replace(/</g, "\\u003c");
}
export { site };
import { absoluteUrl, site } from "./metadata";
const context = "https://schema.org";
export function organizationSchema(): Record<string, unknown> {
return {
"@context": context,
"@type": "Organization",
"@id": `${absoluteUrl("/")}#organization`,
name: site.organization.legalName || site.name,
url: absoluteUrl("/"),
description: site.description,
...(site.organization.logo ? { logo: absoluteUrl(site.organization.logo) } : {}),
...(site.email ? { email: site.email } : {}),
...(site.phone ? { telephone: site.phone } : {}),
...(site.organization.sameAs.length ? { sameAs: site.organization.sameAs } : {}),
};
}
export function webSiteSchema(): Record<string, unknown> {
return {
"@context": context,
"@type": "WebSite",
"@id": `${absoluteUrl("/")}#website`,
url: absoluteUrl("/"),
name: site.name,
description: site.seo.description,
inLanguage: site.locale,
publisher: { "@id": `${absoluteUrl("/")}#organization` },
};
}
export function webPageSchema(input: { name: string; description: string; path: string }): Record<string, unknown> {
return {
"@context": context,
"@type": "WebPage",
"@id": `${absoluteUrl(input.path)}#webpage`,
url: absoluteUrl(input.path),
name: input.name,
description: input.description,
inLanguage: site.locale,
isPartOf: { "@id": `${absoluteUrl("/")}#website` },
};
}
export function breadcrumbSchema(items: Array<{ name: string; path: string }>): Record<string, unknown> {
return {
"@context": context,
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: absoluteUrl(item.path),
})),
};
}
export function articleSchema(input: {
title: string; description: string; path: string; image?: string;
author?: string; publishedAt?: Date; updatedAt?: Date;
}): Record<string, unknown> {
return {
"@context": context,
"@type": "BlogPosting",
headline: input.title,
description: input.description,
mainEntityOfPage: absoluteUrl(input.path),
...(input.image ? { image: absoluteUrl(input.image) } : {}),
...(input.publishedAt ? { datePublished: input.publishedAt.toISOString() } : {}),
...(input.updatedAt ? { dateModified: input.updatedAt.toISOString() } : {}),
author: input.author
? { "@type": "Person", name: input.author }
: { "@type": "Organization", "@id": `${absoluteUrl("/")}#organization` },
publisher: { "@id": `${absoluteUrl("/")}#organization` },
};
}
export function productSchema(input: {
title: string; description: string; path: string; kind: "product" | "service";
image?: string; brand?: string; sku?: string; price?: number; currency?: string;
}): Record<string, unknown> {
return {
"@context": context,
"@type": input.kind === "service" ? "Service" : "Product",
name: input.title,
description: input.description,
url: absoluteUrl(input.path),
...(input.image ? { image: absoluteUrl(input.image) } : {}),
...(input.brand ? { brand: { "@type": "Brand", name: input.brand } } : {}),
...(input.sku ? { sku: input.sku } : {}),
...(input.price !== undefined ? {
offers: {
"@type": "Offer",
price: input.price,
priceCurrency: input.currency || "CNY",
availability: "https://schema.org/InStock",
},
} : {}),
provider: { "@id": `${absoluteUrl("/")}#organization` },
};
}
---
import company from "../data/company.json";
import company from "../data/site.json";
---
<section class="section about" id="about"><div class="container about-grid">
<div><span class="eyebrow">ABOUT US</span><h2>理解变化,<br />也创造变化。</h2></div>
......
---
import company from "../data/company.json";
import company from "../data/site.json";
---
<section class="section contact" id="contact"><div class="container contact-card">
<div><span class="eyebrow">LET'S TALK</span><h2>准备好开启<br />下一段增长了吗?</h2></div>
......
---
import company from "../data/company.json";
import company from "../data/site.json";
---
<footer><div class="container footer-inner"><div class="brand"><span>W</span><strong>{company.name}</strong></div><p>© {new Date().getFullYear()} {company.name}. Crafted for progress.</p><a href="#top">返回顶部 ↑</a></div></footer>
<style>
......
---
import company from "../data/company.json";
import company from "../data/site.json";
---
<header class="header">
<div class="container nav">
<a class="brand" href="#top"><span class="brand-mark">W</span><span>{company.name}</span></a>
<nav><a href="#about">关于我们</a><a href="#services">服务能力</a><a href="#advantages">企业优势</a></nav>
<nav><a href="#about">关于我们</a><a href="#services">服务能力</a><a href="#advantages">企业优势</a><a href="articles/">文章</a></nav>
<a class="nav-action" href="#contact">联系合作 <span>↗</span></a>
</div>
</header>
......
---
import company from "../data/company.json";
import company from "../data/site.json";
import home from "../data/home.json";
const titleLines = home.heroTitle.split("\n");
---
......
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const seo = z.object({
title: z.string().max(70).optional(),
description: z.string().max(200).optional(),
ogDescription: z.string().max(240).optional(),
ogImage: z.string().optional(),
noindex: z.boolean().default(false),
}).default({ noindex: false });
const common = {
title: z.string().min(1),
summary: z.string().min(1).max(500),
status: z.enum(["draft", "published"]).default("draft"),
cover: z.string().optional(),
tags: z.array(z.string()).default([]),
publishedAt: z.coerce.date().optional(),
updatedAt: z.coerce.date().optional(),
seo,
};
const articles = defineCollection({
loader: glob({
pattern: "**/*.{md,mdx}",
base: process.env.SITE_ARTICLES_DIR || "./src/content/articles",
}),
schema: z.object({ ...common, author: z.string().optional() }),
});
const products = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/products" }),
schema: z.object({
...common,
kind: z.enum(["product", "service"]).default("service"),
brand: z.string().optional(),
sku: z.string().optional(),
price: z.number().nonnegative().optional(),
currency: z.string().length(3).default("CNY"),
}),
});
export const collections = { articles, products };
---
title: 示例服务
summary: 这是一个用于说明产品与服务内容格式的草稿,不会出现在公开网站中。
status: draft
kind: service
seo:
noindex: true
---
在这里介绍服务内容、适用对象、交付方式和客户可以获得的价值。
......@@ -4,5 +4,18 @@
"description": "以可信赖的数字化能力,帮助成长型企业连接技术、业务与长期价值。",
"email": "hello@example.com",
"phone": "400-888-2026",
"location": "上海 · 中国"
"location": "上海 · 中国",
"locale": "zh-CN",
"seo": {
"defaultTitle": "星澜智能|企业智能服务",
"titleTemplate": "%s|星澜智能",
"description": "以可信赖的数字化能力,帮助成长型企业连接技术、业务与长期价值。",
"defaultOgImage": "",
"favicon": "/images/branding/favicon.svg"
},
"organization": {
"legalName": "星澜智能",
"logo": "",
"sameAs": []
}
}
---
import "../styles/global.css";
interface Props { title: string; description: string; }
const { title, description } = Astro.props;
import SeoHead from "../_platform/seo/SeoHead.astro";
import site from "../data/site.json";
interface Props {
title: string;
description: string;
canonicalPath?: string;
image?: string;
type?: "website" | "article" | "product";
noindex?: boolean;
publishedTime?: string;
updatedTime?: string;
schemas?: Record<string, unknown>[];
absoluteTitle?: boolean;
}
const props = Astro.props;
---
<!doctype html>
<html lang="zh-CN">
<html lang={site.locale}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta name="description" content={description} />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><rect width=%2232%22 height=%2232%22 rx=%229%22 fill=%22%237028ff%22/><path d=%22M9 20L16 8l7 12-4 4-3-6-3 6z%22 fill=%22white%22/></svg>" />
<title>{title}</title>
<SeoHead {...props} />
<slot name="head" />
</head>
<body><slot /></body>
</html>
---
import BaseLayout from "./BaseLayout.astro";
import Footer from "../components/Footer.astro";
import { withBase } from "../_platform/seo/metadata";
import site from "../data/site.json";
import "../styles/global.css";
import "../styles/content.css";
type Props = Parameters<typeof BaseLayout>[0] & { eyebrow?: string };
const { eyebrow, ...seo } = Astro.props;
---
<BaseLayout {...seo}>
<header class="content-header"><div class="container">
<a class="content-brand" href={withBase("/")}><span>W</span>{site.name}</a>
<a href={withBase("/")}>返回首页</a>
</div></header>
<main class="content-main">
<div class="container content-shell">
{eyebrow && <span class="eyebrow">{eyebrow}</span>}
<slot />
</div>
</main>
<Footer />
</BaseLayout>
---
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";
export async function getStaticPaths() {
return (await publishedArticles()).map((entry) => ({ params: { id: entry.id.replace(/\.(md|mdx)$/i, "") }, props: { entry } }));
}
const { entry } = Astro.props as { entry: CollectionEntry<"articles"> };
const { Content } = await render(entry);
const path = entryPath("articles", entry.id);
const description = entry.data.seo.ogDescription || entry.data.seo.description || entry.data.summary;
---
<ContentLayout
title={entry.data.seo.title || entry.data.title}
description={description}
canonicalPath={path}
image={entry.data.seo.ogImage || entry.data.cover}
type="article"
noindex={entry.data.seo.noindex}
publishedTime={entry.data.publishedAt?.toISOString()}
updatedTime={entry.data.updatedAt?.toISOString()}
eyebrow="ARTICLE"
schemas={[
articleSchema({ title: entry.data.title, description, path, image: entry.data.cover, author: entry.data.author, publishedAt: entry.data.publishedAt, updatedAt: entry.data.updatedAt }),
breadcrumbSchema([{ name: "首页", path: "/" }, { name: "文章", path: "/articles/" }, { name: entry.data.title, path }]),
]}
>
<h1>{entry.data.title}</h1>
<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>}
{entry.data.tags.map((tag) => <span>#{tag}</span>)}
</div>
<article class="prose"><Content /></article>
</ContentLayout>
---
import ContentLayout from "../../layouts/ContentLayout.astro";
import { entryPath, publishedArticles } from "../../_platform/content/public-content";
import { breadcrumbSchema, webPageSchema } from "../../_platform/seo/schema";
import { withBase } from "../../_platform/seo/metadata";
const articles = await publishedArticles();
const title = "文章";
const description = "查看我们分享的行业洞察、实践经验与最新动态。";
---
<ContentLayout title={title} description={description} canonicalPath="/articles/" eyebrow="INSIGHTS" schemas={[
webPageSchema({ name: title, description, path: "/articles/" }),
breadcrumbSchema([{ name: "首页", path: "/" }, { name: title, path: "/articles/" }]),
]}>
<h1>{title}</h1><p class="content-lead">{description}</p>
{articles.length ? <div class="content-list">{articles.map((article) => <article>
<h2>{article.data.title}</h2><p>{article.data.summary}</p>
<a href={withBase(entryPath("articles", article.id))}>阅读全文 →</a>
</article>)}</div> : <p class="empty-content">文章正在准备中。</p>}
</ContentLayout>
......@@ -7,8 +7,19 @@ import Services from "../components/Services.astro";
import Advantages from "../components/Advantages.astro";
import Contact from "../components/Contact.astro";
import Footer from "../components/Footer.astro";
import company from "../data/company.json";
import site from "../data/site.json";
import "../styles/global.css";
import { organizationSchema, webPageSchema, webSiteSchema } from "../_platform/seo/schema";
---
<BaseLayout title={`${company.name}|${company.industry}`} description={company.description}>
<BaseLayout
title={site.seo.defaultTitle}
description={site.seo.description}
absoluteTitle
schemas={[organizationSchema(), webSiteSchema(), webPageSchema({
name: site.seo.defaultTitle,
description: site.seo.description,
path: "/",
})]}
>
<Header /><main><Hero /><About /><Services /><Advantages /><Contact /></main><Footer />
</BaseLayout>
import type { APIRoute } from "astro";
import { publishedArticles, publishedProducts } from "../_platform/content/public-content";
import { site } from "../_platform/seo/metadata";
const MAX_CHARACTERS = 200_000;
export const GET: APIRoute = async () => {
const entries = [
...(await publishedProducts()).filter((entry) => !entry.data.seo.noindex),
...(await publishedArticles()).filter((entry) => !entry.data.seo.noindex),
];
const sections = [`# ${site.name}\n\n> ${site.description}`];
for (const entry of entries) {
const body = entry.body || entry.data.summary;
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);
}
return new Response(`${sections.join("\n\n")}\n`, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
};
import type { APIRoute } from "astro";
import { entryPath, publishedArticles, publishedProducts } from "../_platform/content/public-content";
import { absoluteUrl, site } from "../_platform/seo/metadata";
export const GET: APIRoute = async () => {
const articles = (await publishedArticles()).filter((entry) => !entry.data.seo.noindex);
const products = (await publishedProducts()).filter((entry) => !entry.data.seo.noindex);
const lines = [
`# ${site.name}`,
"",
`> ${site.description}`,
"",
`- Industry: ${site.industry}`,
`- Website: ${absoluteUrl("/")}`,
...(site.email ? [`- Contact: ${site.email}`] : []),
"",
"## Products and services",
"",
...(products.length ? products.map((entry) => `- [${entry.data.title}](${absoluteUrl(entryPath("products", entry.id))}): ${entry.data.summary}`) : ["- No published items."]),
"",
"## Articles",
"",
...(articles.length ? articles.map((entry) => `- [${entry.data.title}](${absoluteUrl(entryPath("articles", entry.id))}): ${entry.data.summary}`) : ["- No published articles."]),
"",
];
return new Response(lines.join("\n"), { headers: { "Content-Type": "text/plain; charset=utf-8" } });
};
---
import { render, type CollectionEntry } from "astro:content";
import ContentLayout from "../../layouts/ContentLayout.astro";
import { entryPath, publishedProducts } from "../../_platform/content/public-content";
import { breadcrumbSchema, productSchema } from "../../_platform/seo/schema";
export async function getStaticPaths() {
return (await publishedProducts()).map((entry) => ({ params: { id: entry.id.replace(/\.(md|mdx)$/i, "") }, props: { entry } }));
}
const { entry } = Astro.props as { entry: CollectionEntry<"products"> };
const { Content } = await render(entry);
const path = entryPath("products", entry.id);
const description = entry.data.seo.ogDescription || entry.data.seo.description || entry.data.summary;
---
<ContentLayout
title={entry.data.seo.title || entry.data.title}
description={description}
canonicalPath={path}
image={entry.data.seo.ogImage || entry.data.cover}
type="product"
noindex={entry.data.seo.noindex}
eyebrow={entry.data.kind === "service" ? "SERVICE" : "PRODUCT"}
schemas={[
productSchema({ title: entry.data.title, description, path, kind: entry.data.kind, image: entry.data.cover, brand: entry.data.brand, sku: entry.data.sku, price: entry.data.price, currency: entry.data.currency }),
breadcrumbSchema([{ name: "首页", path: "/" }, { name: "产品与服务", path: "/products/" }, { name: entry.data.title, path }]),
]}
>
<h1>{entry.data.title}</h1>
<p class="content-lead">{entry.data.summary}</p>
<article class="prose"><Content /></article>
</ContentLayout>
---
import ContentLayout from "../../layouts/ContentLayout.astro";
import { entryPath, publishedProducts } from "../../_platform/content/public-content";
import { breadcrumbSchema, webPageSchema } from "../../_platform/seo/schema";
import { withBase } from "../../_platform/seo/metadata";
const products = await publishedProducts();
const title = "产品与服务";
const description = "了解我们的核心产品、服务能力与解决方案。";
---
<ContentLayout title={title} description={description} canonicalPath="/products/" eyebrow="PRODUCTS & SERVICES" schemas={[
webPageSchema({ name: title, description, path: "/products/" }),
breadcrumbSchema([{ name: "首页", path: "/" }, { name: title, path: "/products/" }]),
]}>
<h1>{title}</h1><p class="content-lead">{description}</p>
{products.length ? <div class="content-list">{products.map((product) => <article>
<h2>{product.data.title}</h2><p>{product.data.summary}</p>
<a href={withBase(entryPath("products", product.id))}>了解详情 →</a>
</article>)}</div> : <p class="empty-content">产品与服务内容正在准备中。</p>}
</ContentLayout>
import type { APIRoute } from "astro";
import { absoluteUrl, isIndexable } from "../_platform/seo/metadata";
export const GET: APIRoute = () => new Response(isIndexable
? `User-agent: *\nAllow: /\nSitemap: ${absoluteUrl("/sitemap.xml")}\n`
: "User-agent: *\nDisallow: /\n", {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
import type { APIRoute } from "astro";
import { entryPath, publishedArticles, publishedProducts } from "../_platform/content/public-content";
import { absoluteUrl, isIndexable } from "../_platform/seo/metadata";
const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, (character) => ({
"<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", "\"": "&quot;",
})[character]!);
export const GET: APIRoute = async () => {
const articles = (await publishedArticles()).filter((entry) => !entry.data.seo.noindex);
const products = (await publishedProducts()).filter((entry) => !entry.data.seo.noindex);
const urls = isIndexable ? [
{ path: "/", updatedAt: undefined },
{ path: "/articles/", updatedAt: undefined },
{ path: "/products/", updatedAt: undefined },
...articles.map((entry) => ({ path: entryPath("articles", entry.id), updatedAt: entry.data.updatedAt || entry.data.publishedAt })),
...products.map((entry) => ({ path: entryPath("products", entry.id), updatedAt: entry.data.updatedAt || entry.data.publishedAt })),
] : [];
const body = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map(({ path, updatedAt }) => ` <url><loc>${escapeXml(absoluteUrl(path))}</loc>${updatedAt ? `<lastmod>${updatedAt.toISOString()}</lastmod>` : ""}</url>`).join("\n")}\n</urlset>\n`;
return new Response(body, { headers: { "Content-Type": "application/xml; charset=utf-8" } });
};
.content-header { padding: 22px 0; border-bottom: 1px solid var(--color-divider); background: white; }
.content-header .container { display: flex; align-items: center; justify-content: space-between; }
.content-header a { color: var(--color-text-secondary); font-size: 14px; }
.content-brand { display: flex; align-items: center; gap: 10px; color: var(--color-text-primary) !important; font-weight: 800; }
.content-brand span { display: grid; place-items: center; width: 32px; height: 32px; color: white; border-radius: 10px; background: var(--gradient-primary); }
.content-main { min-height: 70vh; padding: 84px 0 112px; background: var(--color-background); }
.content-shell { max-width: 860px; }
.content-shell > h1 { margin: 18px 0 20px; font-size: clamp(42px, 7vw, 68px); line-height: 1.08; letter-spacing: -.05em; }
.content-lead { max-width: 720px; margin: 0 0 46px; color: var(--color-text-secondary); font-size: 18px; line-height: 1.8; }
.content-list { display: grid; gap: 16px; }
.content-list article { padding: 28px; border: 1px solid var(--color-border); border-radius: 18px; background: white; }
.content-list h2 { margin: 0 0 10px; font-size: 24px; }
.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; }
.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; }
.prose img { max-width: 100%; height: auto; border-radius: 16px; }
.empty-content { padding: 36px; color: var(--color-text-secondary); text-align: center; border: 1px dashed var(--color-border); border-radius: 18px; background: white; }
......@@ -22,6 +22,7 @@
"dotenv": "^16.5.0",
"execa": "^9.5.2",
"fastify": "^5.3.2",
"yaml": "^2.9.0",
"zod": "^3.24.2"
},
"devDependencies": {
......
......@@ -55,10 +55,17 @@ export class AgentLoop {
private async localCompanyPatch(projectPath: string, message: string): Promise<SitePatch> {
const name = message.match(/(?:改成|修改为)[\"']?([^“”\"',。]{2,30})/)?.[1]?.trim();
if (!name) throw new Error("请使用“把企业名称改成某某公司”的格式");
const file = path.join(projectPath, "src/data/company.json");
const file = path.join(projectPath, "src/data/site.json");
const data = JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
data.name = name;
return { summary: "将企业名称修改为“" + name + "”", operations: [{ type: "write", path: "src/data/company.json", content: JSON.stringify(data, null, 2) + "\n" }] };
const seo = data.seo as Record<string, unknown> | undefined;
if (seo) {
seo.defaultTitle = typeof data.industry === "string" ? `${name}${data.industry}` : name;
seo.titleTemplate = `%s|${name}`;
}
const organization = data.organization as Record<string, unknown> | undefined;
if (organization) organization.legalName = name;
return { summary: "将企业名称修改为“" + name + "”", operations: [{ type: "write", path: "src/data/site.json", content: JSON.stringify(data, null, 2) + "\n" }] };
}
private async localHeroPatch(projectPath: string, message: string): Promise<SitePatch> {
......
......@@ -27,6 +27,12 @@ test("AgentDiffValidator accepts source edits and rejects protected files", asyn
assert.equal(allowed.passed, true);
assert.deepEqual(allowed.files, ["src/index.ts"]);
await mkdir(path.join(root, "src", "_platform"));
await writeFile(path.join(root, "src", "_platform", "seo.ts"), "export const protectedSeo = true;\n");
const platformRejected = await new AgentDiffValidator().validate(root);
assert.equal(platformRejected.passed, false);
assert.ok(platformRejected.violations.some((violation) => violation.includes("平台能力内核")));
await writeFile(path.join(root, "package.json"), "{\"scripts\":{}}\n");
const rejected = await new AgentDiffValidator().validate(root);
assert.equal(rejected.passed, false);
......
......@@ -15,6 +15,8 @@ const protectedPath = (file: string) => file === "package.json" || file === "pnp
|| file === ".env" || file.startsWith(".env.") || file === ".git" || file.startsWith(".git/")
|| file === "dist" || file.startsWith("dist/") || file === "node_modules" || file.startsWith("node_modules/");
const allowedPath = (file: string) => file.startsWith("src/") || file.startsWith("public/images/");
const platformPath = (file: string) => file.startsWith("src/_platform/") || file === "src/content.config.ts"
|| file.startsWith("src/content/articles/");
export class AgentDiffValidator {
async validate(workspace: string): Promise<AgentDiffValidation> {
......@@ -35,6 +37,7 @@ export class AgentDiffValidator {
continue;
}
if (protectedPath(file)) violations.push(`禁止修改受保护路径: ${file}`);
if (platformPath(file)) violations.push(`禁止修改平台能力内核: ${file}`);
if (!allowedPath(file)) violations.push(`路径不在网站可编辑范围: ${file}`);
const candidate = path.resolve(workspace, file);
try {
......
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { AgentRunEvent } from "@webagent/shared";
import { config } from "../config.js";
import { applyPatch } from "../sites/apply-patch.js";
import { validatePatch } from "../security/path-policy.js";
import { AgentLoop } from "./agent-loop.js";
......@@ -21,6 +24,8 @@ export class LocalAgentProvider implements CodingAgentProvider {
};
const result = (async (): Promise<AgentProviderResult> => {
try {
await readFile(path.join(input.workingDirectory, "TEMPLATE.md"), "utf8")
.catch(() => readFile(path.join(config.templateDir, "TEMPLATE.md"), "utf8"));
emit("assistant", "本地 Agent 已开始分析修改要求");
const patch = await this.agent.generateLocal(input.workingDirectory, input.prompt);
if (controller.signal.aborted) return { status: "cancelled", error: "Agent 运行已取消" };
......
......@@ -89,7 +89,10 @@ export class AgentOrchestrator {
const buildStarted = Date.now();
let dist: string;
try {
dist = await this.builds.build(workspace, "agent_" + runId, getPublicPreviewUrl(run.tenantId, run.siteId));
dist = await this.builds.build(workspace, "agent_" + runId, {
basePath: getPublicPreviewUrl(run.tenantId, run.siteId), indexable: false,
articlesDirectory: this.sites.getArticlesPath(run.tenantId, run.siteId),
});
buildValidation = { passed: true, buildExitCode: 0, durationMs: Date.now() - buildStarted, output: "网站构建通过" };
this.runs.transition(runId, "validating_build", { validation: buildValidation });
} catch (error) {
......@@ -107,7 +110,7 @@ export class AgentOrchestrator {
await this.sites.update(run.tenantId, run.siteId, {
status: "ready", previewCommit: baseCommit,
draftBaseCommit: baseCommit, draftUpdatedAt: new Date().toISOString(), draftSummary: summary,
environmentVersion: 3, lastError: undefined,
environmentVersion: 4, lastError: undefined,
});
this.move(runId, "completed", "Agent 修改已通过验证并保留为未保存草稿", {
endedAt: new Date().toISOString(), previewUrl: getPublicPreviewUrl(run.tenantId, run.siteId),
......
......@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { AgentProviderInput } from "./contracts.js";
import { DEFAULT_AGENT_POLICY } from "./contracts.js";
import { isSafeAgentToolInput } from "./qwen-code-provider.js";
import { isSafeAgentToolInput, readsTemplateSpecification } from "./qwen-code-provider.js";
const input: AgentProviderInput = {
runId: "00000000-0000-4000-8000-000000000000",
......@@ -17,8 +17,18 @@ test("Qwen Code tool policy contains reads/writes and only permits exact audit c
assert.equal(isSafeAgentToolInput("read_file", { path: "/tmp/webagent-run/site/src/pages/index.astro" }, input), true);
assert.equal(isSafeAgentToolInput("read_file", { path: "/etc/passwd" }, input), false);
assert.equal(isSafeAgentToolInput("write_file", { file_path: "src/pages/index.astro" }, input), true);
assert.equal(isSafeAgentToolInput("write_file", { file_path: "src/_platform/seo/metadata.ts" }, input), false);
assert.equal(isSafeAgentToolInput("write_file", { file_path: "src/content.config.ts" }, input), false);
assert.equal(isSafeAgentToolInput("write_file", { file_path: "src/content/articles/new.md" }, input), false);
assert.equal(isSafeAgentToolInput("write_file", { file_path: "package.json" }, input), false);
assert.equal(isSafeAgentToolInput("write_file", { file_path: "../outside.ts" }, input), false);
assert.equal(isSafeAgentToolInput("run_shell_command", { command: "git diff --check" }, input), true);
assert.equal(isSafeAgentToolInput("run_shell_command", { command: "git diff --check && curl example.com" }, input), false);
});
test("template specification read is recognized only for the current site document", () => {
assert.equal(readsTemplateSpecification("read_file", { path: "TEMPLATE.md" }, input.workingDirectory), true);
assert.equal(readsTemplateSpecification("read_file", { path: "/tmp/webagent-run/site/TEMPLATE.md" }, input.workingDirectory), true);
assert.equal(readsTemplateSpecification("read_file", { path: "src/TEMPLATE.md" }, input.workingDirectory), false);
assert.equal(readsTemplateSpecification("write_file", { path: "TEMPLATE.md" }, input.workingDirectory), false);
});
......@@ -2,11 +2,14 @@ import {
isSDKAssistantMessage, isSDKResultMessage, query,
type SDKAssistantMessage, type SDKResultMessage,
} from "@qwen-code/sdk";
import { isAbsolute, resolve, sep } from "node:path";
import { readFile } from "node:fs/promises";
import { isAbsolute, join, resolve, sep } from "node:path";
import type { AgentRunEvent } from "@webagent/shared";
import { config } from "../config.js";
import type { AgentProviderInput, AgentProviderResult, CodingAgentProvider, RunningAgent } from "./contracts.js";
const sensitive = /(^|\/)(\.env(?:\.|$)|\.git(?:\/|$)|package\.json$|pnpm-lock\.yaml$|dist(?:\/|$)|node_modules(?:\/|$))/;
const platformPath = /(^|\/)src\/(?:_platform\/|content\/articles\/|content\.config\.ts(?:["'\s]|$))/;
function isContained(candidatePath: string, rootPath: string): boolean {
const candidate = resolve(candidatePath);
......@@ -14,6 +17,14 @@ function isContained(candidatePath: string, rootPath: string): boolean {
return candidate === root || candidate.startsWith(root + sep);
}
export function readsTemplateSpecification(toolName: string, toolInput: Record<string, unknown>, workingDirectory: string): boolean {
if (toolName !== "read_file") return false;
const file = typeof toolInput.file_path === "string" ? toolInput.file_path : typeof toolInput.path === "string" ? toolInput.path : undefined;
if (!file) return false;
const absolute = isAbsolute(file) ? resolve(file) : resolve(workingDirectory, file);
return absolute === resolve(workingDirectory, "TEMPLATE.md");
}
export function isSafeAgentToolInput(toolName: string, toolInput: Record<string, unknown>, input: AgentProviderInput): boolean {
const serialized = JSON.stringify(toolInput);
if (sensitive.test(serialized)) return false;
......@@ -22,7 +33,7 @@ export function isSafeAgentToolInput(toolName: string, toolInput: Record<string,
}
if (toolName === "edit" || toolName === "write_file") {
const file = typeof toolInput.file_path === "string" ? toolInput.file_path : typeof toolInput.path === "string" ? toolInput.path : undefined;
if (!file || file.includes("..")) return false;
if (!file || file.includes("..") || platformPath.test(file)) return false;
const absolute = isAbsolute(file) ? file : resolve(input.workingDirectory, file);
return isContained(absolute, resolve(input.workingDirectory, "src"));
}
......@@ -65,10 +76,14 @@ export class QwenCodeProvider implements CodingAgentProvider {
readonly version = "0.1.8";
async start(input: AgentProviderInput): Promise<RunningAgent> {
const siteSpecification = await readFile(join(input.workingDirectory, "TEMPLATE.md"), "utf8")
.then((content) => ({ content, local: true }))
.catch(async () => ({ content: await readFile(join(config.templateDir, "TEMPLATE.md"), "utf8"), local: false }));
const controller = new AbortController();
const events: AgentRunEvent[] = [];
let done = false;
let wake: (() => void) | undefined;
let specificationRead = !siteSpecification.local;
const emit = (type: AgentRunEvent["type"], message: string, data?: Record<string, unknown>) => {
events.push({ runId: input.runId, type, timestamp: new Date().toISOString(), message, ...(data ? { data } : {}) });
wake?.(); wake = undefined;
......@@ -88,17 +103,26 @@ export class QwenCodeProvider implements CodingAgentProvider {
env: { ...process.env, SEATBELT_PROFILE: "restrictive-open" },
systemPrompt: {
type: "preset", preset: "qwen_code",
append: `你正在修改 WebAgent 的 Astro 网站。只能在 ${input.workingDirectory} 内工作。使用 read_file/glob/grep 检查代码;只能用 edit/write_file 修改 src;shell 只能执行明确允许的 Git 自检命令。完成编辑后重新读取每个改动文件,并依次执行 git diff --check、git diff --no-ext-diff、git status --short。发现问题必须修复。禁止安装依赖、构建、测试、部署、联网、computer-use 以及访问工作区外路径。`,
append: `你正在修改 WebAgent 的 Astro 网站。${siteSpecification.local ? `第一步必须使用 read_file 完整阅读 ${input.workingDirectory}/TEMPLATE.md;在此之前禁止任何 edit/write_file。` : "这是旧站点,站点内暂缺规范文件;你必须遵守下方已读取的基础模版规范。"}只能在 ${input.workingDirectory} 内工作。使用 read_file/glob/grep 检查代码;只能用 edit/write_file 修改 src,但禁止修改 src/_platform、src/content.config.ts 和 src/content/articles;文章只能由平台文章管理服务维护。shell 只能执行明确允许的 Git 自检命令。页面布局、组件和样式可自由调整。完成编辑后重新读取每个改动文件,并依次执行 git diff --check、git diff --no-ext-diff、git status --short。发现问题必须修复。禁止安装依赖、构建、测试、部署、联网、computer-use 以及访问工作区外路径。\n\n网站修改规范:\n${siteSpecification.content}`,
},
coreTools: ["read_file", "read_many_files", "list_directory", "glob", "grep_search", "edit", "write_file", "run_shell_command"],
excludeTools: [
"Read(/.env)", "Read(/.env.*)", "Read(/.qwen/**)", "Read(/.git/**)", "Read(/node_modules/**)", "Read(/dist/**)",
"Edit(/.env)", "Edit(/.env.*)", "Edit(/.qwen/**)", "Edit(/package.json)", "Edit(/pnpm-lock.yaml)", "Edit(/.git/**)", "Edit(/dist/**)", "Edit(/node_modules/**)",
"Edit(/src/_platform/**)", "Edit(/src/content.config.ts)",
"Edit(/src/content/articles/**)",
"Bash(git push*)", "Bash(git remote*)", "Bash(pnpm add*)", "Bash(pnpm install*)", "Bash(npm install*)", "Bash(curl*)", "Bash(wget*)",
],
canUseTool: async (toolName, toolInput) => isSafeAgentToolInput(toolName, toolInput, input)
? { behavior: "allow", updatedInput: toolInput }
: { behavior: "deny", message: `WebAgent 安全策略拒绝了工具调用: ${toolName}` },
canUseTool: async (toolName, toolInput) => {
if (!isSafeAgentToolInput(toolName, toolInput, input)) {
return { behavior: "deny", message: `WebAgent 安全策略拒绝了工具调用: ${toolName}` };
}
if ((toolName === "edit" || toolName === "write_file") && !specificationRead) {
return { behavior: "deny", message: "修改网站前必须先完整阅读 TEMPLATE.md" };
}
if (readsTemplateSpecification(toolName, toolInput, input.workingDirectory)) specificationRead = true;
return { behavior: "allow", updatedInput: toolInput };
},
},
});
const result = (async (): Promise<AgentProviderResult> => {
......
import assert from "node:assert/strict";
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import type { ArticleInput, SiteInfo } from "@webagent/shared";
import type { BuildManager, SiteBuildContext } from "../build/build-manager.js";
import type { PreviewProcessManager } from "../preview/preview-process-manager.js";
import type { SiteRepository } from "../sites/site-repository.js";
import { ArticleService } from "./article-service.js";
const exists = (target: string) => access(target).then(() => true).catch(() => false);
test("article mutations use external content storage without creating Git-managed article files", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "webagent-articles-"));
const project = path.join(root, "project");
const articles = path.join(root, "content", "articles");
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");
const now = new Date().toISOString();
let site: SiteInfo = {
tenantId: "tenant_test", siteId: "site_test", name: "Test", industry: "Test",
status: "ready", templateVersion: "1", previewPort: 4300, previewUrl: "/preview/",
currentCommit: "abc1234", previewCommit: "abc1234", publishStatus: "unpublished",
createdAt: now, updatedAt: now,
};
let buildArticlesDirectory = "";
const sites = {
get: async () => site,
update: async (_tenantId: string, _siteId: string, values: Partial<SiteInfo>) => {
site = { ...site, ...values, updatedAt: new Date().toISOString() };
return site;
},
getProjectPath: () => project,
getDraftPath: () => path.join(root, "draft"),
getArticlesPath: () => articles,
} as unknown as SiteRepository;
const builds = {
build: async (_workspace: string, _taskId: string, context: SiteBuildContext) => {
buildArticlesDirectory = context.articlesDirectory || "";
assert.match(await readFile(path.join(buildArticlesDirectory, "hello.md"), "utf8"), /title: Hello/);
await mkdir(dist, { recursive: true });
return dist;
},
publishPreview: async () => dist,
} as unknown as BuildManager;
const previews = { start: async () => undefined } as unknown as PreviewProcessManager;
const article: ArticleInput = {
slug: "hello", title: "Hello", summary: "Summary", body: "Article body", status: "draft",
tags: [], seo: { noindex: false },
};
try {
const result = await new ArticleService(sites, builds, previews).create("tenant_test", "site_test", article);
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-/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
import crypto from "node:crypto";
import path from "node:path";
import { access, cp, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
import type { ArticleDocument, ArticleInput, ArticleListResult, ArticleMutationResult, ArticleSeoInput, SiteInfo } from "@webagent/shared";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { BuildError, BuildManager } from "../build/build-manager.js";
import { getPublicPreviewUrl } from "../config.js";
import { PreviewProcessManager } from "../preview/preview-process-manager.js";
import { articleInputSchema } from "../schemas.js";
import { SiteRepository } from "../sites/site-repository.js";
type Frontmatter = Record<string, unknown>;
export class ArticleService {
private readonly queues = new Map<string, Promise<unknown>>();
constructor(
private readonly sites: SiteRepository,
private readonly builds: BuildManager,
private readonly previews: PreviewProcessManager,
) {}
async list(tenantId: string, siteId: string): Promise<ArticleListResult> {
const site = await this.sites.get(tenantId, siteId);
if (!await this.supported(this.sites.getProjectPath(tenantId, siteId))) return { supported: false, articles: [] };
const directory = this.sites.getArticlesPath(tenantId, siteId);
await mkdir(directory, { recursive: true });
const files = (await readdir(directory, { withFileTypes: true }))
.filter((entry) => entry.isFile() && !entry.name.startsWith("_") && /\.md$/i.test(entry.name))
.map((entry) => entry.name);
const articles = await Promise.all(files.map((file) => this.readArticleFile(path.join(directory, file), file.replace(/\.md$/i, ""))));
return {
supported: true,
articles: articles.map(({ body: _body, ...article }) => article).sort((a, b) => {
const left = a.updatedAt || a.publishedAt || "";
const right = b.updatedAt || b.publishedAt || "";
return right.localeCompare(left) || a.title.localeCompare(b.title, "zh-CN");
}),
};
}
async get(tenantId: string, siteId: string, slug: string): Promise<ArticleDocument> {
this.assertSlug(slug);
await this.sites.get(tenantId, siteId);
await this.assertSupported(this.sites.getProjectPath(tenantId, siteId));
return this.readArticleFile(this.articlePath(this.sites.getArticlesPath(tenantId, siteId), slug), slug);
}
async create(tenantId: string, siteId: string, input: ArticleInput): Promise<ArticleMutationResult> {
return this.mutate(tenantId, siteId, async (directory) => {
const destination = this.articlePath(directory, input.slug);
if (await this.exists(destination)) throw Object.assign(new Error("Slug 已存在,请更换后再保存"), { statusCode: 409 });
await mkdir(path.dirname(destination), { recursive: true });
await writeFile(destination, serializeArticle(input), "utf8");
return { article: input, summary: `新增文章:${input.title}` };
});
}
async update(tenantId: string, siteId: string, currentSlug: string, input: ArticleInput): Promise<ArticleMutationResult> {
this.assertSlug(currentSlug);
return this.mutate(tenantId, siteId, async (directory) => {
const source = this.articlePath(directory, currentSlug);
if (!await this.exists(source)) throw Object.assign(new Error("文章不存在"), { statusCode: 404 });
const destination = this.articlePath(directory, input.slug);
if (input.slug !== currentSlug && await this.exists(destination)) {
throw Object.assign(new Error("新 Slug 已存在,请更换后再保存"), { statusCode: 409 });
}
const next = serializeArticle(input);
const previous = input.slug === currentSlug ? await readFile(source, "utf8") : undefined;
if (previous === next) throw Object.assign(new Error("文章没有实际变化"), { statusCode: 409 });
await writeFile(destination, next, "utf8");
if (destination !== source) await rm(source, { force: true });
return { article: input, summary: `更新文章:${input.title}` };
});
}
async remove(tenantId: string, siteId: string, slug: string): Promise<ArticleMutationResult> {
this.assertSlug(slug);
return this.mutate(tenantId, siteId, async (directory) => {
const source = this.articlePath(directory, slug);
if (!await this.exists(source)) throw Object.assign(new Error("文章不存在"), { statusCode: 404 });
const article = await this.readArticleFile(source, slug);
await rm(source, { force: true });
return { deletedSlug: slug, summary: `删除文章:${article.title}` };
});
}
async importMarkdown(tenantId: string, siteId: string, filename: string, markdown: string): Promise<ArticleMutationResult> {
const parsed = importedArticle(filename, markdown);
return this.create(tenantId, siteId, articleInputSchema.parse(parsed));
}
private async mutate(
tenantId: string,
siteId: string,
operation: (articlesDirectory: string) => Promise<{ article?: ArticleInput; deletedSlug?: string; summary: string }>,
): Promise<ArticleMutationResult> {
return this.withLock(`${tenantId}/${siteId}`, async () => {
const site = await this.sites.get(tenantId, siteId);
if (site.status === "building") throw Object.assign(new Error("网站正在执行其他构建,请稍后重试"), { statusCode: 409 });
if (site.previewCommit && site.previewCommit !== site.currentCommit && !site.draftBaseCommit) {
throw Object.assign(new Error("当前正在查看历史版本,请先切换回最近保存版本"), { statusCode: 409 });
}
const project = this.sites.getProjectPath(tenantId, siteId);
const workspace = await this.readWorkspace(site);
await this.assertSupported(project);
const articlesDirectory = this.sites.getArticlesPath(tenantId, siteId);
const suffix = crypto.randomBytes(5).toString("hex");
const stagingDirectory = path.join(path.dirname(articlesDirectory), `.articles-next-${suffix}`);
const previousDirectory = path.join(path.dirname(articlesDirectory), `.articles-previous-${suffix}`);
await mkdir(articlesDirectory, { recursive: true });
await cp(articlesDirectory, stagingDirectory, { recursive: true });
let changed: { article?: ArticleInput; deletedSlug?: string; summary: string };
try {
changed = await operation(stagingDirectory);
} catch (error) {
await rm(stagingDirectory, { recursive: true, force: true });
throw error;
}
await this.sites.update(tenantId, siteId, { status: "building", lastError: undefined });
let swapped = false;
try {
const dist = await this.builds.build(workspace, `article_${crypto.randomBytes(4).toString("hex")}`, {
basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false, articlesDirectory: stagingDirectory,
});
await rename(articlesDirectory, previousDirectory);
await rename(stagingDirectory, articlesDirectory);
swapped = true;
const published = await this.builds.publishPreview(tenantId, siteId, dist);
await this.previews.start(tenantId, siteId, published, site.previewPort);
await this.sites.update(tenantId, siteId, {
status: "ready", previewCommit: site.draftBaseCommit || site.currentCommit,
environmentVersion: 4, lastError: undefined,
});
const article = changed.article
? await this.readArticleFile(this.articlePath(articlesDirectory, changed.article.slug), changed.article.slug)
: undefined;
return { article, deletedSlug: changed.deletedSlug, previewUrl: site.previewUrl };
} catch (error) {
if (swapped) {
await rm(articlesDirectory, { recursive: true, force: true });
await rename(previousDirectory, articlesDirectory).catch(() => undefined);
}
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.sites.update(tenantId, siteId, {
status: site.previewCommit ? "ready" : "failed",
lastError: details,
});
throw error;
} finally {
await Promise.all([
rm(stagingDirectory, { recursive: true, force: true }),
rm(previousDirectory, { recursive: true, force: true }),
]);
}
});
}
private async readWorkspace(site: SiteInfo): Promise<string> {
if (!site.draftBaseCommit) return this.sites.getProjectPath(site.tenantId, site.siteId);
const draft = this.sites.getDraftPath(site.tenantId, site.siteId);
if (!await this.exists(draft)) throw Object.assign(new Error("工作草稿目录不存在,请刷新站点后重试"), { statusCode: 409 });
return draft;
}
private async readArticleFile(file: string, slug: string): Promise<ArticleDocument> {
try {
const parsed = parseMarkdown(await readFile(file, "utf8"));
const input = articleInputSchema.parse({ slug, ...parsed.data, body: parsed.body });
return { ...input, wordCount: wordCount(input.body) };
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") throw Object.assign(new Error("文章不存在"), { statusCode: 404 });
throw Object.assign(new Error(`文章 ${slug} 格式无效:${error instanceof Error ? error.message : String(error)}`), { statusCode: 400 });
}
}
private articlePath(directory: string, slug: string): string { this.assertSlug(slug); return path.join(directory, `${slug}.md`); }
private async supported(workspace: string): Promise<boolean> {
return this.exists(path.join(workspace, "src", "content.config.ts"));
}
private async assertSupported(workspace: string): Promise<void> {
if (!await this.supported(workspace)) throw Object.assign(new Error("当前网站使用旧版模版,尚不支持平台文章管理"), { statusCode: 409 });
}
private assertSlug(slug: string): void {
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) throw Object.assign(new Error("无效的文章 Slug"), { statusCode: 400 });
}
private exists(target: string): Promise<boolean> { return access(target).then(() => true).catch(() => false); }
private async withLock<T>(key: string, operation: () => Promise<T>): Promise<T> {
const previous = this.queues.get(key) || Promise.resolve();
const current = previous.catch(() => undefined).then(operation);
this.queues.set(key, current);
try { return await current; } finally { if (this.queues.get(key) === current) this.queues.delete(key); }
}
}
function parseMarkdown(markdown: string): { data: Frontmatter; body: string } {
const normalized = markdown.replaceAll("\r\n", "\n");
if (!normalized.startsWith("---\n")) return { data: {}, body: normalized.trim() };
const boundary = normalized.indexOf("\n---", 4);
if (boundary < 0) throw new Error("Frontmatter 缺少结束分隔符");
const data = parseYaml(normalized.slice(4, boundary)) as Frontmatter | null;
return { data: data && typeof data === "object" ? data : {}, body: normalized.slice(boundary + 4).replace(/^\n/, "").trim() };
}
function serializeArticle(article: ArticleInput): string {
const frontmatter = {
title: article.title,
summary: article.summary,
status: article.status,
...(article.author ? { author: article.author } : {}),
...(article.cover ? { cover: article.cover } : {}),
tags: article.tags,
...(article.publishedAt ? { publishedAt: article.publishedAt } : {}),
...(article.updatedAt ? { updatedAt: article.updatedAt } : {}),
seo: compactSeo(article.seo),
};
return `---\n${stringifyYaml(frontmatter, { lineWidth: 0 }).trim()}\n---\n\n${article.body.trim()}\n`;
}
function compactSeo(seo: ArticleSeoInput): ArticleSeoInput {
return {
...(seo.title ? { title: seo.title } : {}),
...(seo.description ? { description: seo.description } : {}),
...(seo.ogDescription ? { ogDescription: seo.ogDescription } : {}),
...(seo.ogImage ? { ogImage: seo.ogImage } : {}),
noindex: seo.noindex,
};
}
function importedArticle(filename: string, markdown: string): ArticleInput {
const parsed = parseMarkdown(markdown);
const data = parsed.data;
const filenameSlug = filename.replace(/\.md$/i, "");
const titleFromBody = parsed.body.match(/^#\s+(.+)$/m)?.[1]?.trim();
const title = text(data.title) || titleFromBody || filenameSlug || "导入文章";
const summary = text(data.summary) || text(data.description) || firstParagraph(parsed.body) || title;
const seoData = data.seo && typeof data.seo === "object" ? data.seo as Frontmatter : {};
return {
slug: slugify(text(data.slug) || filenameSlug),
title,
summary: summary.slice(0, 500),
body: parsed.body,
status: data.status === "published" ? "published" : "draft",
author: text(data.author) || undefined,
cover: text(data.cover) || undefined,
tags: Array.isArray(data.tags) ? data.tags.map(String).filter(Boolean).slice(0, 20) : [],
publishedAt: dateText(data.publishedAt),
updatedAt: dateText(data.updatedAt),
seo: {
title: text(seoData.title) || undefined,
description: text(seoData.description) || undefined,
ogDescription: text(seoData.ogDescription) || undefined,
ogImage: text(seoData.ogImage) || undefined,
noindex: seoData.noindex === true,
},
};
}
function slugify(value: string): string {
const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 100);
return normalized.length >= 2 ? normalized : `article-${Date.now()}`;
}
function firstParagraph(body: string): string {
return body.split(/\n\s*\n/).map((value) => value.replace(/^#+\s*/g, "").trim()).find((value) => value && !value.startsWith("!")) || "";
}
function text(value: unknown): string { return typeof value === "string" ? value.trim() : ""; }
function dateText(value: unknown): string | undefined {
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10);
const raw = text(value);
return /^\d{4}-\d{2}-\d{2}/.test(raw) ? raw.slice(0, 10) : undefined;
}
function wordCount(body: string): number {
const textBody = body.replace(/[`*_>#\[\]()!-]/g, " ");
const latin = textBody.match(/[A-Za-z0-9]+/g)?.length || 0;
const han = textBody.match(/[\u3400-\u9fff]/g)?.length || 0;
return latin + han;
}
......@@ -8,10 +8,24 @@ export class BuildError extends Error {
constructor(message: string, public readonly output: string) { super(message); }
}
export interface SiteBuildContext {
basePath: string;
publicOrigin?: string;
indexable?: boolean;
articlesDirectory?: string;
}
export class BuildManager {
async build(projectPath: string, taskId: string, basePath: string): Promise<string> {
async build(projectPath: string, taskId: string, context: string | SiteBuildContext): Promise<string> {
await mkdir(runtimePaths.logs, { recursive: true });
await Promise.all([
rm(path.join(projectPath, ".astro"), { recursive: true, force: true }),
rm(path.join(projectPath, "node_modules", ".astro"), { recursive: true, force: true }),
]);
let output = "";
const options = typeof context === "string" ? { basePath: context } : context;
const publicOrigin = (options.publicOrigin || config.frontendOrigin).replace(/\/$/, "");
if (options.articlesDirectory) await mkdir(options.articlesDirectory, { recursive: true });
try {
const install = await execa("pnpm", ["install", "--store-dir", runtimePaths.pnpmStore, "--prefer-offline"], {
cwd: projectPath,
......@@ -20,7 +34,14 @@ export class BuildManager {
output += install.stdout + "\n" + install.stderr + "\n";
const build = await execa("pnpm", ["run", "build"], {
cwd: projectPath,
env: { ...process.env, CI: "true", SITE_BASE_PATH: basePath.replace(/\/$/, "") || "/" },
env: {
...process.env,
CI: "true",
SITE_BASE_PATH: options.basePath.replace(/\/$/, "") || "/",
SITE_PUBLIC_ORIGIN: publicOrigin,
SITE_INDEXABLE: options.indexable === true ? "true" : "false",
...(options.articlesDirectory ? { SITE_ARTICLES_DIR: options.articlesDirectory } : {}),
},
});
output += build.stdout + "\n" + build.stderr;
await writeFile(path.join(runtimePaths.logs, taskId + ".log"), output, "utf8");
......
......@@ -41,7 +41,12 @@ export class DomainDeploymentService {
try {
await this.git.createWorktree(project, workspace, commit);
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, "/");
const primary = bindings.find((domain) => domain.ownershipStatus === "verified" && domain.isPrimary)
|| bindings.find((domain) => domain.ownershipStatus === "verified");
const dist = await this.builds.build(workspace, taskId, {
basePath: "/", publicOrigin: primary ? `https://${primary.hostname}` : undefined, indexable: true,
articlesDirectory: this.sites.getArticlesPath(tenantId, siteId),
});
await this.builds.publishCustomDomain(tenantId, siteId, dist);
await this.domains.updateSite(tenantId, siteId, (domain) => domain.ownershipStatus === "verified"
? { deploymentStatus: "active", deployedCommit: commit, lastDeploymentError: undefined }
......
......@@ -33,6 +33,33 @@ export const saveDraftSchema = z.object({
message: z.string().trim().min(2, "版本说明至少 2 个字符").max(120),
});
const optionalDate = z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/, "日期格式必须是 YYYY-MM-DD").optional().or(z.literal("").transform(() => undefined));
export const articleInputSchema = z.object({
slug: z.string().trim().min(2).max(100).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Slug 只能包含小写字母、数字和短横线"),
title: z.string().trim().min(1).max(120),
summary: z.string().trim().min(1).max(500),
body: z.string().max(256 * 1024),
status: z.enum(["draft", "published"]),
author: z.string().trim().max(80).optional().or(z.literal("").transform(() => undefined)),
cover: z.string().trim().max(300).optional().or(z.literal("").transform(() => undefined)),
tags: z.array(z.string().trim().min(1).max(40)).max(20).default([]),
publishedAt: optionalDate,
updatedAt: optionalDate,
seo: z.object({
title: z.string().trim().max(70).optional().or(z.literal("").transform(() => undefined)),
description: z.string().trim().max(200).optional().or(z.literal("").transform(() => undefined)),
ogDescription: z.string().trim().max(240).optional().or(z.literal("").transform(() => undefined)),
ogImage: z.string().trim().max(300).optional().or(z.literal("").transform(() => undefined)),
noindex: z.boolean().default(false),
}),
});
export const articleImportSchema = z.object({
filename: z.string().trim().min(1).max(180),
markdown: z.string().min(1).max(256 * 1024),
});
export const addDomainSchema = z.object({
hostname: z.string().trim().min(4, "请输入完整域名").max(253),
});
......
......@@ -7,6 +7,7 @@ const allowedPatterns = [
/^src\/.+\.json$/, /^src\/.+\.md$/, /^public\/images\/.+$/,
];
const forbiddenParts = new Set([".git", "node_modules", "dist", ".astro"]);
const protectedPrefixes = ["src/_platform/", "src/content.config.ts", "src/content/articles/"];
export async function validatePatch(patch: SitePatch, workspacePath: string): Promise<void> {
if (patch.operations.length > 5) throw new Error("单次最多修改 5 个文件");
......@@ -15,6 +16,7 @@ export async function validatePatch(patch: SitePatch, workspacePath: string): Pr
const normalized = operation.path.replaceAll("\\", "/");
if (path.isAbsolute(normalized) || normalized.includes("..") || normalized.startsWith("/")) throw new Error("Patch 包含不安全路径");
if (normalized.split("/").some((part) => forbiddenParts.has(part))) throw new Error("Patch 尝试修改禁止目录");
if (protectedPrefixes.some((prefix) => normalized === prefix || normalized.startsWith(prefix))) throw new Error("Patch 尝试修改平台能力内核");
if (!allowedPatterns.some((pattern) => pattern.test(normalized))) throw new Error("Patch 路径不在白名单中: " + normalized);
if (seen.has(normalized)) throw new Error("Patch 中存在重复路径: " + normalized);
seen.add(normalized);
......
......@@ -23,19 +23,32 @@ export class CreateSiteService {
const previewPort = await this.sites.allocatePreviewPort();
const now = new Date().toISOString();
const site: SiteInfo = {
tenantId, siteId, name: input.name, industry: input.industry, status: "creating", templateVersion: "0.1.0", environmentVersion: 3,
tenantId, siteId, name: input.name, industry: input.industry, status: "creating", templateVersion: "1.0.0", environmentVersion: 4,
previewPort, previewUrl: getPublicPreviewUrl(tenantId, siteId), currentCommit: "", publishStatus: "unpublished",
createdAt: now, updatedAt: now,
};
await mkdir(projectPath, { recursive: true });
const articlesDirectory = this.sites.getArticlesPath(tenantId, siteId);
await Promise.all([mkdir(projectPath, { recursive: true }), mkdir(articlesDirectory, { 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 rm(path.join(projectPath, "src/data/company.json"), { force: true });
await writeFile(path.join(projectPath, "src/data/company.json"), JSON.stringify({
name: input.name, industry: input.industry, description: input.description,
email: input.email, phone: input.phone || "", location: "中国",
}, null, 2) + "\n", "utf8");
const siteDataPath = path.join(projectPath, "src/data/site.json");
const siteData = JSON.parse(await readFile(siteDataPath, "utf8")) as {
name: string; industry: string; description: string; email: string; phone: string;
seo: { defaultTitle: string; titleTemplate: string; description: string };
organization: { legalName: string };
};
siteData.name = input.name;
siteData.industry = input.industry;
siteData.description = input.description;
siteData.email = input.email;
siteData.phone = input.phone || "";
siteData.seo.defaultTitle = `${input.name}${input.industry}`;
siteData.seo.titleTemplate = `%s|${input.name}`;
siteData.seo.description = input.description;
siteData.organization.legalName = input.name;
await writeFile(siteDataPath, JSON.stringify(siteData, null, 2) + "\n", "utf8");
const homePath = path.join(projectPath, "src/data/home.json");
const home = JSON.parse(await readFile(homePath, "utf8")) as { services: Array<{ number: string; title: string; description: string }> };
home.services = input.services.map((service, index) => ({ number: String(index + 1).padStart(2, "0"), title: service, description: "以专业方法和可靠交付,为客户提供高质量的" + service + "服务。" }));
......@@ -43,7 +56,9 @@ export class CreateSiteService {
const themePath = path.join(projectPath, "src/styles/theme.css");
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, getPublicPreviewUrl(tenantId, siteId));
const dist = await this.builds.build(projectPath, "create_" + siteId, {
basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false, articlesDirectory,
});
const commit = await this.git.init(projectPath);
await this.sites.update(tenantId, siteId, { currentCommit: commit });
const published = await this.builds.publishPreview(tenantId, siteId, dist);
......
......@@ -4,11 +4,16 @@ import { readFile, writeFile } from "node:fs/promises";
export async function ensureEnvironmentConfig(projectPath: string): Promise<boolean> {
const configPath = path.join(projectPath, "astro.config.mjs");
const content = await readFile(configPath, "utf8");
if (content.includes("process.env.SITE_BASE_PATH")) return false;
const next = content.replace(
if (content.includes("process.env.SITE_PUBLIC_ORIGIN") && content.includes("process.env.SITE_BASE_PATH")) return false;
let next = content;
if (!next.includes("process.env.SITE_BASE_PATH")) next = next.replace(
/(output:\s*["']static["'],?)/,
'$1\n base: process.env.SITE_BASE_PATH || "/",',
);
if (!next.includes("process.env.SITE_PUBLIC_ORIGIN")) next = next.replace(
/(output:\s*["']static["'],?)/,
'$1\n site: process.env.SITE_PUBLIC_ORIGIN || "https://example.com",',
);
if (next === content) throw new Error("无法迁移站点 Astro 环境路径配置");
await writeFile(configPath, next, "utf8");
return true;
......
......@@ -36,6 +36,10 @@ export class SiteRepository {
return path.join(this.getSiteRoot(tenantId, siteId), "draft");
}
getArticlesPath(tenantId: string, siteId: string): string {
return path.join(this.getSiteRoot(tenantId, siteId), "content", "articles");
}
getMetadataPath(tenantId: string, siteId: string): string {
return path.join(this.getSiteRoot(tenantId, siteId), "metadata", "site.json");
}
......
......@@ -50,13 +50,16 @@ export class SiteVersionService {
await this.sites.update(tenantId, siteId, { status: "building", lastError: undefined });
try {
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(tenantId, siteId));
const dist = await this.builds.build(workspace, taskId, {
basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false,
articlesDirectory: this.sites.getArticlesPath(tenantId, siteId),
});
const published = await this.builds.publishPreview(tenantId, siteId, dist);
await this.previews.start(tenantId, siteId, published, site.previewPort);
await this.sites.update(tenantId, siteId, {
status: "ready", previewCommit: site.draftBaseCommit,
draftUpdatedAt: new Date().toISOString(),
environmentVersion: 3, lastError: undefined,
environmentVersion: 4, lastError: undefined,
});
return { baseCommit: site.draftBaseCommit, previewUrl: site.previewUrl };
} catch (error) {
......@@ -79,7 +82,10 @@ export class SiteVersionService {
await this.sites.update(tenantId, siteId, { status: "building", lastError: undefined });
try {
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(tenantId, siteId));
const dist = await this.builds.build(workspace, taskId, {
basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false,
articlesDirectory: this.sites.getArticlesPath(tenantId, siteId),
});
const draftCommit = await this.git.hasChanges(workspace)
? await this.git.commit(workspace, message)
: await this.git.currentCommit(workspace);
......@@ -89,7 +95,7 @@ export class SiteVersionService {
await this.sites.update(tenantId, siteId, {
status: "ready", currentCommit: commit, previewCommit: commit,
draftBaseCommit: undefined, draftUpdatedAt: undefined, draftSummary: undefined,
environmentVersion: 3, lastError: undefined,
environmentVersion: 4, lastError: undefined,
});
await this.git.removePersistentWorktree(project, workspace);
return { commit, previewUrl: site.previewUrl };
......@@ -124,13 +130,16 @@ export class SiteVersionService {
try {
await this.git.createWorktree(project, workspace, targetCommit);
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(tenantId, siteId));
const dist = await this.builds.build(workspace, taskId, {
basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false,
articlesDirectory: this.sites.getArticlesPath(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.update(tenantId, siteId, {
status: "ready", currentCommit: commit, previewCommit: commit,
environmentVersion: 3, lastError: undefined,
environmentVersion: 4, lastError: undefined,
});
return { commit, previewUrl: site.previewUrl };
} catch (error) {
......@@ -152,7 +161,10 @@ export class SiteVersionService {
await this.git.assertCommit(project, commit);
await this.git.createWorktree(project, workspace, commit);
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicProductionUrl(tenantId, siteId));
const dist = await this.builds.build(workspace, taskId, {
basePath: getPublicProductionUrl(tenantId, siteId), indexable: true,
articlesDirectory: this.sites.getArticlesPath(tenantId, siteId),
});
await this.builds.publishProduction(tenantId, siteId, dist);
const publishedAt = new Date().toISOString();
const productionUrl = getPublicProductionUrl(tenantId, siteId);
......@@ -184,12 +196,15 @@ export class SiteVersionService {
try {
await this.git.createWorktree(project, workspace, commit);
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(site.tenantId, site.siteId));
const dist = await this.builds.build(workspace, taskId, {
basePath: getPublicPreviewUrl(site.tenantId, site.siteId), indexable: false,
articlesDirectory: this.sites.getArticlesPath(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.update(site.tenantId, site.siteId, {
...metadata,
status: "ready", previewCommit: commit, environmentVersion: 3, lastError: undefined,
status: "ready", previewCommit: commit, environmentVersion: 4, lastError: undefined,
});
} catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
......
......@@ -21,4 +21,6 @@
- `src/**/*.md`
- `public/images/**`
平台能力内核 `src/_platform/**`、内容协议 `src/content.config.ts` 和外置文章映射 `src/content/articles/**` 由 WebAgent 维护,Agent 不得修改。页面、布局、组件和样式仍可自由调整。
禁止路径包括 `.git``node_modules``dist``.astro`、依赖清单和 Astro 配置。单次最多 5 个操作,单文件不超过 256 KiB;不允许路径穿越、绝对路径、符号链接写入或模型生成 Shell。
......@@ -8,6 +8,8 @@ WebAgent-sites/
└── site_xxxxxx/
├── project/ # 独立 Astro 项目,也是独立 Git 仓库
├── draft/ # 持久化工作草稿 Git Worktree,不进入正式版本历史
├── content/
│ └── articles/ # 平台文章源文件,不属于 Git 仓库
└── metadata/
├── site.json # WebAgent 管理信息,不交给 Agent 修改
└── domains.json # 当前租户站点的域名绑定
......@@ -47,3 +49,4 @@ WebAgent/.runtime/
7. 每个站点拥有强随机且稳定的 `siteId` 和预览端口;站点名称不参与路径计算。
8. 自定义域名必须通过 TXT 所有权验证后才允许生成路由和申请证书。
9. 自定义域名构建或 DNS 故障不能覆盖、阻塞或改变 `/sites/tenant_xxx/site_xxx/` 生产环境。
10. 文章内容保存在站点外部 `content/articles/`,所有构建只读注入;Git 保存、恢复和草稿操作不得修改或回滚文章。
......@@ -44,6 +44,9 @@ importers:
fastify:
specifier: ^5.3.2
version: 5.10.0
yaml:
specifier: ^2.9.0
version: 2.9.0
zod:
specifier: ^3.24.2
version: 3.24.2
......
......@@ -196,6 +196,49 @@ export interface DraftPreviewResult {
baseCommit: string;
}
export type ArticleStatus = "draft" | "published";
export interface ArticleSeoInput {
title?: string;
description?: string;
ogDescription?: string;
ogImage?: string;
noindex: boolean;
}
export interface ArticleInput {
slug: string;
title: string;
summary: string;
body: string;
status: ArticleStatus;
author?: string;
cover?: string;
tags: string[];
publishedAt?: string;
updatedAt?: string;
seo: ArticleSeoInput;
}
export interface ArticleSummary extends Omit<ArticleInput, "body"> {
wordCount: number;
}
export interface ArticleDocument extends ArticleInput {
wordCount: number;
}
export interface ArticleListResult {
supported: boolean;
articles: ArticleSummary[];
}
export interface ArticleMutationResult {
article?: ArticleDocument;
deletedSlug?: string;
previewUrl: string;
}
export type DomainOwnershipStatus = "pending" | "verified" | "failed";
export type DomainDnsStatus = "pending" | "valid" | "invalid";
export type DomainDeploymentStatus = "pending" | "deploying" | "active" | "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