feat: render CMS SEO metadata and refresh published page caches

This commit is contained in:
2026-10-11 18:05:47 +08:00
parent a8e2863cc4
commit 870b9b4282
19 changed files with 310 additions and 35 deletions
+22
View File
@@ -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:
+1 -2
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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) => (
+4 -6
View File
@@ -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
View File
@@ -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} /></>;
}
+4 -2
View File
@@ -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 -2
View File
@@ -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>
+6 -3
View File
@@ -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 -2
View File
@@ -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>
+6 -3
View File
@@ -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) => (
+14
View File
@@ -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"),
}}
/>
);
}
+4
View File
@@ -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:
+6 -1
View File
@@ -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) {
+35
View File
@@ -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 },
};
}
+167
View File
@@ -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);
}
});
+7
View 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> {