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 });
}
});
This diff is collapsed.
......@@ -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