Commit c438125b authored by tao355667's avatar tao355667

refactor: render CMS content with SSR

parent 869d9a03
Pipeline #469 canceled with stage
......@@ -2,6 +2,7 @@ node_modules/
dist/
.astro/
.codex-tmp/
.agents/
.runtime/
.env
!.env.example
......
# 智引未来洞察内容后台
项目采用与官网同端口的 Astro 内容后台:Markdown 文件负责内容存储,Astro 同时提供管理 API 和静态页面生成。文章列表、分类页和详情页均预渲染为 HTML,便于 SEO 与 GEO 发现
项目采用与官网同端口的 Astro 内容后台:Markdown 文件负责内容存储,Astro 提供管理 API 和 Node SSR 页面。文章列表、分类页、详情页及首页文章区均在请求时读取 CMS 数据并渲染,发布内容后无需静态重建即可生效
## 启动
......@@ -43,12 +43,12 @@ CMS_BUILD_LOCK=/root/zhiyinweilai/shared/site-build.lock
## 内容目录与版本
- `src/content/articles/`:工作稿,包括新草稿和已发布文章的未发布修改
- `src/content/published/`:官网构建使用的线上稿
- `src/content/categories.json`:后台维护的分类
- `src/data/content/articles/`:本地初始化用工作稿种子
- `src/data/content/published/`:本地初始化用线上稿种子
- `src/data/content/categories.json`:本地初始化用分类种子
- `public/uploads/`:本地开发的上传图片
配置 `CMS_DATA_DIR` 后,上述用户数据改存到该持久化目录。保存草稿不会改动线上稿;点击发布后,编辑稿才覆盖线上稿并触发静态构建。下架会移除线上稿但保留编辑稿;删除同时移除两版,构建失败时自动恢复
配置 `CMS_DATA_DIR` 后,上述用户数据改存到该持久化目录。保存草稿不会改动线上稿;点击发布后,编辑稿才覆盖线上稿,SSR 请求会直接读取最新内容。下架会移除线上稿但保留编辑稿;删除同时移除两版
## 管理 API
......@@ -63,12 +63,12 @@ CMS_BUILD_LOCK=/root/zhiyinweilai/shared/site-build.lock
| 文章 | `GET/POST /api/cms/articles` | 列表与新建草稿 |
| 文章 | `GET/PUT/DELETE /api/cms/articles/:slug` | 读取、保存、删除 |
| 草稿 | `DELETE /api/cms/articles/:slug/draft` | 放弃未发布修改 |
| 发布 | `POST /api/cms/articles/:slug/publish` | 发布并构建 |
| 下架 | `POST /api/cms/articles/:slug/unpublish` | 下架并构建 |
| 发布 | `POST /api/cms/articles/:slug/publish` | 发布并立即生效 |
| 下架 | `POST /api/cms/articles/:slug/unpublish` | 下架并立即生效 |
| 工具 | `POST /api/cms/preview` | Markdown 预览 |
| 上传 | `POST /api/cms/uploads` | 上传文章图片 |
| 导入 | `POST /api/cms/imports/word` | 导入 `.docx` 草稿与内嵌图片 |
| 构建 | `GET /api/cms/build` | 查询静态构建状态 |
| 构建 | `GET /api/cms/build` | SSR 模式兼容状态接口 |
浏览器后台通过会话 Cookie 鉴权;外部程序可使用 `Authorization: Bearer <CMS_API_KEY>`
......@@ -91,9 +91,9 @@ Content-Type: application/json
}
```
## 构建一致性
## 内容一致性
发布、下架、删除文章或修改已使用分类时,后台先获取共享构建锁。若代码部署或其他构建已持锁,本次操作返回冲突,不进入等待队列。后台在临时目录执行 Astro 构建,成功后原子替换 `dist/`;失败则恢复内容并保留旧站点
文章写入、发布、下架、删除和分类修改仍使用共享锁,与代码部署互斥;操作只更新持久化 Markdown 和分类文件,不触发静态页面编译。下一次请求会从 `CMS_DATA_DIR` 读取最新内容
生产数据必须位于:
......
# 智引未来企业官网
西安智引未来人工智能科技有限公司官网。项目基于 Astro 5 + Node standalone adapter,包含品牌官网、GEO/SMO 服务页、响应式适配、搜索增长洞察 CMS、SEO/GEO 基础、原子构建与 GitLab/Nginx 部署配置。
西安智引未来人工智能科技有限公司官网。项目基于 Astro 5 + Node standalone adapter 的 SSR 模式,包含品牌官网、GEO/SMO 服务页、响应式适配、搜索增长洞察 CMS、SEO/GEO 基础、原子构建与 GitLab/Nginx 部署配置。
## 本地运行
......@@ -11,6 +11,7 @@ npm run dev
- 官网开发地址:`http://localhost:4321/`
- 内容后台:`http://localhost:4321/admin/`
- 文章列表、分类页、详情页和首页文章区均在请求时从 CMS 数据目录读取并渲染。
生产构建与本地服务:
......@@ -32,7 +33,7 @@ npm start # 启动 standalone 服务
## 内容管理
洞察文章使用 Markdown 存储,并由同端口 `/admin/` 后台管理。工作稿位于 `src/content/articles/`,线上稿位于 `src/content/published/`;生产环境通过 `CMS_DATA_DIR` 将内容持久化到仓库外。发布、下架、删除和分类修改会触发带共享锁的静态页面重建
洞察文章使用 Markdown 存储,并由同端口 `/admin/` 后台管理。仓库中的 `src/data/content/` 仅用于本地初始化种子;生产环境通过 `CMS_DATA_DIR` 将内容持久化到仓库外。文章页面采用 SSR,发布、下架、删除后无需重新编译静态页面即可生效
后台支持:
......@@ -48,7 +49,7 @@ npm start # 启动 standalone 服务
- 页面级标题、描述、关键词、canonical、Open Graph 和 Twitter Card
- Organization、WebSite、WebPage、Service、FAQ、Article 和 Breadcrumb 结构化数据
- sitemap、robots.txt、语义化页面、静态文章与分类分页
- sitemap、robots.txt、语义化页面、SSR 文章与分类分页
- `/llms.txt``/llms-full.txt` 提供结构化站点摘要与完整内容入口
- Astro 图片优化、移动端适配、减少动态效果偏好与原生懒加载
......
......@@ -18,6 +18,7 @@ export default defineConfig({
},
})],
adapter: node({ mode: "standalone" }),
output: "server",
...(customOutDir ? { outDir: path.resolve(customOutDir) } : {}),
build: { format: "directory" },
trailingSlash: "ignore",
......
......@@ -684,7 +684,7 @@ async function triggerBuild(endpoint, { action = "发布", method = "POST", body
show("#build-modal");
hide("#close-modal");
$("#build-title").textContent = `正在${action}…`;
$("#build-log").textContent = "系统正在更新官网,请稍候。";
$("#build-log").textContent = "系统正在更新内容,请稍候。";
try {
await api(endpoint, { method, body });
} catch (error) {
......
......@@ -3,4 +3,4 @@ Allow: /
Disallow: /admin/
Disallow: /api/
Sitemap: https://www.zhiyintec.com/sitemap-index.xml
Sitemap: https://www.zhiyintec.com/sitemap.xml
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const articleSchema = z.object({
title: z.string(),
slug: z.string(),
date: z.coerce.date(),
updated: z.coerce.date(),
category: z.string(),
author: z.string(),
excerpt: z.string(),
status: z.enum(["draft", "published"]),
});
const articles = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/articles" }),
schema: articleSchema,
});
const published = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/published" }),
schema: articleSchema,
});
export const collections = { articles, published };
......@@ -6,11 +6,11 @@ import { marked } from "marked";
const ROOT = process.cwd();
const DATA_ROOT = process.env.CMS_DATA_DIR ? path.resolve(process.env.CMS_DATA_DIR) : null;
const SOURCE_ARTICLES_DIR = path.join(ROOT, "src", "content", "articles");
const SOURCE_PUBLISHED_DIR = path.join(ROOT, "src", "content", "published");
const SOURCE_ARTICLES_DIR = path.join(ROOT, "src", "data", "content", "articles");
const SOURCE_PUBLISHED_DIR = path.join(ROOT, "src", "data", "content", "published");
export const ARTICLES_DIR = DATA_ROOT ? path.join(DATA_ROOT, "articles") : path.join(ROOT, "src", "content", "articles");
export const PUBLISHED_DIR = DATA_ROOT ? path.join(DATA_ROOT, "published") : path.join(ROOT, "src", "content", "published");
export const CATEGORIES_FILE = DATA_ROOT ? path.join(DATA_ROOT, "categories.json") : path.join(ROOT, "src", "content", "categories.json");
export const CATEGORIES_FILE = DATA_ROOT ? path.join(DATA_ROOT, "categories.json") : path.join(ROOT, "src", "data", "content", "categories.json");
export const UPLOADS_DIR = DATA_ROOT ? path.join(DATA_ROOT, "uploads") : path.join(ROOT, "public", "uploads");
const DEFAULT_CATEGORIES = ["GEO洞察", "SMO增长", "AI搜索", "品牌内容"];
......
......@@ -3,7 +3,6 @@ import { marked } from "marked";
import {
StoreError,
addCategory,
createContentSnapshot,
deleteArticle,
discardDraft,
generateSlug,
......@@ -23,7 +22,7 @@ import {
} from "./article-store";
import { convertWordToMarkdown, decodeWordDataUrl } from "./word-import";
import { currentSessionVersion, savePassword, verifyPassword } from "./cms-auth";
import { buildSiteAtomic, tryAcquireSiteBuildLock, withSiteBuildLock } from "../../scripts/site-build.mjs";
import { tryAcquireSiteBuildLock, withSiteBuildLock } from "../../scripts/site-build.mjs";
const SECRET = process.env.CMS_SECRET || crypto.randomBytes(32).toString("hex");
const API_KEY = process.env.CMS_API_KEY || "";
......@@ -33,15 +32,6 @@ const MAX_LOGIN_FAILURES = 5;
const LOGIN_FAILURE_WINDOW = 10 * 60 * 1000;
const LOGIN_BLOCK_DURATION = 10 * 60 * 1000;
const loginAttempts = new Map<string, { count: number; first: number; blockedUntil: number }>();
type BuildState = {
status: "idle" | "building" | "success" | "error";
ok: boolean | null;
log: string;
startedAt: string | null;
finishedAt: string | null;
};
let buildState: BuildState = { status: "idle", ok: null, log: "", startedAt: null, finishedAt: null };
const json = (data: unknown, status = 200, headers?: HeadersInit) => Response.json(data, { status, headers });
const safeEqual = (leftValue: unknown, rightValue: unknown): boolean => {
......@@ -154,49 +144,17 @@ function requireSlug(value: string | undefined): string {
return slug;
}
async function startContentBuild(change: () => Promise<void>): Promise<boolean> {
if (buildState.status === "building") return false;
async function applyContentChange(change: () => Promise<void>): Promise<Response> {
const releaseLock = await tryAcquireSiteBuildLock();
if (!releaseLock) return false;
buildState = { status: "building", ok: null, log: "", startedAt: new Date().toISOString(), finishedAt: null };
void (async () => {
const snapshot = await createContentSnapshot();
if (!releaseLock) {
return json({ error: "网站正在部署,本次操作未受理,请稍后重试" }, 409, { "Retry-After": "5" });
}
try {
await change();
const log = await buildSiteAtomic();
buildState = { status: "success", ok: true, log, startedAt: buildState.startedAt, finishedAt: new Date().toISOString() };
} catch (error) {
let rollbackMessage = "";
try { await snapshot.restore(); } catch (rollbackError) {
rollbackMessage = `\n内容回滚失败:${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`;
}
const message = error instanceof Error ? error.message : String(error);
const log = typeof error === "object" && error && "log" in error ? String(error.log || "") : "";
buildState = {
status: "error",
ok: false,
log: `${log}${log ? "\n" : ""}${message}${rollbackMessage}`,
startedAt: buildState.startedAt,
finishedAt: new Date().toISOString(),
};
}
})().finally(releaseLock).catch((error) => {
buildState = {
status: "error",
ok: false,
log: error instanceof Error ? error.message : String(error),
startedAt: buildState.startedAt,
finishedAt: new Date().toISOString(),
};
});
return true;
}
async function startOrConflict(change: () => Promise<void>): Promise<Response> {
if (!await startContentBuild(change)) {
return json({ error: "网站正在部署或生成静态页面,本次操作未受理,请稍后重试" }, 409, { "Retry-After": "5" });
return json({ ok: true, status: "success" });
} finally {
await releaseLock();
}
return json({ ok: true, status: "building" });
}
export async function handleCmsApi(request: Request, routeValue: string, clientAddress = "unknown"): Promise<Response> {
......@@ -260,18 +218,12 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
if (route === "categories") {
if (method === "GET") return json({ categories: await getCategoryNames(), stats: await getCategoryStats() });
const body = await bodyOf(request);
if (method === "POST") {
return startOrConflict(async () => { await addCategory(body.name); });
}
if (method === "PATCH") {
return startOrConflict(async () => { await renameCategory(body.current, body.name); });
}
if (method === "DELETE") {
return startOrConflict(async () => { await removeCategory(body.name, body.replacement); });
}
if (method === "POST") return applyContentChange(async () => { await addCategory(body.name); });
if (method === "PATCH") return applyContentChange(async () => { await renameCategory(body.current, body.name); });
if (method === "DELETE") return applyContentChange(async () => { await removeCategory(body.name, body.replacement); });
}
if (route === "build" && method === "GET") return json(buildState);
if (route === "build" && method === "GET") return json({ status: "idle", ok: true, log: "SSR 内容按请求读取,无需重新构建", startedAt: null, finishedAt: null });
if (route === "articles") {
if (method === "GET") return json(await listWorkingArticles());
......@@ -326,12 +278,8 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
if (parts.length === 3 && parts[2] === "draft" && method === "DELETE") {
return json({ ok: true, ...await withSiteBuildLock(() => discardDraft(slug)) });
}
if (parts.length === 3 && parts[2] === "publish" && method === "POST") {
return startOrConflict(async () => { await publishArticle(slug); });
}
if (parts.length === 3 && parts[2] === "unpublish" && method === "POST") {
return startOrConflict(async () => { await unpublishArticle(slug); });
}
if (parts.length === 3 && parts[2] === "publish" && method === "POST") return applyContentChange(async () => { await publishArticle(slug); });
if (parts.length === 3 && parts[2] === "unpublish" && method === "POST") return applyContentChange(async () => { await unpublishArticle(slug); });
if (parts.length === 2 && method === "GET") {
const article = await readWorkingArticle(slug);
return json({ ...article, publishStatus: await getPublishStatus(article) });
......@@ -343,9 +291,7 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
const article = await withSiteBuildLock(() => writeArticle(slug, body));
return json({ ok: true, slug, publishStatus: await getPublishStatus(article) });
}
if (parts.length === 2 && method === "DELETE") {
return startOrConflict(async () => { await deleteArticle(slug); });
}
if (parts.length === 2 && method === "DELETE") return applyContentChange(async () => { await deleteArticle(slug); });
}
if (route === "preview" && method === "POST") {
......
---
import Base from "../../layouts/Base.astro"; import Header from "../../components/Header.astro"; import Footer from "../../components/Footer.astro"; import { site } from "../../data/site"; import { getPublishedArticle, getPublishedArticles } from "../../lib/article-store"; import { fmtDate } from "../../lib/articles";
type ArticleDetail = NonNullable<Awaited<ReturnType<typeof getPublishedArticle>>>;
export async function getStaticPaths() { const articles = await getPublishedArticles(); return Promise.all(articles.map(async (article) => ({ params: { slug: article.id }, props: { entry: await getPublishedArticle(article.id) } }))); }
const { entry } = Astro.props as { entry: ArticleDetail }; const article = entry.data; const date = fmtDate(article.date); const title = `${article.title}|洞察中心|${site.brand.name}`; const pageUrl = new URL(`/articles/${entry.id}/`, Astro.site).href;
import Base from "../../layouts/Base.astro"; import Header from "../../components/Header.astro"; import Footer from "../../components/Footer.astro"; import { site } from "../../data/site"; import { getPublishedArticle } from "../../lib/article-store"; import { fmtDate } from "../../lib/articles";
export const prerender = false;
const entry = await getPublishedArticle(Astro.params.slug || "");
if (!entry) return Astro.rewrite("/404");
const article = entry.data; const date = fmtDate(article.date); const title = `${article.title}|洞察中心|${site.brand.name}`; const pageUrl = new URL(`/articles/${entry.id}/`, Astro.site).href;
const modified = fmtDate(article.updated || article.date);
const keywords = [article.category, article.title, "GEO", "SMO", site.brand.name];
const jsonLd = [{ "@context": "https://schema.org", "@type": "Article", headline: article.title, description: article.excerpt, datePublished: date, dateModified: modified, articleSection: article.category, inLanguage: "zh-CN", author: { "@type": "Organization", name: article.author }, publisher: { "@id": new URL("/#organization", Astro.site).href }, mainEntityOfPage: { "@type": "WebPage", "@id": `${pageUrl}#webpage` }, url: pageUrl }, { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [{ "@type": "ListItem", position: 1, name: "首页", item: Astro.site?.href }, { "@type": "ListItem", position: 2, name: "洞察中心", item: new URL("/articles/", Astro.site).href }, { "@type": "ListItem", position: 3, name: article.title, item: pageUrl }] }];
......
......@@ -2,6 +2,8 @@
import ArticlesView from "../../components/ArticlesView.astro";
import { getPublishedArticles, getCategoryNames, getCategories, pageSlice, lastPageOf } from "../../lib/articles";
export const prerender = false;
const all = await getPublishedArticles();
const categories = getCategories(all, await getCategoryNames());
const items = pageSlice(all, 1);
......
......@@ -2,14 +2,10 @@
import ArticlesView from "../../../components/ArticlesView.astro";
import { getPublishedArticles, getCategoryNames, getCategories, pageSlice, lastPageOf } from "../../../lib/articles";
export async function getStaticPaths() {
const articles = await getPublishedArticles();
return Array.from({ length: Math.max(0, lastPageOf(articles.length) - 1) }, (_, index) => ({
params: { page: String(index + 2) },
}));
}
export const prerender = false;
const all = await getPublishedArticles();
const page = Number(Astro.params.page);
if (!Number.isInteger(page) || page < 2 || page > lastPageOf(all.length)) return Astro.rewrite("/404");
---
<ArticlesView items={pageSlice(all, page)} categories={getCategories(all, await getCategoryNames())} totalCount={all.length} activeSlug={null} activeName={null} currentPage={page} lastPage={lastPageOf(all.length)} basePath="/articles" />
---
import ArticlesView from "../../../../components/ArticlesView.astro";
import { categoryToSlug, getPublishedArticles, getCategoryNames, getCategories, slugToCategory, pageSlice, lastPageOf } from "../../../../lib/articles";
import { getPublishedArticles, getCategoryNames, getCategories, slugToCategory, pageSlice, lastPageOf } from "../../../../lib/articles";
export async function getStaticPaths() {
return (await getCategoryNames()).map((categoryName) => ({
params: { slug: categoryToSlug(categoryName) },
props: { categoryName },
}));
}
export const prerender = false;
const all = await getPublishedArticles();
const categories = getCategories(all, await getCategoryNames());
const categoryName = String(Astro.props.categoryName || slugToCategory(Astro.params.slug!, categories.map((item) => item.name)) || "");
const categoryName = slugToCategory(Astro.params.slug!, categories.map((item) => item.name));
if (!categoryName) return Astro.rewrite("/404");
const filtered = all.filter((item) => item.data.category === categoryName);
---
<ArticlesView items={pageSlice(filtered, 1)} categories={categories} totalCount={all.length} activeSlug={Astro.params.slug!} activeName={categoryName} currentPage={1} lastPage={lastPageOf(filtered.length)} basePath={`/articles/topic/${Astro.params.slug}`} />
......@@ -2,22 +2,14 @@
import ArticlesView from "../../../../../components/ArticlesView.astro";
import { getPublishedArticles, getCategoryNames, getCategories, slugToCategory, pageSlice, lastPageOf } from "../../../../../lib/articles";
export async function getStaticPaths() {
const articles = await getPublishedArticles();
const categories = getCategories(articles, await getCategoryNames());
const paths: Array<{ params: { slug: string; page: string }; props: { categoryName: string } }> = [];
for (const category of categories) {
for (let page = 2; page <= lastPageOf(category.count); page += 1) {
paths.push({ params: { slug: category.slug, page: String(page) }, props: { categoryName: category.name } });
}
}
return paths;
}
export const prerender = false;
const all = await getPublishedArticles();
const categories = getCategories(all, await getCategoryNames());
const categoryName = String(Astro.props.categoryName || slugToCategory(Astro.params.slug!, categories.map((item) => item.name)) || "");
const categoryName = slugToCategory(Astro.params.slug!, categories.map((item) => item.name));
if (!categoryName) return Astro.rewrite("/404");
const filtered = all.filter((item) => item.data.category === categoryName);
const page = Number(Astro.params.page);
if (!Number.isInteger(page) || page < 2 || page > lastPageOf(filtered.length)) return Astro.rewrite("/404");
---
<ArticlesView items={pageSlice(filtered, page)} categories={categories} totalCount={all.length} activeSlug={Astro.params.slug!} activeName={categoryName} currentPage={page} lastPage={lastPageOf(filtered.length)} basePath={`/articles/topic/${Astro.params.slug}`} />
......@@ -8,6 +8,8 @@ import CTA from "../components/CTA.astro";
import { site } from "../data/site";
import { fmtDate, getPublishedArticles } from "../lib/articles";
export const prerender = false;
const latest = (await getPublishedArticles()).slice(0, 3);
const title = site.seo.title;
const jsonLd = {
......
......@@ -2,7 +2,7 @@ import type { APIRoute } from "astro";
import { site } from "../data/site";
import { getPublishedArticles } from "../lib/articles";
export const prerender = true;
export const prerender = false;
export const GET: APIRoute = async ({ site: configuredSite }) => {
const articles = await getPublishedArticles();
......
......@@ -2,7 +2,7 @@ import type { APIRoute } from "astro";
import { site } from "../data/site";
import { getPublishedArticles } from "../lib/articles";
export const prerender = true;
export const prerender = false;
export const GET: APIRoute = async ({ site: configuredSite }) => {
const articles = await getPublishedArticles();
......
......@@ -5,11 +5,8 @@ import Footer from "../../components/Footer.astro";
import CTA from "../../components/CTA.astro";
import { site } from "../../data/site";
export function getStaticPaths() {
return site.services.map((service) => ({ params: { slug: service.slug }, props: { service } }));
}
type Service = (typeof site.services)[number];
const { service } = Astro.props as { service: Service };
const service = site.services.find((item) => item.slug === Astro.params.slug);
if (!service) return Astro.rewrite("/404");
const pageUrl = new URL(`/services/${service.slug}/`, Astro.site).href;
const jsonLd = [{
"@context": "https://schema.org", "@type": "Service", "@id": `${pageUrl}#service`, name: service.name,
......
import type { APIRoute } from "astro";
import { site } from "../data/site";
import { categoryToSlug, getCategories, getCategoryNames, getPublishedArticles, lastPageOf } from "../lib/articles";
export const prerender = false;
const escapeXml = (value: string) => value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
export const GET: APIRoute = async ({ site: configuredSite }) => {
const origin = configuredSite ?? new URL("https://www.zhiyintec.com");
const articles = await getPublishedArticles();
const categories = getCategories(articles, await getCategoryNames());
const paths = new Set<string>(["/", "/about/", "/articles/", "/contact/", "/faq/", "/join/", "/methodology/", "/services/"]);
for (const service of site.services) paths.add(`/services/${service.slug}/`);
for (const article of articles) paths.add(`/articles/${article.id}/`);
if (articles.length > 9) for (let page = 2; page <= lastPageOf(articles.length); page += 1) paths.add(`/articles/page/${page}/`);
for (const category of categories) {
paths.add(`/articles/topic/${categoryToSlug(category.name)}/`);
for (let page = 2; page <= lastPageOf(category.count); page += 1) paths.add(`/articles/topic/${categoryToSlug(category.name)}/page/${page}/`);
}
const body = [...paths].map((pathname) => ` <url><loc>${escapeXml(new URL(pathname, origin).href)}</loc></url>`).join("\n");
return new Response(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>`, {
headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=3600" },
});
};
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