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.
- 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 +2002 -189
- package/templates/nextblock-template/app/cms/components/SeoScoreBadge.tsx +3 -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/app/cms/products/ProductFormClientShell.tsx +19 -0
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +14 -2
- 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 +68 -0
- package/templates/nextblock-template/lib/seo/page-document.ts +4 -412
- package/templates/nextblock-template/lib/seo/robots-txt.ts +5 -4
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +202 -177
- package/templates/nextblock-template/package.json +1 -1
|
@@ -347,4 +347,72 @@ 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
|
+
});
|
|
383
|
+
|
|
384
|
+
it('prepends product title as H1 when documentType is product', () => {
|
|
385
|
+
const blocks = [
|
|
386
|
+
{
|
|
387
|
+
block_type: 'section',
|
|
388
|
+
content: {
|
|
389
|
+
column_blocks: [
|
|
390
|
+
[
|
|
391
|
+
{
|
|
392
|
+
block_type: 'heading',
|
|
393
|
+
content: { level: 2, text_content: 'Features' },
|
|
394
|
+
},
|
|
395
|
+
],
|
|
396
|
+
],
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
];
|
|
400
|
+
|
|
401
|
+
const withProduct = buildPageSeoDocument(blocks, {
|
|
402
|
+
documentTitle: 'NextBlock Commerce Pro',
|
|
403
|
+
documentType: 'product',
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
expect(withProduct.headings).toEqual([
|
|
407
|
+
{ level: 1, order: 0, text: 'NextBlock Commerce Pro' },
|
|
408
|
+
{ level: 2, order: 1, text: 'Features' },
|
|
409
|
+
]);
|
|
410
|
+
expect(withProduct.words).toContain('commerce');
|
|
411
|
+
expect(withProduct.words).toContain('pro');
|
|
412
|
+
|
|
413
|
+
const audit = auditSeo({ document: withProduct });
|
|
414
|
+
expect(audit.issues.map((i) => i.id)).not.toContain('headings-missing-h1');
|
|
415
|
+
});
|
|
350
416
|
});
|
|
417
|
+
|
|
418
|
+
|
|
@@ -1,412 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
tokenizeWords,
|
|
6
|
-
type SeoDocument,
|
|
7
|
-
type SeoHeading,
|
|
8
|
-
type SeoHeadingLevel,
|
|
9
|
-
type SeoImage,
|
|
10
|
-
type SeoLink,
|
|
11
|
-
} from '@nextblock-cms/utils/seo';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Flattens a page's blocks into the single `SeoDocument` the audit grades.
|
|
15
|
-
*
|
|
16
|
-
* The SEO engine in `@nextblock-cms/utils/seo` was written against one document
|
|
17
|
-
* that represents one page, but the CMS does not store a page as one document:
|
|
18
|
-
* it stores a list of block rows, each holding its own slice of content in its
|
|
19
|
-
* own differently-named field, with most of a real page's copy nested two
|
|
20
|
-
* levels down inside a `section` block's columns. Auditing those rows one at a
|
|
21
|
-
* time is what produced the finding this module exists to retire — "this page
|
|
22
|
-
* has no H1 heading" reported against a single paragraph, when the H1 was sitting
|
|
23
|
-
* in a `heading` block two rows above and the auditor was never shown it.
|
|
24
|
-
*
|
|
25
|
-
* So this walks the whole list in document order and merges everything into one
|
|
26
|
-
* document, which lets `auditSeo` answer the questions it was designed to
|
|
27
|
-
* answer — is there exactly one H1, is there enough copy, where does the
|
|
28
|
-
* keyphrase fall — about the page a visitor actually reads.
|
|
29
|
-
*
|
|
30
|
-
* Two properties are load-bearing and every change here has to preserve them:
|
|
31
|
-
*
|
|
32
|
-
* - **Nothing throws.** `content` is `Json` straight out of Postgres, and the
|
|
33
|
-
* editor hands us half-typed state on every keystroke, so a field can be
|
|
34
|
-
* null, a number, an array where an object was expected, or absent. Every
|
|
35
|
-
* read below tolerates that and skips rather than reporting. An unrecognised
|
|
36
|
-
* block — a custom block, whose content shape is whatever its author defined
|
|
37
|
-
* — is skipped for the same reason: guessing at arbitrary keys would put the
|
|
38
|
-
* author's internal configuration strings into the page's word count.
|
|
39
|
-
* - **It stays linear and allocation-light.** This runs behind the analysis
|
|
40
|
-
* panel's debounce, so it is re-run every time the author pauses typing. It
|
|
41
|
-
* makes one pass over the tree, reuses the image and link objects the HTML
|
|
42
|
-
* reader already built instead of copying them, and joins the text exactly
|
|
43
|
-
* once at the end.
|
|
44
|
-
*
|
|
45
|
-
* One known and accepted limitation: `buildSeoDocument` records where blocks
|
|
46
|
-
* divide the token stream so a multi-word keyphrase cannot be counted across a
|
|
47
|
-
* gap the reader can see, and that side table is keyed on the document object
|
|
48
|
-
* it built, so the merged document here does not carry one. The consequence is
|
|
49
|
-
* narrow — a phrase whose words happen to straddle the join between two blocks
|
|
50
|
-
* can be counted once too often — and it is the same fallback any hand-built
|
|
51
|
-
* document has always had. Correcting it needs a way to register boundaries for
|
|
52
|
-
* an assembled document, which is a change to the engine, not to this walker.
|
|
53
|
-
*/
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* How deep the walk will follow nested containers before giving up.
|
|
57
|
-
*
|
|
58
|
-
* Sections can hold sections, so the structure is genuinely recursive and has
|
|
59
|
-
* no schema-enforced ceiling. Eight levels is far past anything a human builds
|
|
60
|
-
* and cheap to enforce, and it means a corrupt row that somehow refers back
|
|
61
|
-
* into itself costs a bounded walk instead of a blown stack in the editor.
|
|
62
|
-
*/
|
|
63
|
-
const MAX_BLOCK_NESTING_DEPTH = 8;
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* The heading level used when a `heading` block's level is missing or corrupt.
|
|
67
|
-
*
|
|
68
|
-
* This mirrors `HeadingBlockRenderer`, which falls back to an H2 for anything
|
|
69
|
-
* outside 1-6. The audit has to grade the outline the visitor is served, and
|
|
70
|
-
* defaulting to 1 instead would invent an H1 that is nowhere on the page — and
|
|
71
|
-
* then report a second, entirely fictional H1 as a duplicate.
|
|
72
|
-
*/
|
|
73
|
-
const FALLBACK_HEADING_LEVEL: SeoHeadingLevel = 2;
|
|
74
|
-
|
|
75
|
-
/** The accumulating page document, before its parts are joined. */
|
|
76
|
-
interface PageDocumentDraft {
|
|
77
|
-
headings: SeoHeading[];
|
|
78
|
-
images: SeoImage[];
|
|
79
|
-
links: SeoLink[];
|
|
80
|
-
/**
|
|
81
|
-
* Each block's text, kept apart until the end and then joined with a single
|
|
82
|
-
* space. Concatenating without a separator would glue the last word of one
|
|
83
|
-
* block onto the first word of the next ("…our coffee" + "beans are…" reading
|
|
84
|
-
* as the single token "coffeebeans"), which corrupts the word count, the
|
|
85
|
-
* keyphrase density and the readability sample all at once.
|
|
86
|
-
*/
|
|
87
|
-
textParts: string[];
|
|
88
|
-
words: string[];
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function createDraft(): PageDocumentDraft {
|
|
92
|
-
return { headings: [], images: [], links: [], textParts: [], words: [] };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Narrows to a plain object, which is the only shape a block or content has. */
|
|
96
|
-
function readRecord(value: unknown): Record<string, unknown> | null {
|
|
97
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
98
|
-
? (value as Record<string, unknown>)
|
|
99
|
-
: null;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Reads a field that is supposed to hold display text.
|
|
104
|
-
*
|
|
105
|
-
* Whitespace is collapsed to single spaces to match `SeoDocument.text`, which
|
|
106
|
-
* the readability pass tokenises on the assumption that it already has been.
|
|
107
|
-
* Anything that is not a string reads as empty rather than being coerced,
|
|
108
|
-
* because `String(someObject)` would put "[object Object]" on the page.
|
|
109
|
-
*/
|
|
110
|
-
function readText(value: unknown): string {
|
|
111
|
-
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Clamps a stored heading level onto the six levels HTML actually has. */
|
|
115
|
-
function readHeadingLevel(value: unknown): SeoHeadingLevel {
|
|
116
|
-
const level = typeof value === 'number' ? Math.trunc(value) : Number.NaN;
|
|
117
|
-
|
|
118
|
-
return Number.isFinite(level) && level >= 1 && level <= 6
|
|
119
|
-
? (level as SeoHeadingLevel)
|
|
120
|
-
: FALLBACK_HEADING_LEVEL;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Adds a run of plain text, keeping the token stream in step with the text. */
|
|
124
|
-
function appendText(draft: PageDocumentDraft, text: string): void {
|
|
125
|
-
if (text === '') {
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
draft.textParts.push(text);
|
|
130
|
-
for (const word of tokenizeWords(text)) {
|
|
131
|
-
draft.words.push(word);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Folds a document the engine already built — one rich-text block's HTML or
|
|
137
|
-
* Tiptap JSON — into the page draft.
|
|
138
|
-
*
|
|
139
|
-
* Headings are renumbered as they land so `order` counts positions on the page
|
|
140
|
-
* rather than positions within whichever block happened to contain them; the
|
|
141
|
-
* audit uses that number to say where the H1 sits, and a per-block number would
|
|
142
|
-
* point the author at the wrong heading.
|
|
143
|
-
*/
|
|
144
|
-
function appendDocument(draft: PageDocumentDraft, document: SeoDocument): void {
|
|
145
|
-
for (const heading of document.headings) {
|
|
146
|
-
draft.headings.push({ level: heading.level, order: draft.headings.length, text: heading.text });
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
for (const image of document.images) {
|
|
150
|
-
draft.images.push(image);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
for (const link of document.links) {
|
|
154
|
-
draft.links.push(link);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (document.text !== '') {
|
|
158
|
-
draft.textParts.push(document.text);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// `words` is taken from the sub-document rather than re-tokenised from its
|
|
162
|
-
// text: the engine guarantees the two agree, and re-tokenising would double
|
|
163
|
-
// the work on the largest blocks on the page for an identical answer.
|
|
164
|
-
for (const word of document.words) {
|
|
165
|
-
draft.words.push(word);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** Adds one heading block, which is a standalone row rather than inline markup. */
|
|
170
|
-
function collectHeadingBlock(content: Record<string, unknown>, draft: PageDocumentDraft): void {
|
|
171
|
-
// Not run through an HTML stripper, unlike a rich-text block: the renderer
|
|
172
|
-
// prints `text_content` as a React child, so any markup in it is escaped and
|
|
173
|
-
// the visitor sees the angle brackets. Stripping here would analyse text the
|
|
174
|
-
// page never shows.
|
|
175
|
-
const text = readText(content['text_content']);
|
|
176
|
-
|
|
177
|
-
draft.headings.push({
|
|
178
|
-
level: readHeadingLevel(content['level']),
|
|
179
|
-
order: draft.headings.length,
|
|
180
|
-
text,
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
// A heading is also words on the page. Leaving it out of the text would make
|
|
184
|
-
// headings free of charge in the word count and invisible to the keyphrase
|
|
185
|
-
// density, even though they are the most heavily weighted copy on the page.
|
|
186
|
-
appendText(draft, text);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Adds one image block.
|
|
191
|
-
*
|
|
192
|
-
* The source is resolved the way `ImageBlockRenderer` resolves it — an external
|
|
193
|
-
* URL wins, otherwise the R2 object key — and a block with neither is not
|
|
194
|
-
* recorded as an image at all, because that block renders a "media not
|
|
195
|
-
* selected" placeholder. Reporting it would raise "an image is missing alt
|
|
196
|
-
* text" about something the visitor never sees as an image, which is precisely
|
|
197
|
-
* the class of false finding this module was written to remove.
|
|
198
|
-
*/
|
|
199
|
-
function collectImageBlock(content: Record<string, unknown>, draft: PageDocumentDraft): void {
|
|
200
|
-
const externalUrl = readText(content['external_url']);
|
|
201
|
-
const objectKey = readText(content['object_key']);
|
|
202
|
-
const source = externalUrl !== '' ? externalUrl : objectKey;
|
|
203
|
-
|
|
204
|
-
if (source === '') {
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
draft.images.push({ alt: readText(content['alt_text']), src: source });
|
|
209
|
-
|
|
210
|
-
// The caption is rendered in a <figcaption> under the image, so it is prose a
|
|
211
|
-
// reader and a crawler both see, and it belongs in the page's text.
|
|
212
|
-
appendText(draft, readText(content['caption']));
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** Adds one button block: a link the visitor can follow, with a visible label. */
|
|
216
|
-
function collectButtonBlock(content: Record<string, unknown>, draft: PageDocumentDraft): void {
|
|
217
|
-
const text = readText(content['text']);
|
|
218
|
-
const href = readText(content['url']);
|
|
219
|
-
|
|
220
|
-
if (href !== '') {
|
|
221
|
-
draft.links.push({ external: isExternalHref(href), href, text });
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
appendText(draft, text);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** Adds a testimonial's visible quote and attribution. */
|
|
228
|
-
function collectTestimonialBlock(
|
|
229
|
-
content: Record<string, unknown>,
|
|
230
|
-
draft: PageDocumentDraft
|
|
231
|
-
): void {
|
|
232
|
-
appendText(draft, readText(content['quote']));
|
|
233
|
-
appendText(draft, readText(content['author_name']));
|
|
234
|
-
appendText(draft, readText(content['author_title']));
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Walks `column_blocks`, an array of columns each holding an array of blocks.
|
|
239
|
-
*
|
|
240
|
-
* Column order then block order is the order the page reads in on a phone,
|
|
241
|
-
* where the columns stack, and it is the only total order that exists for this
|
|
242
|
-
* shape. Getting it wrong would not lose any content, but it would misreport
|
|
243
|
-
* which heading comes first and where in the copy the keyphrase falls.
|
|
244
|
-
*/
|
|
245
|
-
function collectColumnBlocks(value: unknown, draft: PageDocumentDraft, depth: number): void {
|
|
246
|
-
if (!Array.isArray(value)) {
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
for (const column of value) {
|
|
251
|
-
if (!Array.isArray(column)) {
|
|
252
|
-
continue;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
for (const block of column) {
|
|
256
|
-
collectBlock(block, draft, depth + 1);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Walks a section, which is where most of a real page's content lives.
|
|
263
|
-
*
|
|
264
|
-
* Its children are not rows in the blocks array — they are plain
|
|
265
|
-
* `{ block_type, content }` objects buried in `content.column_blocks` — so a
|
|
266
|
-
* walker that only looked at the top-level list would under-report almost every
|
|
267
|
-
* page ever built in this CMS.
|
|
268
|
-
*
|
|
269
|
-
* A section in slider mode renders its slides *instead of* its columns, and
|
|
270
|
-
* this mirrors that: walking both would count copy from a stale column set the
|
|
271
|
-
* visitor cannot reach, and inflate the word count with it.
|
|
272
|
-
*/
|
|
273
|
-
function collectSectionBlock(
|
|
274
|
-
content: Record<string, unknown>,
|
|
275
|
-
draft: PageDocumentDraft,
|
|
276
|
-
depth: number
|
|
277
|
-
): void {
|
|
278
|
-
const slides = content['slides'];
|
|
279
|
-
|
|
280
|
-
if (content['slider'] === true && Array.isArray(slides) && slides.length > 0) {
|
|
281
|
-
for (const slide of slides) {
|
|
282
|
-
const record = readRecord(slide);
|
|
283
|
-
if (record !== null) {
|
|
284
|
-
collectColumnBlocks(record['column_blocks'], draft, depth);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
collectColumnBlocks(content['column_blocks'], draft, depth);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/** Dispatches one block row onto the reader that knows where its text lives. */
|
|
295
|
-
function collectBlock(value: unknown, draft: PageDocumentDraft, depth: number): void {
|
|
296
|
-
if (depth > MAX_BLOCK_NESTING_DEPTH) {
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const block = readRecord(value);
|
|
301
|
-
if (block === null) {
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const blockType = typeof block['block_type'] === 'string' ? block['block_type'] : '';
|
|
306
|
-
const content = readRecord(block['content']);
|
|
307
|
-
if (content === null) {
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
switch (blockType) {
|
|
312
|
-
case 'text': {
|
|
313
|
-
// `html_content` holds either an HTML string or a JSON-stringified Tiptap
|
|
314
|
-
// document depending on when and where the block was authored.
|
|
315
|
-
// `buildSeoDocument` already distinguishes the two and reads both, so
|
|
316
|
-
// this delegates rather than growing a second parser that could disagree
|
|
317
|
-
// with the one the block-level panel uses. `text_content` is the shape
|
|
318
|
-
// some older rows still carry.
|
|
319
|
-
const source = content['html_content'] ?? content['text_content'];
|
|
320
|
-
appendDocument(draft, buildSeoDocument(source));
|
|
321
|
-
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
case 'heading':
|
|
326
|
-
collectHeadingBlock(content, draft);
|
|
327
|
-
|
|
328
|
-
return;
|
|
329
|
-
|
|
330
|
-
case 'image':
|
|
331
|
-
collectImageBlock(content, draft);
|
|
332
|
-
|
|
333
|
-
return;
|
|
334
|
-
|
|
335
|
-
case 'button':
|
|
336
|
-
collectButtonBlock(content, draft);
|
|
337
|
-
|
|
338
|
-
return;
|
|
339
|
-
|
|
340
|
-
case 'testimonial':
|
|
341
|
-
collectTestimonialBlock(content, draft);
|
|
342
|
-
|
|
343
|
-
return;
|
|
344
|
-
|
|
345
|
-
// The registry calls this `video_embed`; `video` is accepted alongside it
|
|
346
|
-
// because that is the name the block is known by in the UI and in prompts,
|
|
347
|
-
// and both carry the same optional `title`.
|
|
348
|
-
case 'video':
|
|
349
|
-
case 'video_embed':
|
|
350
|
-
appendText(draft, readText(content['title']));
|
|
351
|
-
|
|
352
|
-
return;
|
|
353
|
-
|
|
354
|
-
case 'section':
|
|
355
|
-
collectSectionBlock(content, draft, depth);
|
|
356
|
-
|
|
357
|
-
return;
|
|
358
|
-
|
|
359
|
-
// `hero` is not in the current registry, but rows created before sections
|
|
360
|
-
// absorbed it still exist in live databases and still render, so their copy
|
|
361
|
-
// still counts. They keep both shapes: slides and a plain column set.
|
|
362
|
-
case 'hero': {
|
|
363
|
-
const slides = content['slides'];
|
|
364
|
-
if (Array.isArray(slides)) {
|
|
365
|
-
for (const slide of slides) {
|
|
366
|
-
const record = readRecord(slide);
|
|
367
|
-
if (record !== null) {
|
|
368
|
-
collectColumnBlocks(record['column_blocks'], draft, depth);
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
collectColumnBlocks(content['column_blocks'], draft, depth);
|
|
374
|
-
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
default:
|
|
379
|
-
// Every other block type — posts grids, forms, the commerce blocks, and
|
|
380
|
-
// any custom block whose content is defined by whoever built it — renders
|
|
381
|
-
// from data this walker cannot read, so it contributes nothing rather
|
|
382
|
-
// than contributing a guess.
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Builds one `SeoDocument` from a page's or post's block list.
|
|
389
|
-
*
|
|
390
|
-
* `blocks` is deliberately typed `unknown`: it comes from form state, from a
|
|
391
|
-
* draft row, or straight from Supabase as `Json`, and pretending at the
|
|
392
|
-
* signature that it is already an array of well-formed rows would only push the
|
|
393
|
-
* validation somewhere that has less context to do it in.
|
|
394
|
-
*/
|
|
395
|
-
export function buildPageSeoDocument(blocks: unknown): SeoDocument {
|
|
396
|
-
if (!Array.isArray(blocks) || blocks.length === 0) {
|
|
397
|
-
return emptySeoDocument();
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
const draft = createDraft();
|
|
401
|
-
for (const block of blocks) {
|
|
402
|
-
collectBlock(block, draft, 0);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
return {
|
|
406
|
-
headings: draft.headings,
|
|
407
|
-
images: draft.images,
|
|
408
|
-
links: draft.links,
|
|
409
|
-
text: draft.textParts.join(' '),
|
|
410
|
-
words: draft.words,
|
|
411
|
-
};
|
|
412
|
-
}
|
|
1
|
+
export {
|
|
2
|
+
buildPageSeoDocument,
|
|
3
|
+
type BuildPageSeoDocumentOptions,
|
|
4
|
+
} from '@nextblock-cms/utils/seo';
|
|
@@ -346,7 +346,7 @@ function toCustomRule(group: CustomRuleGroup): RobotsRule {
|
|
|
346
346
|
const values = group.other[key];
|
|
347
347
|
other[key] = values.length === 1 ? values[0] : values;
|
|
348
348
|
}
|
|
349
|
-
rule.other = other;
|
|
349
|
+
(rule as any).other = other;
|
|
350
350
|
}
|
|
351
351
|
|
|
352
352
|
return rule;
|
|
@@ -476,9 +476,10 @@ export function renderRobotsMetadata(metadata: MetadataRoute.Robots): string {
|
|
|
476
476
|
content += `Crawl-delay: ${rule.crawlDelay}\n`;
|
|
477
477
|
}
|
|
478
478
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
479
|
+
const ruleOther = (rule as any).other;
|
|
480
|
+
if (ruleOther) {
|
|
481
|
+
for (const key of Object.keys(ruleOther)) {
|
|
482
|
+
const value = ruleOther[key];
|
|
482
483
|
if (value === null || value === undefined) {
|
|
483
484
|
continue;
|
|
484
485
|
}
|