create-nextblock 0.16.2 → 0.16.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -336,11 +336,17 @@ export default function PostForm({
336
336
  * also bails when the values are unchanged, so nothing here can loop back into a render of
337
337
  * this form.
338
338
  */
339
- const setPageSeoMeta = usePageSeo()?.setMeta;
339
+ const pageSeo = usePageSeo();
340
+ const setPageSeoMeta = pageSeo?.setMeta;
341
+ const setPageSeoDocumentTitle = pageSeo?.setDocumentTitle;
340
342
  useEffect(() => {
341
343
  setPageSeoMeta?.({ metaDescription, metaTitle });
342
344
  }, [metaDescription, metaTitle, setPageSeoMeta]);
343
345
 
346
+ useEffect(() => {
347
+ setPageSeoDocumentTitle?.(title);
348
+ }, [title, setPageSeoDocumentTitle]);
349
+
344
350
  // Remove languagesLoading from this condition
345
351
  if (authLoading) {
346
352
  return <div>Loading form...</div>;
@@ -1,194 +1,251 @@
1
- // app/cms/posts/page.tsx
2
- import React from "react";
3
- import { createClient } from "@nextblock-cms/db/server";
4
- import Link from "next/link";
5
- import { Button } from "@nextblock-cms/ui";
6
- import {
7
- Table,
8
- TableBody,
9
- TableCell,
10
- TableHead,
11
- TableHeader,
12
- TableRow,
13
- } from "@nextblock-cms/ui";
14
- import { Badge } from "@nextblock-cms/ui";
15
- import { Alert, AlertDescription } from "@nextblock-cms/ui";
16
- import { MoreHorizontal, PlusCircle, Edit3, PenTool } from "lucide-react"; // Removed Trash2 as it's in the client component
17
- import {
18
- DropdownMenu,
19
- DropdownMenuContent,
20
- DropdownMenuItem,
21
- DropdownMenuButtonTrigger,
22
- DropdownMenuSeparator,
23
- } from "@nextblock-cms/ui";
24
- // deletePost server action is now used by DeletePostButtonClient
25
- import type { Database } from "@nextblock-cms/db";
26
- import { getActiveLanguagesServerSide } from "@nextblock-cms/db/server";
27
- import { resolveMediaUrl } from "../../../lib/media/resolveMediaUrl";
28
-
29
- type Post = Database['public']['Tables']['posts']['Row'] & { feature_image_url?: string | null };
30
- import LanguageFilterSelect from "../components/LanguageFilterSelect";
31
- import DeletePostButtonClient from "./components/DeletePostButtonClient"; // Import the new client component
32
- import { ContentTransferControls } from "../import-export/ContentTransferControls";
33
- import VisibilityBadge from "../components/VisibilityBadge";
34
-
35
- async function getPostsWithDetails(filterLanguageId?: number): Promise<{ post: Post; languageCode: string }[]> {
36
- const supabase = createClient();
37
- const languages = await getActiveLanguagesServerSide();
38
- const langMap = new Map(languages.map(l => [l.id, l.code]));
39
-
40
- let query = supabase
41
- .from("posts")
42
- .select("*, languages!inner(code), media ( object_key )")
43
- .order("created_at", { ascending: false });
44
-
45
- if (filterLanguageId) {
46
- query = query.eq("language_id", filterLanguageId);
47
- }
48
-
49
- const { data: postsData, error } = await query;
50
-
51
- if (error) {
52
- console.error("Error fetching posts:", error);
53
- return [];
54
- }
55
- if (!postsData) return [];
56
-
57
- return postsData.map(p => {
58
- const langInfo = p.languages as unknown as { code: string } | null;
59
- return {
60
- post: { ...p, feature_image_url: resolveMediaUrl(p.media?.object_key) } as Post,
61
- languageCode: langInfo?.code?.toUpperCase() || langMap.get(p.language_id)?.toUpperCase() || 'N/A',
62
- };
63
- });
64
- }
65
-
66
- interface CmsPostsListPageProps {
67
- searchParams?: Promise<{
68
- lang?: string;
69
- success?: string;
70
- }>;
71
- }
72
-
73
- export default async function CmsPostsListPage(props: CmsPostsListPageProps) {
74
- const searchParams = await props.searchParams;
75
- const allLanguages = await getActiveLanguagesServerSide();
76
- const selectedLangId = searchParams?.lang ? parseInt(searchParams.lang, 10) : undefined;
77
- const isValidLangId = selectedLangId ? allLanguages.some(l => l.id === selectedLangId) : true;
78
- const filterLangId = isValidLangId ? selectedLangId : undefined;
79
-
80
- const postsWithDetails = await getPostsWithDetails(filterLangId);
81
- const successMessage = searchParams?.success;
82
-
83
- return (
84
- <div className="w-full">
85
- <div className="flex justify-between items-center mb-6 flex-wrap gap-4">
86
- <h1 className="text-2xl font-semibold">Manage Posts</h1>
87
- <div className="flex items-center gap-3">
88
- <ContentTransferControls
89
- contentType="posts"
90
- label="Posts"
91
- languageId={filterLangId}
92
- hasContent={postsWithDetails.length > 0}
93
- />
94
- <LanguageFilterSelect
95
- allLanguages={allLanguages}
96
- currentFilterLangId={filterLangId}
97
- basePath="/cms/posts"
98
- />
99
- <Button variant="default" asChild>
100
- <Link href="/cms/posts/new">
101
- <PlusCircle className="mr-2 h-4 w-4" /> Create New Post
102
- </Link>
103
- </Button>
104
- </div>
105
- </div>
106
-
107
- {successMessage && (
108
- <Alert variant="success" className="mb-4">
109
- <AlertDescription>
110
- {decodeURIComponent(successMessage)}
111
- </AlertDescription>
112
- </Alert>
113
- )}
114
-
115
- {postsWithDetails.length === 0 ? (
116
- <div className="text-center py-10 border rounded-lg dark:border-slate-700">
117
- <PenTool className="mx-auto h-12 w-12 text-muted-foreground" />
118
- <h3 className="mt-2 text-sm font-medium text-foreground">
119
- {filterLangId ? "No posts found for the selected language." : "No posts found."}
120
- </h3>
121
- <p className="mt-1 text-sm text-muted-foreground">
122
- Get started by creating a new post.
123
- </p>
124
- <div className="mt-6">
125
- <Button asChild>
126
- <Link href="/cms/posts/new">
127
- <PlusCircle className="mr-2 h-4 w-4" /> Create Post
128
- </Link>
129
- </Button>
130
- </div>
131
- </div>
132
- ) : (
133
- <div className="rounded-lg border overflow-hidden dark:border-slate-700">
134
- <Table>
135
- <TableHeader>
136
- <TableRow className="dark:border-slate-700">
137
- <TableHead className="w-[300px] sm:w-[400px]">Title</TableHead>
138
- <TableHead>Status</TableHead>
139
- <TableHead>Language</TableHead>
140
- <TableHead className="hidden md:table-cell">Slug</TableHead>
141
- <TableHead className="hidden lg:table-cell">Published At</TableHead>
142
- <TableHead className="text-right w-[80px]">Actions</TableHead>
143
- </TableRow>
144
- </TableHeader>
145
- <TableBody>
146
- {postsWithDetails.map(({ post, languageCode }) => (
147
- <TableRow key={post.id} className="dark:border-slate-700">
148
- <TableCell className="font-medium">
149
- <Link
150
- href={`/cms/posts/${post.id}/edit`}
151
- className="flex items-center cursor-pointer"
152
- >
153
- <Edit3 className="mr-2 h-4 w-4" />
154
- {post.title}
155
- </Link>
156
- </TableCell>
157
- <TableCell>
158
- <VisibilityBadge
159
- type="post"
160
- status={post.status}
161
- publishedAt={post.published_at}
162
- />
163
- </TableCell>
164
- <TableCell><Badge variant="outline" className="dark:border-slate-600">{languageCode}</Badge></TableCell>
165
- <TableCell className="text-muted-foreground text-xs hidden md:table-cell">/article/{post.slug}</TableCell>
166
- <TableCell className="hidden lg:table-cell text-xs text-muted-foreground">
167
- {post.published_at ? new Date(post.published_at).toLocaleDateString() : "Not yet"}
168
- </TableCell>
169
- <TableCell className="text-right">
170
- <DropdownMenu>
171
- <DropdownMenuButtonTrigger id={`post-trigger-${post.id}`}>
172
- <MoreHorizontal className="h-4 w-4" />
173
- <span className="sr-only">Post actions for {post.title}</span>
174
- </DropdownMenuButtonTrigger>
175
- <DropdownMenuContent align="end">
176
- <DropdownMenuItem asChild>
177
- <Link href={`/cms/posts/${post.id}/edit`} className="flex items-center cursor-pointer">
178
- <Edit3 className="mr-2 h-4 w-4" /> Edit
179
- </Link>
180
- </DropdownMenuItem>
181
- <DropdownMenuSeparator />
182
- <DeletePostButtonClient postId={post.id} />
183
- </DropdownMenuContent>
184
- </DropdownMenu>
185
- </TableCell>
186
- </TableRow>
187
- ))}
188
- </TableBody>
189
- </Table>
190
- </div>
191
- )}
192
- </div>
193
- );
194
- }
1
+ // app/cms/posts/page.tsx
2
+ import React from "react";
3
+ import { createClient } from "@nextblock-cms/db/server";
4
+ import Link from "next/link";
5
+ import { Button } from "@nextblock-cms/ui";
6
+ import {
7
+ Table,
8
+ TableBody,
9
+ TableCell,
10
+ TableHead,
11
+ TableHeader,
12
+ TableRow,
13
+ } from "@nextblock-cms/ui";
14
+ import { Badge } from "@nextblock-cms/ui";
15
+ import { Alert, AlertDescription } from "@nextblock-cms/ui";
16
+ import { MoreHorizontal, PlusCircle, Edit3, PenTool } from "lucide-react";
17
+ import {
18
+ DropdownMenu,
19
+ DropdownMenuContent,
20
+ DropdownMenuItem,
21
+ DropdownMenuButtonTrigger,
22
+ DropdownMenuSeparator,
23
+ } from "@nextblock-cms/ui";
24
+ import type { Database } from "@nextblock-cms/db";
25
+ import { getActiveLanguagesServerSide } from "@nextblock-cms/db/server";
26
+ import { resolveMediaUrl } from "../../../lib/media/resolveMediaUrl";
27
+ import { auditSeo } from "@nextblock-cms/utils/seo";
28
+ import { buildPageSeoDocument } from "../../../lib/seo/page-document";
29
+
30
+ type Post = Database['public']['Tables']['posts']['Row'] & { feature_image_url?: string | null };
31
+ import LanguageFilterSelect from "../components/LanguageFilterSelect";
32
+ import DeletePostButtonClient from "./components/DeletePostButtonClient";
33
+ import { ContentTransferControls } from "../import-export/ContentTransferControls";
34
+ import VisibilityBadge from "../components/VisibilityBadge";
35
+ import SeoScoreBadge from "../components/SeoScoreBadge";
36
+ import TablePagination from "../components/TablePagination";
37
+
38
+ async function getPostsWithDetails(
39
+ filterLanguageId?: number,
40
+ pageNumber: number = 1,
41
+ pageSize: number = 25
42
+ ): Promise<{
43
+ items: { post: Post; languageCode: string; seoScore: number }[];
44
+ totalCount: number;
45
+ }> {
46
+ const supabase = createClient();
47
+ const languages = await getActiveLanguagesServerSide();
48
+ const langMap = new Map(languages.map(l => [l.id, l.code]));
49
+
50
+ let query = supabase
51
+ .from("posts")
52
+ .select("*, languages!inner(code), media ( object_key ), blocks(id, block_type, content, order)", { count: "exact" })
53
+ .order("created_at", { ascending: false });
54
+
55
+ if (filterLanguageId) {
56
+ query = query.eq("language_id", filterLanguageId);
57
+ }
58
+
59
+ const from = (pageNumber - 1) * pageSize;
60
+ const to = from + pageSize - 1;
61
+
62
+ const { data: postsData, count, error } = await query.range(from, to);
63
+
64
+ if (error) {
65
+ console.error("Error fetching posts:", error);
66
+ return { items: [], totalCount: 0 };
67
+ }
68
+ if (!postsData) return { items: [], totalCount: 0 };
69
+
70
+ const items = postsData.map(p => {
71
+ const langInfo = p.languages as unknown as { code: string } | null;
72
+ const rawBlocks = (p.blocks || []) as unknown as Parameters<typeof buildPageSeoDocument>[0];
73
+ const doc = buildPageSeoDocument(rawBlocks, {
74
+ documentTitle: p.title,
75
+ documentType: "post",
76
+ });
77
+ const auditResult = auditSeo({
78
+ document: doc,
79
+ metaTitle: p.meta_title ?? undefined,
80
+ metaDescription: p.meta_description ?? undefined,
81
+ scope: "page",
82
+ });
83
+
84
+ const langCode = langInfo?.code || langMap.get(p.language_id) || "N/A";
85
+
86
+ return {
87
+ post: { ...p, feature_image_url: resolveMediaUrl(p.media?.object_key) } as Post,
88
+ languageCode: String(langCode).toUpperCase(),
89
+ seoScore: auditResult.score,
90
+ };
91
+ });
92
+
93
+ return { items, totalCount: count ?? items.length };
94
+ }
95
+
96
+ interface CmsPostsListPageProps {
97
+ searchParams?: Promise<{
98
+ lang?: string;
99
+ success?: string;
100
+ page?: string;
101
+ pageSize?: string;
102
+ }>;
103
+ }
104
+
105
+ export default async function CmsPostsListPage(props: CmsPostsListPageProps) {
106
+ const searchParams = await props.searchParams;
107
+ const allLanguages = await getActiveLanguagesServerSide();
108
+ const selectedLangId = searchParams?.lang ? parseInt(searchParams.lang, 10) : undefined;
109
+ const isValidLangId = selectedLangId ? allLanguages.some(l => l.id === selectedLangId) : true;
110
+ const filterLangId = isValidLangId ? selectedLangId : undefined;
111
+
112
+ const pageNumber = searchParams?.page
113
+ ? Math.max(1, parseInt(searchParams.page, 10) || 1)
114
+ : 1;
115
+ const pageSize = searchParams?.pageSize
116
+ ? Math.max(1, parseInt(searchParams.pageSize, 10) || 25)
117
+ : 25;
118
+
119
+ const { items: postsWithDetails, totalCount } = await getPostsWithDetails(
120
+ filterLangId,
121
+ pageNumber,
122
+ pageSize
123
+ );
124
+ const successMessage = searchParams?.success;
125
+
126
+ return (
127
+ <div className="w-full">
128
+ <div className="flex justify-between items-center mb-6 flex-wrap gap-4">
129
+ <h1 className="text-2xl font-semibold">Manage Posts</h1>
130
+ <div className="flex items-center gap-3">
131
+ <ContentTransferControls
132
+ contentType="posts"
133
+ label="Posts"
134
+ languageId={filterLangId}
135
+ hasContent={postsWithDetails.length > 0}
136
+ />
137
+ <LanguageFilterSelect
138
+ allLanguages={allLanguages}
139
+ currentFilterLangId={filterLangId}
140
+ basePath="/cms/posts"
141
+ />
142
+ <Button variant="default" asChild>
143
+ <Link href="/cms/posts/new">
144
+ <PlusCircle className="mr-2 h-4 w-4" /> Create New Post
145
+ </Link>
146
+ </Button>
147
+ </div>
148
+ </div>
149
+
150
+ {successMessage && (
151
+ <Alert variant="success" className="mb-4">
152
+ <AlertDescription>
153
+ {decodeURIComponent(successMessage)}
154
+ </AlertDescription>
155
+ </Alert>
156
+ )}
157
+
158
+ {totalCount === 0 ? (
159
+ <div className="text-center py-10 border rounded-lg dark:border-slate-700">
160
+ <PenTool className="mx-auto h-12 w-12 text-muted-foreground" />
161
+ <h3 className="mt-2 text-sm font-medium text-foreground">
162
+ {filterLangId
163
+ ? "No posts found for the selected language."
164
+ : "No posts found."}
165
+ </h3>
166
+ <p className="mt-1 text-sm text-muted-foreground">
167
+ Get started by creating a new post.
168
+ </p>
169
+ <div className="mt-6">
170
+ <Button asChild>
171
+ <Link href="/cms/posts/new">
172
+ <PlusCircle className="mr-2 h-4 w-4" /> Create Post
173
+ </Link>
174
+ </Button>
175
+ </div>
176
+ </div>
177
+ ) : (
178
+ <div className="rounded-lg border overflow-hidden dark:border-slate-700">
179
+ <Table>
180
+ <TableHeader>
181
+ <TableRow className="dark:border-slate-700">
182
+ <TableHead className="w-[280px] sm:w-[350px]">Title</TableHead>
183
+ <TableHead>Status</TableHead>
184
+ <TableHead>Language</TableHead>
185
+ <TableHead className="hidden md:table-cell">Slug</TableHead>
186
+ <TableHead>SEO</TableHead>
187
+ <TableHead className="hidden lg:table-cell">Published At</TableHead>
188
+ <TableHead className="text-right w-[80px]">Actions</TableHead>
189
+ </TableRow>
190
+ </TableHeader>
191
+ <TableBody>
192
+ {postsWithDetails.map(({ post, languageCode, seoScore }) => (
193
+ <TableRow key={post.id} className="dark:border-slate-700">
194
+ <TableCell className="font-medium">
195
+ <Link
196
+ href={`/cms/posts/${post.id}/edit`}
197
+ className="flex items-center cursor-pointer"
198
+ >
199
+ <Edit3 className="mr-2 h-4 w-4" />
200
+ {post.title}
201
+ </Link>
202
+ </TableCell>
203
+ <TableCell>
204
+ <VisibilityBadge
205
+ type="post"
206
+ status={post.status}
207
+ publishedAt={post.published_at}
208
+ />
209
+ </TableCell>
210
+ <TableCell><Badge variant="outline" className="dark:border-slate-600">{languageCode}</Badge></TableCell>
211
+ <TableCell className="text-muted-foreground text-xs hidden md:table-cell">/article/{post.slug}</TableCell>
212
+ <TableCell>
213
+ <SeoScoreBadge score={seoScore} />
214
+ </TableCell>
215
+ <TableCell className="hidden lg:table-cell text-xs text-muted-foreground">
216
+ {post.published_at ? new Date(post.published_at).toLocaleDateString() : "Not yet"}
217
+ </TableCell>
218
+ <TableCell className="text-right">
219
+ <DropdownMenu>
220
+ <DropdownMenuButtonTrigger id={`post-trigger-${post.id}`}>
221
+ <MoreHorizontal className="h-4 w-4" />
222
+ <span className="sr-only">Post actions for {post.title}</span>
223
+ </DropdownMenuButtonTrigger>
224
+ <DropdownMenuContent align="end">
225
+ <DropdownMenuItem asChild>
226
+ <Link href={`/cms/posts/${post.id}/edit`} className="flex items-center cursor-pointer">
227
+ <Edit3 className="mr-2 h-4 w-4" /> Edit
228
+ </Link>
229
+ </DropdownMenuItem>
230
+ <DropdownMenuSeparator />
231
+ <DeletePostButtonClient postId={post.id} />
232
+ </DropdownMenuContent>
233
+ </DropdownMenu>
234
+ </TableCell>
235
+ </TableRow>
236
+ ))}
237
+ </TableBody>
238
+ </Table>
239
+
240
+ <TablePagination
241
+ currentPage={pageNumber}
242
+ pageSize={pageSize}
243
+ totalCount={totalCount}
244
+ basePath="/cms/posts"
245
+ itemLabel="posts"
246
+ />
247
+ </div>
248
+ )}
249
+ </div>
250
+ );
251
+ }
@@ -4,6 +4,7 @@ import React from 'react';
4
4
  import { ProductForm } from '@nextblock-cms/ecommerce';
5
5
  import MediaPickerDialog from '../media/components/MediaPickerDialog';
6
6
  import DraftStatusActions from '../components/DraftStatusActions';
7
+ import { usePageSeo } from '../../../lib/seo/page-audit-context';
7
8
 
8
9
  type ProductFormProps = React.ComponentProps<typeof ProductForm>;
9
10
  type ProductUpdateAction = NonNullable<ProductFormProps['updateAction']>;
@@ -24,6 +25,23 @@ export default function ProductFormClientShell({
24
25
  ...props
25
26
  }: ProductFormClientShellProps) {
26
27
  const [isMounted, setIsMounted] = React.useState(false);
28
+ const pageSeo = usePageSeo();
29
+
30
+ const handleSeoChange = React.useCallback(
31
+ (values: { title?: string | null; meta_title?: string | null; meta_description?: string | null }) => {
32
+ if (pageSeo) {
33
+ if (values.title !== undefined) {
34
+ pageSeo.setDocumentTitle(values.title || null);
35
+ }
36
+ pageSeo.setMeta({
37
+ metaTitle: values.meta_title || null,
38
+ metaDescription: values.meta_description || null,
39
+ });
40
+ }
41
+ },
42
+ [pageSeo]
43
+ );
44
+
27
45
  // The draft toolbar is gated on the server-computed `hasDraft`, but the form
28
46
  // autosave writes a draft WITHOUT revalidating the route (revalidating would
29
47
  // re-init the form and loop the autosave — see updateProductAction). Track
@@ -64,6 +82,7 @@ export default function ProductFormClientShell({
64
82
  <ProductForm
65
83
  {...props}
66
84
  hasOpenDraft={hasDraft}
85
+ onSeoChange={handleSeoChange}
67
86
  updateAction={updateAction ? wrappedUpdateAction : undefined}
68
87
  mediaPickerNode={
69
88
  <MediaPickerDialog
@@ -35,6 +35,8 @@ import BlockEditorArea from '../../../blocks/components/BlockEditorArea';
35
35
  import VisibilityControl from '../../../components/VisibilityControl';
36
36
  import RevisionHistoryButton from '../../../revisions/RevisionHistoryButton';
37
37
  import { buildViewUrl } from '../../../../../lib/publishing/viewUrl';
38
+ import { PageSeoProvider } from '../../../../../lib/seo/page-audit-context';
39
+ import { PageSeoAuditSection } from '../../../../../components/seo/PageSeoAuditSection';
38
40
 
39
41
  export default async function EditProductPage({
40
42
  params,
@@ -211,8 +213,15 @@ export default async function EditProductPage({
211
213
  }
212
214
 
213
215
  return (
214
- <div className="space-y-8 w-full max-w-[1400px] mx-auto px-6 py-8">
215
- <CortexAiPageContextRegistrar
216
+ <PageSeoProvider
217
+ documentTitle={product.title}
218
+ documentType="product"
219
+ initialBlocks={descriptionBlocks}
220
+ initialMetaDescription={product.meta_description}
221
+ initialMetaTitle={product.meta_title}
222
+ >
223
+ <div className="space-y-8 w-full max-w-[1400px] mx-auto px-6 py-8">
224
+ <CortexAiPageContextRegistrar
216
225
  context={{
217
226
  contentType: 'product',
218
227
  entityId: product.id,
@@ -356,6 +365,8 @@ export default async function EditProductPage({
356
365
  availableCategoriesProp={allCategories}
357
366
  />
358
367
 
368
+ <PageSeoAuditSection className="my-6" />
369
+
359
370
  <div className="border-t pt-8">
360
371
  <h2 className="text-xl font-bold mb-4">Product Description Blocks</h2>
361
372
  <BlockEditorArea
@@ -366,5 +377,6 @@ export default async function EditProductPage({
366
377
  />
367
378
  </div>
368
379
  </div>
380
+ </PageSeoProvider>
369
381
  );
370
382
  }
@@ -152,7 +152,14 @@ export function PageSeoAuditSection({ className }: PageSeoAuditSectionProps) {
152
152
  // Memoised because the document's identity is one of the panel's debounce
153
153
  // dependencies: a fresh object on every render would reset the panel's timer
154
154
  // forever and no score would ever appear.
155
- const pageDocument = React.useMemo(() => buildPageSeoDocument(settledBlocks), [settledBlocks]);
155
+ const pageDocument = React.useMemo(
156
+ () =>
157
+ buildPageSeoDocument(settledBlocks, {
158
+ documentTitle: pageSeo?.snapshot.documentTitle,
159
+ documentType: pageSeo?.snapshot.documentType,
160
+ }),
161
+ [settledBlocks, pageSeo?.snapshot.documentTitle, pageSeo?.snapshot.documentType],
162
+ );
156
163
 
157
164
  const handleAuditChange = React.useCallback((next: SeoAuditResult | null) => {
158
165
  setAudit(next);
@@ -52,6 +52,10 @@ export interface PageSeoSnapshot {
52
52
  * content is `Json` and a custom block can carry anything.
53
53
  */
54
54
  blocks: unknown[];
55
+ /** Document title for articles / posts where the template renders an H1. */
56
+ documentTitle: string | null;
57
+ /** Whether this is a standalone 'page', an editorial 'post', or a store 'product'. */
58
+ documentType: 'page' | 'post' | 'product';
55
59
  metaDescription: string | null;
56
60
  metaTitle: string | null;
57
61
  }
@@ -60,6 +64,7 @@ export interface PageSeoContextValue {
60
64
  /** The focus keyphrase, held here so it survives switching between blocks. */
61
65
  keyword: string;
62
66
  setBlocks: (blocks: unknown[]) => void;
67
+ setDocumentTitle: (title: string | null) => void;
63
68
  setKeyword: (keyword: string) => void;
64
69
  setMeta: (meta: { metaDescription: string | null; metaTitle: string | null }) => void;
65
70
  snapshot: PageSeoSnapshot;
@@ -69,6 +74,8 @@ const PageSeoContext = createContext<PageSeoContextValue | null>(null);
69
74
 
70
75
  export interface PageSeoProviderProps {
71
76
  children: ReactNode;
77
+ documentTitle?: string | null;
78
+ documentType?: 'page' | 'post' | 'product';
72
79
  initialBlocks?: unknown[];
73
80
  initialMetaDescription?: string | null;
74
81
  initialMetaTitle?: string | null;
@@ -76,17 +83,24 @@ export interface PageSeoProviderProps {
76
83
 
77
84
  export function PageSeoProvider({
78
85
  children,
86
+ documentTitle: initialDocumentTitle = null,
87
+ documentType = 'page',
79
88
  initialBlocks = [],
80
89
  initialMetaDescription = null,
81
90
  initialMetaTitle = null,
82
91
  }: PageSeoProviderProps) {
83
92
  const [blocks, setBlocksState] = useState<unknown[]>(initialBlocks);
93
+ const [documentTitle, setDocumentTitleState] = useState<string | null>(initialDocumentTitle);
84
94
  const [keyword, setKeyword] = useState('');
85
95
  const [meta, setMetaState] = useState<{
86
96
  metaDescription: string | null;
87
97
  metaTitle: string | null;
88
98
  }>({ metaDescription: initialMetaDescription, metaTitle: initialMetaTitle });
89
99
 
100
+ const setDocumentTitle = useCallback((next: string | null) => {
101
+ setDocumentTitleState((previous) => (previous === next ? previous : next));
102
+ }, []);
103
+
90
104
  // Both setters bail when nothing actually changed. The blocks array is rebuilt on
91
105
  // every keystroke in a block editor, so without the length-and-identity check
92
106
  // below every character typed would re-render the provider and every consumer of
@@ -114,15 +128,18 @@ export function PageSeoProvider({
114
128
  () => ({
115
129
  keyword,
116
130
  setBlocks,
131
+ setDocumentTitle,
117
132
  setKeyword,
118
133
  setMeta,
119
134
  snapshot: {
120
135
  blocks,
136
+ documentTitle,
137
+ documentType,
121
138
  metaDescription: meta.metaDescription,
122
139
  metaTitle: meta.metaTitle,
123
140
  },
124
141
  }),
125
- [blocks, keyword, meta.metaDescription, meta.metaTitle, setBlocks, setMeta],
142
+ [blocks, documentTitle, documentType, keyword, meta.metaDescription, meta.metaTitle, setBlocks, setDocumentTitle, setMeta],
126
143
  );
127
144
 
128
145
  return <PageSeoContext.Provider value={value}>{children}</PageSeoContext.Provider>;