feat: render CMS SEO metadata and refresh published page caches
This commit is contained in:
@@ -20,6 +20,28 @@ You can start editing the page by modifying `app/page.tsx`. The page auto-update
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## CMS SEO and Publish Cache
|
||||
|
||||
CMS routes read custom SEO title/description (falling back to standard Wagtail
|
||||
SEO fields), canonical URL, social image, and JSON-LD. Explicit CMS SEO titles
|
||||
are output without appending the site title template. Empty JSON-LD is omitted,
|
||||
and script boundaries are escaped before output.
|
||||
|
||||
Deploy the backend API changes first, then build and restart the frontend.
|
||||
No database migrations are required. Set `NEXT_PUBLIC_API_URL` to the public
|
||||
CMS origin before building the frontend.
|
||||
|
||||
Set backend `FRONTEND_REVALIDATE_URL` to the frontend `/api/revalidate` endpoint.
|
||||
Both services must use the same `REVALIDATE_SECRET`. All Wagtail page requests
|
||||
share the `wagtail-pages` tag. Publishing expires this tag immediately, and the
|
||||
next request fetches fresh data. This refreshes all page caches, including lists.
|
||||
Snippet caches retain separate tags. Without a working webhook, the existing
|
||||
60-second time-based revalidation remains the fallback.
|
||||
|
||||
Frontend tests: `node --test tests/seo.test.cjs`.
|
||||
Backend tests (from the backend root):
|
||||
`python -m pytest apps/core/tests.py -k "page_api_exposes_seo_fields or seo_image_serializer or notify_frontend_revalidate"`.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
@@ -26,8 +26,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
// Next.js 16 要求传入 cache profile;"max" 表示立即使该标签下的缓存完全失效。
|
||||
revalidateTag(tag, "max");
|
||||
revalidateTag(tag, { expire: 0 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ revalidated: tags });
|
||||
|
||||
@@ -4,6 +4,8 @@ import { getBlogBySlug } from "@/services/blog.service";
|
||||
import { getPreviewOrFallback } from "@/services/preview.service";
|
||||
import { BlockRenderer } from "@/blocks/BlockRenderer";
|
||||
import type { BlogPageDetail } from "@/types/wagtail";
|
||||
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
|
||||
import PageSEO from "@/components/layout/PageSEO";
|
||||
|
||||
interface BlogDetailPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
@@ -13,8 +15,7 @@ export async function generateMetadata({
|
||||
params,
|
||||
}: BlogDetailPageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await getBlogBySlug(slug).catch(() => null);
|
||||
return { title: post?.title ?? "文章未找到" };
|
||||
return createPageMetadata(await getPageSEO("blog.BlogPage", slug).catch(() => null), "文章未找到");
|
||||
}
|
||||
|
||||
export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
|
||||
@@ -31,6 +32,7 @@ export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
|
||||
|
||||
return (
|
||||
<article className="mx-auto max-w-3xl px-6 py-24">
|
||||
<PageSEO type="blog.BlogPage" slug={slug} />
|
||||
<h1 className="mb-4 text-4xl font-bold text-slate-900">{post.title}</h1>
|
||||
{post.published_at && (
|
||||
<p className="mb-12 text-sm text-slate-500">{post.published_at}</p>
|
||||
|
||||
+6
-3
@@ -1,16 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { getBlogList } from "@/services/blog.service";
|
||||
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
|
||||
import PageSEO from "@/components/layout/PageSEO";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "技术博客",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return createPageMetadata(await getPageSEO("blog.BlogIndexPage", "blog").catch(() => null), "技术博客");
|
||||
}
|
||||
|
||||
export default async function BlogListPage() {
|
||||
const posts = await getBlogList().catch(() => []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-24">
|
||||
<PageSEO type="blog.BlogIndexPage" slug="blog" />
|
||||
<h1 className="mb-12 text-4xl font-bold text-slate-900">技术博客</h1>
|
||||
<ul className="space-y-6">
|
||||
{posts.map((post) => (
|
||||
|
||||
@@ -4,6 +4,8 @@ import { getCaseStudyBySlug } from "@/services/case.service";
|
||||
import { getPreviewOrFallback } from "@/services/preview.service";
|
||||
import { BlockRenderer } from "@/blocks/BlockRenderer";
|
||||
import type { CaseStudyPageDetail } from "@/types/wagtail";
|
||||
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
|
||||
import PageSEO from "@/components/layout/PageSEO";
|
||||
|
||||
interface CaseStudyDetailPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
@@ -13,8 +15,7 @@ export async function generateMetadata({
|
||||
params,
|
||||
}: CaseStudyDetailPageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const item = await getCaseStudyBySlug(slug).catch(() => null);
|
||||
return { title: item?.title ?? "案例未找到" };
|
||||
return createPageMetadata(await getPageSEO("cases.CaseStudyPage", slug).catch(() => null), "案例未找到");
|
||||
}
|
||||
|
||||
export default async function CaseStudyDetailPage({
|
||||
@@ -33,6 +34,7 @@ export default async function CaseStudyDetailPage({
|
||||
|
||||
return (
|
||||
<article className="mx-auto max-w-3xl px-6 py-24">
|
||||
<PageSEO type="cases.CaseStudyPage" slug={slug} />
|
||||
<h1 className="mb-2 text-4xl font-bold text-slate-900">{item.title}</h1>
|
||||
<p className="mb-12 text-sm text-slate-500">
|
||||
{item.client_name}
|
||||
|
||||
+6
-3
@@ -1,16 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { getCaseStudyList } from "@/services/case.service";
|
||||
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
|
||||
import PageSEO from "@/components/layout/PageSEO";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "客户案例",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return createPageMetadata(await getPageSEO("cases.CaseStudyIndexPage", "cases").catch(() => null), "客户案例");
|
||||
}
|
||||
|
||||
export default async function CaseStudyListPage() {
|
||||
const caseStudies = await getCaseStudyList().catch(() => []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-24">
|
||||
<PageSEO type="cases.CaseStudyIndexPage" slug="cases" />
|
||||
<h1 className="mb-12 text-4xl font-bold text-slate-900">客户案例</h1>
|
||||
<ul className="space-y-6">
|
||||
{caseStudies.map((item) => (
|
||||
|
||||
@@ -3,13 +3,11 @@ import { getContactPage } from "@/services/page.service";
|
||||
import { getPreviewOrFallback } from "@/services/preview.service";
|
||||
import { BlockRenderer } from "@/blocks/BlockRenderer";
|
||||
import type { ContactPageDetail } from "@/types/wagtail";
|
||||
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
|
||||
import PageSEO from "@/components/layout/PageSEO";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const page = await getContactPage().catch(() => null);
|
||||
return {
|
||||
title: page?.seo_title_override || page?.title || "联系我们",
|
||||
description: page?.seo_description_override,
|
||||
};
|
||||
return createPageMetadata(await getPageSEO("core.ContactPage", "contact").catch(() => null), "联系我们");
|
||||
}
|
||||
|
||||
export default async function ContactPage() {
|
||||
@@ -36,5 +34,5 @@ export default async function ContactPage() {
|
||||
);
|
||||
}
|
||||
|
||||
return <BlockRenderer blocks={contactPage.body} />;
|
||||
return <><PageSEO type="core.ContactPage" slug="contact" /><BlockRenderer blocks={contactPage.body} /></>;
|
||||
}
|
||||
|
||||
+6
-4
@@ -3,10 +3,12 @@ import { getHomePage } from "@/services/page.service";
|
||||
import { getPreviewOrFallback } from "@/services/preview.service";
|
||||
import { BlockRenderer } from "@/blocks/BlockRenderer";
|
||||
import type { HomePageDetail } from "@/types/wagtail";
|
||||
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
|
||||
import PageSEO from "@/components/layout/PageSEO";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "首页",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return createPageMetadata(await getPageSEO("home.HomePage").catch(() => null), "首页");
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
const homePage = await getPreviewOrFallback<HomePageDetail>(
|
||||
@@ -33,5 +35,5 @@ export default async function Home() {
|
||||
);
|
||||
}
|
||||
|
||||
return <BlockRenderer blocks={homePage.body} />;
|
||||
return <><PageSEO type="home.HomePage" /><BlockRenderer blocks={homePage.body} /></>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { getSimpleContentPageBySlug } from "@/services/legal.service";
|
||||
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
|
||||
import PageSEO from "@/components/layout/PageSEO";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const page = await getSimpleContentPageBySlug("privacy-policy").catch(() => null);
|
||||
return { title: page?.title ?? "隐私政策" };
|
||||
return createPageMetadata(await getPageSEO("core.SimpleContentPage", "privacy-policy").catch(() => null), "隐私政策");
|
||||
}
|
||||
|
||||
export default async function PrivacyPolicyPage() {
|
||||
@@ -16,6 +17,7 @@ export default async function PrivacyPolicyPage() {
|
||||
|
||||
return (
|
||||
<article className="mx-auto max-w-3xl px-6 py-24">
|
||||
<PageSEO type="core.SimpleContentPage" slug="privacy-policy" />
|
||||
<h1 className="mb-8 text-4xl font-bold text-slate-900">{page.title}</h1>
|
||||
{/* page.body 为 Wagtail RichTextField 渲染出的 HTML,内容仅由后台编辑人员维护,非用户输入,可信任直接渲染 */}
|
||||
<div
|
||||
|
||||
@@ -4,6 +4,8 @@ import { getProductBySlug } from "@/services/product.service";
|
||||
import { getPreviewOrFallback } from "@/services/preview.service";
|
||||
import { BlockRenderer } from "@/blocks/BlockRenderer";
|
||||
import type { ProductPageDetail } from "@/types/wagtail";
|
||||
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
|
||||
import PageSEO from "@/components/layout/PageSEO";
|
||||
|
||||
interface ProductDetailPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
@@ -13,8 +15,7 @@ export async function generateMetadata({
|
||||
params,
|
||||
}: ProductDetailPageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const product = await getProductBySlug(slug).catch(() => null);
|
||||
return { title: product?.title ?? "产品未找到" };
|
||||
return createPageMetadata(await getPageSEO("products.ProductPage", slug).catch(() => null), "产品未找到");
|
||||
}
|
||||
|
||||
export default async function ProductDetailPage({
|
||||
@@ -33,6 +34,7 @@ export default async function ProductDetailPage({
|
||||
|
||||
return (
|
||||
<article className="mx-auto max-w-3xl px-6 py-24">
|
||||
<PageSEO type="products.ProductPage" slug={slug} />
|
||||
<h1 className="mb-4 text-4xl font-bold text-slate-900">
|
||||
{product.title}
|
||||
</h1>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { getProductList } from "@/services/product.service";
|
||||
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
|
||||
import PageSEO from "@/components/layout/PageSEO";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "产品",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return createPageMetadata(await getPageSEO("products.ProductIndexPage", "products").catch(() => null), "产品");
|
||||
}
|
||||
|
||||
export default async function ProductListPage() {
|
||||
const products = await getProductList().catch(() => []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-24">
|
||||
<PageSEO type="products.ProductIndexPage" slug="products" />
|
||||
<h1 className="mb-12 text-4xl font-bold text-slate-900">产品</h1>
|
||||
<ul className="space-y-6">
|
||||
{products.map((product) => (
|
||||
|
||||
@@ -4,6 +4,8 @@ import { getSolutionBySlug } from "@/services/solution.service";
|
||||
import { getPreviewOrFallback } from "@/services/preview.service";
|
||||
import { BlockRenderer } from "@/blocks/BlockRenderer";
|
||||
import type { SolutionPageDetail } from "@/types/wagtail";
|
||||
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
|
||||
import PageSEO from "@/components/layout/PageSEO";
|
||||
|
||||
interface SolutionDetailPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
@@ -13,8 +15,7 @@ export async function generateMetadata({
|
||||
params,
|
||||
}: SolutionDetailPageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const solution = await getSolutionBySlug(slug).catch(() => null);
|
||||
return { title: solution?.title ?? "解决方案未找到" };
|
||||
return createPageMetadata(await getPageSEO("solutions.SolutionPage", slug).catch(() => null), "解决方案未找到");
|
||||
}
|
||||
|
||||
export default async function SolutionDetailPage({
|
||||
@@ -33,6 +34,7 @@ export default async function SolutionDetailPage({
|
||||
|
||||
return (
|
||||
<article className="mx-auto max-w-3xl px-6 py-24">
|
||||
<PageSEO type="solutions.SolutionPage" slug={slug} />
|
||||
<h1 className="mb-2 text-4xl font-bold text-slate-900">
|
||||
{solution.title}
|
||||
</h1>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { getSolutionList } from "@/services/solution.service";
|
||||
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
|
||||
import PageSEO from "@/components/layout/PageSEO";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "解决方案",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return createPageMetadata(await getPageSEO("solutions.SolutionIndexPage", "solutions").catch(() => null), "解决方案");
|
||||
}
|
||||
|
||||
export default async function SolutionListPage() {
|
||||
const solutions = await getSolutionList().catch(() => []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-24">
|
||||
<PageSEO type="solutions.SolutionIndexPage" slug="solutions" />
|
||||
<h1 className="mb-12 text-4xl font-bold text-slate-900">解决方案</h1>
|
||||
<ul className="space-y-6">
|
||||
{solutions.map((solution) => (
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getPageSEO } from "@/services/seo.service";
|
||||
|
||||
export default async function PageSEO({ type, slug }: { type: string; slug?: string }) {
|
||||
const page = await getPageSEO(type, slug).catch(() => null);
|
||||
if (!page?.schema_json || Object.keys(page.schema_json).length === 0) return null;
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(page.schema_json).replace(/</g, "\\u003c"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import nextTs from "eslint-config-next/typescript";
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
{
|
||||
files: ["**/tests/**/*.cjs"],
|
||||
rules: { "@typescript-eslint/no-require-imports": "off" },
|
||||
},
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
|
||||
@@ -27,7 +27,12 @@ export async function fetchAPI<T>(
|
||||
{ revalidate = 60, tags }: FetchOptions = {},
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||
next: { revalidate, tags },
|
||||
next: {
|
||||
revalidate,
|
||||
tags: path.startsWith("/api/v2/pages/")
|
||||
? [...new Set(["wagtail-pages", ...(tags ?? [])])]
|
||||
: tags,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Metadata } from "next";
|
||||
import { fetchAPI } from "./api-client";
|
||||
import type { WagtailListResponse, WagtailPageSummary } from "@/types/wagtail";
|
||||
|
||||
export const SEO_FIELDS = "seo_title,search_description,seo_title_override,seo_description_override,og_image,canonical_url,schema_json";
|
||||
|
||||
export async function getPageSEO(type: string, slug?: string): Promise<WagtailPageSummary | null> {
|
||||
const query = new URLSearchParams({ type, fields: SEO_FIELDS, limit: "1" });
|
||||
if (slug) query.set("slug", slug);
|
||||
const response = await fetchAPI<WagtailListResponse<WagtailPageSummary>>(
|
||||
`/api/v2/pages/?${query}`,
|
||||
);
|
||||
return response.items[0] ?? null;
|
||||
}
|
||||
|
||||
export function createPageMetadata(page: WagtailPageSummary | null, fallback: string): Metadata {
|
||||
const title = page?.seo_title_override || page?.meta.seo_title;
|
||||
const description = page?.seo_description_override || page?.meta.search_description || undefined;
|
||||
const images = page?.og_image?.url
|
||||
? [{ url: page.og_image.url, alt: page.og_image.title }]
|
||||
: undefined;
|
||||
return {
|
||||
title: title ? { absolute: title } : page?.title || fallback,
|
||||
description,
|
||||
alternates: page?.canonical_url ? { canonical: page.canonical_url } : undefined,
|
||||
openGraph: {
|
||||
title: title || page?.title || fallback,
|
||||
description,
|
||||
url: page?.canonical_url || undefined,
|
||||
images,
|
||||
type: "website",
|
||||
},
|
||||
twitter: { card: images ? "summary_large_image" : "summary", title: title || page?.title || fallback, description, images },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const { test } = require("node:test");
|
||||
const ts = require("typescript");
|
||||
|
||||
function loadModule(relativePath, mocks = {}, globals = {}) {
|
||||
const source = fs.readFileSync(path.join(__dirname, "..", relativePath), "utf8");
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
|
||||
}).outputText;
|
||||
const loadedModule = { exports: {} };
|
||||
vm.runInNewContext(compiled, {
|
||||
module: loadedModule, exports: loadedModule.exports, process, URLSearchParams,
|
||||
require: (name) => name in mocks ? mocks[name] : require(name),
|
||||
...globals,
|
||||
});
|
||||
return loadedModule.exports;
|
||||
}
|
||||
|
||||
const page = {
|
||||
title: "Page title",
|
||||
meta: { seo_title: "Standard title", search_description: "Standard description" },
|
||||
seo_title_override: "Custom title | Shiytech",
|
||||
seo_description_override: "Custom description",
|
||||
canonical_url: "https://www.shiytech.cn/products",
|
||||
og_image: { url: "https://cms.shiytech.cn/media/share.png", title: "Share image" },
|
||||
schema_json: { "@type": "CollectionPage" },
|
||||
};
|
||||
|
||||
test("SEO metadata includes absolute title, canonical and social image", () => {
|
||||
const { createPageMetadata } = loadModule("services/seo.service.ts", { "./api-client": {} });
|
||||
const metadata = createPageMetadata(page, "Fallback");
|
||||
assert.equal(metadata.title.absolute, page.seo_title_override);
|
||||
assert.equal(metadata.description, page.seo_description_override);
|
||||
assert.equal(metadata.alternates.canonical, page.canonical_url);
|
||||
assert.equal(metadata.openGraph.images[0].url, page.og_image.url);
|
||||
assert.equal(metadata.twitter.card, "summary_large_image");
|
||||
});
|
||||
|
||||
test("SEO falls back to standard Wagtail fields and handles missing pages", () => {
|
||||
const { createPageMetadata } = loadModule("services/seo.service.ts", { "./api-client": {} });
|
||||
const metadata = createPageMetadata({ ...page, seo_title_override: "", seo_description_override: "", og_image: null }, "Fallback");
|
||||
assert.equal(metadata.title.absolute, page.meta.seo_title);
|
||||
assert.equal(metadata.description, page.meta.search_description);
|
||||
assert.equal(metadata.twitter.card, "summary");
|
||||
assert.equal(createPageMetadata(null, "Fallback").title, "Fallback");
|
||||
});
|
||||
|
||||
test("SEO lookup requests every SEO field and encodes slugs", async () => {
|
||||
let requested;
|
||||
const { getPageSEO, SEO_FIELDS } = loadModule("services/seo.service.ts", {
|
||||
"./api-client": { fetchAPI: async (url) => { requested = url; return { items: [page] }; } },
|
||||
});
|
||||
assert.equal(await getPageSEO("products.ProductPage", "a&b"), page);
|
||||
const query = new URLSearchParams(requested.split("?")[1]);
|
||||
assert.equal(query.get("slug"), "a&b");
|
||||
assert.equal(query.get("fields"), SEO_FIELDS);
|
||||
for (const field of ["seo_title", "search_description", "og_image", "canonical_url", "schema_json"]) {
|
||||
assert.ok(SEO_FIELDS.split(",").includes(field));
|
||||
}
|
||||
});
|
||||
|
||||
test("JSON-LD escapes script boundaries and suppresses empty data", async () => {
|
||||
const schema = { "@type": "WebPage", description: "</script><script>alert(1)</script>" };
|
||||
const { default: PageSEO } = loadModule("components/layout/PageSEO.tsx", {
|
||||
"@/services/seo.service": { getPageSEO: async () => ({ schema_json: schema }) },
|
||||
});
|
||||
const element = await PageSEO({ type: "home.HomePage" });
|
||||
const json = element.props.dangerouslySetInnerHTML.__html;
|
||||
assert.ok(!json.includes("<"));
|
||||
assert.deepEqual(JSON.parse(json), schema);
|
||||
const empty = loadModule("components/layout/PageSEO.tsx", {
|
||||
"@/services/seo.service": { getPageSEO: async () => ({ schema_json: {} }) },
|
||||
});
|
||||
assert.equal(await empty.default({ type: "home.HomePage" }), null);
|
||||
});
|
||||
|
||||
test("page fetches share the publish cache tag without changing snippet tags", async () => {
|
||||
const calls = [];
|
||||
const { fetchAPI } = loadModule("services/api-client.ts", {}, {
|
||||
fetch: async (url, options) => { calls.push(options); return { ok: true, json: async () => ({}) }; },
|
||||
});
|
||||
await fetchAPI("/api/v2/pages/?type=home.HomePage", { tags: ["home-page", "wagtail-pages"] });
|
||||
assert.equal(calls[0].next.tags.join(","), "wagtail-pages,home-page");
|
||||
await fetchAPI("/api/v1/custom/core/site-settings/", { tags: ["site-settings"] });
|
||||
assert.equal(calls[1].next.tags.join(","), "site-settings");
|
||||
});
|
||||
|
||||
test("authenticated webhook expires shared cache immediately and rejects unauthorized requests", async () => {
|
||||
const calls = [];
|
||||
const { POST } = loadModule("app/api/revalidate/route.ts", {
|
||||
"next/cache": { revalidateTag: (tag, profile) => calls.push({ tag, profile }) },
|
||||
}, { process: { env: { REVALIDATE_SECRET: "test-secret" } } });
|
||||
const request = (authorization) => ({
|
||||
headers: { get: () => authorization },
|
||||
json: async () => ({ tags: ["wagtail-pages"] }),
|
||||
});
|
||||
assert.equal((await POST(request("Bearer test-secret"))).status, 200);
|
||||
assert.equal(calls[0].tag, "wagtail-pages");
|
||||
assert.equal(calls[0].profile.expire, 0);
|
||||
assert.equal((await POST(request("Bearer invalid"))).status, 401);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test("all CMS page routes wire metadata and JSON-LD to the correct page model", async () => {
|
||||
const routes = [
|
||||
["app/page.tsx", "home.HomePage", undefined],
|
||||
["app/products/page.tsx", "products.ProductIndexPage", "products"],
|
||||
["app/solutions/page.tsx", "solutions.SolutionIndexPage", "solutions"],
|
||||
["app/cases/page.tsx", "cases.CaseStudyIndexPage", "cases"],
|
||||
["app/blog/page.tsx", "blog.BlogIndexPage", "blog"],
|
||||
["app/contact/page.tsx", "core.ContactPage", "contact"],
|
||||
["app/privacy-policy/page.tsx", "core.SimpleContentPage", "privacy-policy"],
|
||||
["app/products/[slug]/page.tsx", "products.ProductPage", "test-slug"],
|
||||
["app/solutions/[slug]/page.tsx", "solutions.SolutionPage", "test-slug"],
|
||||
["app/cases/[slug]/page.tsx", "cases.CaseStudyPage", "test-slug"],
|
||||
["app/blog/[slug]/page.tsx", "blog.BlogPage", "test-slug"],
|
||||
];
|
||||
const { createPageMetadata } = loadModule("services/seo.service.ts", { "./api-client": {} });
|
||||
const PageSEO = () => null;
|
||||
const service = {
|
||||
getHomePage: async () => page, getContactPage: async () => page,
|
||||
getProductList: async () => [], getSolutionList: async () => [],
|
||||
getCaseStudyList: async () => [], getBlogList: async () => [],
|
||||
getProductBySlug: async () => page, getSolutionBySlug: async () => page,
|
||||
getCaseStudyBySlug: async () => page, getBlogBySlug: async () => page,
|
||||
getSimpleContentPageBySlug: async () => page,
|
||||
};
|
||||
const mocks = {
|
||||
"@/components/layout/PageSEO": { default: PageSEO },
|
||||
"@/blocks/BlockRenderer": { BlockRenderer: () => null },
|
||||
"@/services/preview.service": { getPreviewOrFallback: async (type, fields, fallback) => fallback() },
|
||||
};
|
||||
for (const name of ["page", "product", "solution", "case", "blog", "legal"]) {
|
||||
mocks[`@/services/${name}.service`] = service;
|
||||
}
|
||||
function findSEO(element) {
|
||||
if (!element || typeof element !== "object") return null;
|
||||
if (element.type === PageSEO) return element;
|
||||
const children = element.props?.children;
|
||||
for (const child of Array.isArray(children) ? children : [children]) {
|
||||
const found = findSEO(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
for (const [file, expectedType, expectedSlug] of routes) {
|
||||
const calls = [];
|
||||
const route = loadModule(file, {
|
||||
...mocks,
|
||||
"@/services/seo.service": {
|
||||
createPageMetadata,
|
||||
getPageSEO: async (type, slug) => { calls.push({ type, slug }); return page; },
|
||||
},
|
||||
});
|
||||
const props = { params: Promise.resolve({ slug: "test-slug" }) };
|
||||
assert.equal((await route.generateMetadata(props)).title.absolute, page.seo_title_override, file);
|
||||
assert.equal(calls[0].type, expectedType, file);
|
||||
assert.equal(calls[0].slug, expectedSlug, file);
|
||||
const element = findSEO(await route.default(props));
|
||||
assert.ok(element, file);
|
||||
assert.equal(element.props.type, expectedType, file);
|
||||
assert.equal(element.props.slug, expectedSlug, file);
|
||||
}
|
||||
});
|
||||
@@ -10,12 +10,19 @@ export interface WagtailMeta {
|
||||
html_url: string | null;
|
||||
slug: string;
|
||||
first_published_at: string | null;
|
||||
seo_title?: string;
|
||||
search_description?: string;
|
||||
}
|
||||
|
||||
export interface WagtailPageSummary {
|
||||
id: number;
|
||||
meta: WagtailMeta;
|
||||
title: string;
|
||||
seo_title_override?: string;
|
||||
seo_description_override?: string;
|
||||
og_image?: { url: string; title: string } | null;
|
||||
canonical_url?: string;
|
||||
schema_json?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WagtailListResponse<T> {
|
||||
|
||||
Reference in New Issue
Block a user