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.
@@ -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
- import {
2
- buildSeoDocument,
3
- emptySeoDocument,
4
- isExternalHref,
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
- if (rule.other) {
480
- for (const key of Object.keys(rule.other)) {
481
- const value = rule.other[key];
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
  }