create-nextblock 0.16.2 → 0.16.3
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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +4 -4
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +1827 -189
- package/templates/nextblock-template/app/cms/components/SeoScoreBadge.tsx +40 -0
- package/templates/nextblock-template/app/cms/components/TablePagination.tsx +136 -0
- package/templates/nextblock-template/app/cms/pages/page.tsx +273 -227
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +2 -0
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +7 -1
- package/templates/nextblock-template/app/cms/posts/page.tsx +251 -194
- package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +8 -1
- package/templates/nextblock-template/lib/seo/page-audit-context.tsx +18 -1
- package/templates/nextblock-template/lib/seo/page-document.test.ts +34 -0
- package/templates/nextblock-template/lib/seo/page-document.ts +52 -1
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +192 -177
- package/templates/nextblock-template/package.json +1 -1
|
@@ -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";
|
|
17
|
-
import {
|
|
18
|
-
DropdownMenu,
|
|
19
|
-
DropdownMenuContent,
|
|
20
|
-
DropdownMenuItem,
|
|
21
|
-
DropdownMenuButtonTrigger,
|
|
22
|
-
DropdownMenuSeparator,
|
|
23
|
-
} from "@nextblock-cms/ui";
|
|
24
|
-
|
|
25
|
-
import
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
import
|
|
32
|
-
import
|
|
33
|
-
import
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
</
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
}
|
|
@@ -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(
|
|
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' or an editorial 'post'. */
|
|
58
|
+
documentType: 'page' | 'post';
|
|
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';
|
|
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>;
|
|
@@ -347,4 +347,38 @@ describe('buildPageSeoDocument feeding the page-level audit', () => {
|
|
|
347
347
|
expect(oneBlock.issues.map((issue) => issue.id)).toContain('content-thin');
|
|
348
348
|
expect(wholePage.issues.map((issue) => issue.id)).not.toContain('content-thin');
|
|
349
349
|
});
|
|
350
|
+
|
|
351
|
+
it('treats post title as H1 when documentType is post', () => {
|
|
352
|
+
const blocks = [
|
|
353
|
+
headingBlock(2, 'Overview'),
|
|
354
|
+
textBlock('<p>Detailed article content goes here.</p>'),
|
|
355
|
+
];
|
|
356
|
+
|
|
357
|
+
const withoutOptions = buildPageSeoDocument(blocks);
|
|
358
|
+
expect(withoutOptions.headings.map((h) => h.level)).toEqual([2]);
|
|
359
|
+
|
|
360
|
+
const withPost = buildPageSeoDocument(blocks, {
|
|
361
|
+
documentTitle: 'My Great Article',
|
|
362
|
+
documentType: 'post',
|
|
363
|
+
});
|
|
364
|
+
expect(withPost.headings).toEqual([
|
|
365
|
+
{ level: 1, order: 0, text: 'My Great Article' },
|
|
366
|
+
{ level: 2, order: 1, text: 'Overview' },
|
|
367
|
+
]);
|
|
368
|
+
expect(withPost.words).toContain('my');
|
|
369
|
+
expect(withPost.words).toContain('article');
|
|
370
|
+
|
|
371
|
+
const audit = auditSeo({ document: withPost });
|
|
372
|
+
expect(audit.issues.map((i) => i.id)).not.toContain('headings-missing-h1');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('handles empty blocks with post title', () => {
|
|
376
|
+
const doc = buildPageSeoDocument([], {
|
|
377
|
+
documentTitle: 'Initial Draft Post',
|
|
378
|
+
documentType: 'post',
|
|
379
|
+
});
|
|
380
|
+
expect(doc.headings).toEqual([{ level: 1, order: 0, text: 'Initial Draft Post' }]);
|
|
381
|
+
expect(doc.text).toBe('Initial Draft Post');
|
|
382
|
+
});
|
|
350
383
|
});
|
|
384
|
+
|
|
@@ -384,6 +384,23 @@ function collectBlock(value: unknown, draft: PageDocumentDraft, depth: number):
|
|
|
384
384
|
}
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
+
export interface BuildPageSeoDocumentOptions {
|
|
388
|
+
/**
|
|
389
|
+
* An explicit document title to treat as the page's top-level H1.
|
|
390
|
+
*
|
|
391
|
+
* Posts in NextBlock render their title in an editorial H1 wrapper on the public
|
|
392
|
+
* page (`PostClientContent.tsx`) rather than storing it as a heading block inside
|
|
393
|
+
* the content array. Supplying `documentTitle` with `documentType: 'post'` ensures
|
|
394
|
+
* the audit sees that H1 instead of falsely reporting `headings-missing-h1`.
|
|
395
|
+
*/
|
|
396
|
+
documentTitle?: string | null;
|
|
397
|
+
/**
|
|
398
|
+
* The kind of document being graded. When set to 'post' and a documentTitle is present,
|
|
399
|
+
* the title is treated as the primary H1 of the document.
|
|
400
|
+
*/
|
|
401
|
+
documentType?: 'page' | 'post';
|
|
402
|
+
}
|
|
403
|
+
|
|
387
404
|
/**
|
|
388
405
|
* Builds one `SeoDocument` from a page's or post's block list.
|
|
389
406
|
*
|
|
@@ -392,8 +409,27 @@ function collectBlock(value: unknown, draft: PageDocumentDraft, depth: number):
|
|
|
392
409
|
* signature that it is already an array of well-formed rows would only push the
|
|
393
410
|
* validation somewhere that has less context to do it in.
|
|
394
411
|
*/
|
|
395
|
-
export function buildPageSeoDocument(
|
|
412
|
+
export function buildPageSeoDocument(
|
|
413
|
+
blocks: unknown,
|
|
414
|
+
options?: BuildPageSeoDocumentOptions
|
|
415
|
+
): SeoDocument {
|
|
416
|
+
const isPostWithTitle =
|
|
417
|
+
options?.documentType === 'post' &&
|
|
418
|
+
typeof options?.documentTitle === 'string' &&
|
|
419
|
+
options.documentTitle.trim() !== '';
|
|
420
|
+
|
|
396
421
|
if (!Array.isArray(blocks) || blocks.length === 0) {
|
|
422
|
+
if (isPostWithTitle) {
|
|
423
|
+
const titleText = options!.documentTitle!.trim();
|
|
424
|
+
const titleWords = tokenizeWords(titleText);
|
|
425
|
+
return {
|
|
426
|
+
headings: [{ level: 1, order: 0, text: titleText }],
|
|
427
|
+
images: [],
|
|
428
|
+
links: [],
|
|
429
|
+
text: titleText,
|
|
430
|
+
words: titleWords,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
397
433
|
return emptySeoDocument();
|
|
398
434
|
}
|
|
399
435
|
|
|
@@ -402,6 +438,21 @@ export function buildPageSeoDocument(blocks: unknown): SeoDocument {
|
|
|
402
438
|
collectBlock(block, draft, 0);
|
|
403
439
|
}
|
|
404
440
|
|
|
441
|
+
if (isPostWithTitle) {
|
|
442
|
+
const titleText = options!.documentTitle!.trim();
|
|
443
|
+
const titleWords = tokenizeWords(titleText);
|
|
444
|
+
draft.headings.unshift({
|
|
445
|
+
level: 1,
|
|
446
|
+
order: 0,
|
|
447
|
+
text: titleText,
|
|
448
|
+
});
|
|
449
|
+
for (let i = 1; i < draft.headings.length; i++) {
|
|
450
|
+
draft.headings[i].order = i;
|
|
451
|
+
}
|
|
452
|
+
draft.textParts.unshift(titleText);
|
|
453
|
+
draft.words.unshift(...titleWords);
|
|
454
|
+
}
|
|
455
|
+
|
|
405
456
|
return {
|
|
406
457
|
headings: draft.headings,
|
|
407
458
|
images: draft.images,
|