create-nextblock 0.15.9 → 0.16.1

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.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -0
  3. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -0
  4. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +193 -1
  5. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +7 -1
  6. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +27 -0
  7. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -229
  8. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +171 -6
  9. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -245
  10. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +177 -2
  11. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +28 -0
  12. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -406
  13. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +33 -0
  14. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +618 -383
  15. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -0
  16. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -0
  17. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -0
  18. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -0
  19. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -0
  20. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -0
  21. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -0
  22. package/templates/nextblock-template/app/layout.tsx +1 -1
  23. package/templates/nextblock-template/app/robots.ts +123 -0
  24. package/templates/nextblock-template/app/sitemap.ts +1 -1
  25. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -0
  26. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -0
  27. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -0
  28. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -0
  29. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -0
  30. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -0
  31. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -0
  32. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -0
  33. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -0
  34. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -0
  35. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -0
  36. package/templates/nextblock-template/lib/seo/block-content.ts +123 -0
  37. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -0
  38. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -0
  39. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -0
  40. package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -0
  41. package/templates/nextblock-template/lib/seo/page-document.ts +412 -0
  42. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -0
  43. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -0
  44. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -0
  45. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -0
  46. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -0
  47. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -0
  48. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +10 -0
  49. package/templates/nextblock-template/next-env.d.ts +2 -2
  50. package/templates/nextblock-template/package.json +1 -1
  51. package/templates/nextblock-template/proxy.ts +240 -20
  52. package/templates/nextblock-template/app/robots.txt/route.ts +0 -32
@@ -15,7 +15,67 @@ import { useHotkeys } from '../../../../hooks/use-hotkeys';
15
15
  import { resolveMediaUrl } from '../../../../lib/media/resolveMediaUrl';
16
16
 
17
17
  type Media = Database['public']['Tables']['media']['Row'];
18
- import { FileText } from 'lucide-react';
18
+ import { FileText, Loader2, Sparkles } from 'lucide-react';
19
+ import { toast } from 'sonner';
20
+ import { useCortexAiActive } from '../../components/CortexAiActiveContext';
21
+ import { buildCortexAiRequestHeaders } from '../../../../lib/cortex-ai/sandbox-headers';
22
+ import {
23
+ buildAltTextContext,
24
+ toAbsoluteImageUrl,
25
+ UNRESOLVABLE_IMAGE_URL_MESSAGE,
26
+ } from '../../../../lib/cortex-ai/alt-text-request';
27
+ // The label the upload pipeline gives the untouched original file among a row's
28
+ // `variants`. It currently lives in the email module because email was the first consumer
29
+ // that needed the non-AVIF original; the constant itself is about the media pipeline, not
30
+ // about email, so importing it here is reuse rather than a layering violation — and it is
31
+ // far better than a second copy of the string drifting away from the pipeline that writes it.
32
+ import { ORIGINAL_UPLOAD_VARIANT_LABEL } from '../../../../lib/email/branding-format';
33
+
34
+ /** The `variants` JSONB rows the upload pipeline writes (camelCase keys). */
35
+ interface MediaVariantRecord {
36
+ fileType?: string | null;
37
+ objectKey?: string | null;
38
+ variantLabel?: string | null;
39
+ }
40
+
41
+ /**
42
+ * Choose which stored rendition of this media row to hand the vision model.
43
+ *
44
+ * `media.object_key` points at an AVIF derivative — that is what the pipeline produces and
45
+ * what the site renders, because AVIF is small. It is the wrong thing to send to a vision
46
+ * API: the mainstream image-understanding endpoints accept JPEG, PNG, GIF and WebP, and
47
+ * simply reject AVIF. So the mid-size derivatives (`medium_avif`, `large_avif`), which
48
+ * would otherwise be the ideal choice on download size, are unusable for this call.
49
+ *
50
+ * That leaves the `original_uploaded` variant: the untouched file the operator actually
51
+ * uploaded, in whatever format their camera or design tool produced. The tradeoff is real
52
+ * and worth naming — it can be several megabytes where a mid-size AVIF would have been a
53
+ * few dozen kilobytes, so this call is heavier than it looks. It is accepted here because
54
+ * a heavy call that works beats a cheap one that returns "unsupported image format", and
55
+ * because alt text is generated once per asset, by hand, not on a hot path.
56
+ *
57
+ * When no original was kept (the seeded default assets have no variants) we fall back to
58
+ * the preview URL the form already computed, and let the route decide.
59
+ */
60
+ function pickAltTextSourceObjectKey(media: Media): string | null {
61
+ // `variants` is typed as the generic `Json`, so the cast goes through `unknown`: the
62
+ // shape is a runtime contract with the upload pipeline, not something the generated
63
+ // Supabase types can express, and every field is re-checked below before it is used.
64
+ const variants = Array.isArray(media.variants)
65
+ ? (media.variants as unknown as MediaVariantRecord[])
66
+ : [];
67
+
68
+ const original = variants.find(
69
+ (variant) =>
70
+ variant &&
71
+ typeof variant === 'object' &&
72
+ variant.variantLabel === ORIGINAL_UPLOAD_VARIANT_LABEL &&
73
+ typeof variant.objectKey === 'string' &&
74
+ variant.objectKey.length > 0
75
+ );
76
+
77
+ return original?.objectKey ?? null;
78
+ }
19
79
 
20
80
  interface MediaEditFormProps {
21
81
  mediaItem: Media;
@@ -33,6 +93,12 @@ export default function MediaEditForm({ mediaItem, formAction }: MediaEditFormPr
33
93
  const [description, setDescription] = useState(mediaItem.description || "");
34
94
  const [formMessage, setFormMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
35
95
 
96
+ // Cortex AI is premium; hide the affordance entirely when the package is not activated
97
+ // rather than letting the operator click into a 403.
98
+ const isCortexAiActive = useCortexAiActive();
99
+ const [isGeneratingAltText, setIsGeneratingAltText] = useState(false);
100
+ const [altTextError, setAltTextError] = useState<string | null>(null);
101
+
36
102
  useEffect(() => {
37
103
  const successMessage = searchParams.get('success');
38
104
  const errorMessage = searchParams.get('error');
@@ -78,6 +144,86 @@ export default function MediaEditForm({ mediaItem, formAction }: MediaEditFormPr
78
144
  const formRef = React.useRef<HTMLFormElement>(null);
79
145
  useHotkeys('ctrl+s', () => formRef.current?.requestSubmit());
80
146
  const previewUrl = resolveMediaUrl(mediaItem.file_path || mediaItem.object_key);
147
+ const isImage = Boolean(mediaItem.file_type?.startsWith("image/"));
148
+
149
+ /**
150
+ * Draft this asset's alt text with Cortex AI.
151
+ *
152
+ * `media.description` IS the alt text in this schema — there is no `alt_text` column, and
153
+ * every consumer (the image block, the feature-image renderer, the media picker) already
154
+ * reads `description` as alt. So the generated string is written into the existing
155
+ * `description` state and nothing else changes: persistence still runs through the same
156
+ * `updateMediaItem(mediaId, { description })` submit path as a hand-typed value, and the
157
+ * operator still has to press Save. Generating is a draft, not a write.
158
+ *
159
+ * The URL handed to the route must be absolute http(s), because the route does not
160
+ * receive image bytes — it receives a URL the AI SDK then downloads server-side.
161
+ * `resolveMediaUrl()` returns a relative `/${objectKey}` whenever `NEXT_PUBLIC_R2_BASE_URL`
162
+ * is unset, which is the default for the native Supabase-storage backend, so on a stock
163
+ * install every asset would fail this call without the origin fix-up below.
164
+ */
165
+ const handleGenerateAltText = async () => {
166
+ if (!isImage || isGeneratingAltText) {
167
+ return;
168
+ }
169
+
170
+ const sourceObjectKey = pickAltTextSourceObjectKey(mediaItem);
171
+ const imageUrl = toAbsoluteImageUrl(
172
+ sourceObjectKey ? resolveMediaUrl(sourceObjectKey) : previewUrl
173
+ );
174
+
175
+ if (!imageUrl) {
176
+ setAltTextError(UNRESOLVABLE_IMAGE_URL_MESSAGE);
177
+ return;
178
+ }
179
+
180
+ setAltTextError(null);
181
+ setIsGeneratingAltText(true);
182
+
183
+ try {
184
+ // The filename is the only context this screen has, and it is often genuinely
185
+ // informative ("2026-harvest-crew.jpg"); passing it costs nothing and sometimes
186
+ // rescues a description that would otherwise be generically correct and useless.
187
+ const context = buildAltTextContext(
188
+ `Media library asset named “${mediaItem.file_name}”.`
189
+ );
190
+
191
+ const response = await fetch('/api/ai/seo/alt-text', {
192
+ // The route's body schema is a `z.strictObject` of optionals, so an absent field
193
+ // must be omitted rather than sent as null — a null would fail validation outright.
194
+ body: JSON.stringify({
195
+ ...(context ? { context } : {}),
196
+ imageUrl,
197
+ }),
198
+ headers: buildCortexAiRequestHeaders(),
199
+ method: 'POST',
200
+ });
201
+
202
+ const payload = (await response.json().catch(() => null)) as Record<string, unknown> | null;
203
+
204
+ if (!response.ok || !payload) {
205
+ const message =
206
+ payload && typeof payload['error'] === 'string'
207
+ ? (payload['error'] as string)
208
+ : 'Cortex AI could not describe this image.';
209
+ throw new Error(message);
210
+ }
211
+
212
+ const generated = typeof payload['altText'] === 'string' ? payload['altText'].trim() : '';
213
+ if (!generated) {
214
+ throw new Error('Cortex AI returned an empty description.');
215
+ }
216
+
217
+ setDescription(generated);
218
+ } catch (error) {
219
+ const message =
220
+ error instanceof Error ? error.message : 'Cortex AI could not describe this image.';
221
+ setAltTextError(message);
222
+ toast.error(message);
223
+ } finally {
224
+ setIsGeneratingAltText(false);
225
+ }
226
+ };
81
227
 
82
228
  return (
83
229
  <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
@@ -125,7 +271,29 @@ export default function MediaEditForm({ mediaItem, formAction }: MediaEditFormPr
125
271
  </div>
126
272
 
127
273
  <div>
128
- <Label htmlFor="description">Description (Alt Text for Images)</Label>
274
+ <div className="flex items-center justify-between gap-2">
275
+ <Label htmlFor="description">Description (Alt Text for Images)</Label>
276
+ {/* Only for images, and only on a premium install: a non-activated Cortex AI
277
+ must never render a button that cannot do anything. */}
278
+ {isCortexAiActive && isImage && (
279
+ <Button
280
+ type="button"
281
+ variant="outline"
282
+ size="sm"
283
+ className="h-7 gap-1.5 px-2 text-[11px]"
284
+ onClick={handleGenerateAltText}
285
+ disabled={isGeneratingAltText}
286
+ title="Describe this image with Cortex AI"
287
+ >
288
+ {isGeneratingAltText ? (
289
+ <Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
290
+ ) : (
291
+ <Sparkles aria-hidden="true" className="h-3.5 w-3.5 text-amber-500" />
292
+ )}
293
+ {isGeneratingAltText ? "Generating…" : "Generate with AI"}
294
+ </Button>
295
+ )}
296
+ </div>
129
297
  <Textarea
130
298
  id="description"
131
299
  name="description"
@@ -135,6 +303,13 @@ export default function MediaEditForm({ mediaItem, formAction }: MediaEditFormPr
135
303
  rows={4}
136
304
  placeholder="e.g., A vibrant sunset over a mountain range"
137
305
  />
306
+ {altTextError && <p className="text-xs text-red-500 mt-1">{altTextError}</p>}
307
+ {isCortexAiActive && isImage && (
308
+ <p className="text-xs text-muted-foreground mt-1">
309
+ Generated text lands in this field as a draft — review it, then press
310
+ “Update Media Info” to save.
311
+ </p>
312
+ )}
138
313
  </div>
139
314
 
140
315
  <div className="flex justify-end space-x-3 pt-4">
@@ -14,6 +14,8 @@ import CopyContentFromLanguage from "../../../components/CopyContentFromLanguage
14
14
  import RevisionHistoryButton from "../../../revisions/RevisionHistoryButton";
15
15
  import { UploadFolderProvider } from '../../../media/UploadFolderContext';
16
16
  import { CortexAiPageContextRegistrar } from "../../../components/CortexAiPageContext";
17
+ import { PageSeoAuditSection } from "../../../../../components/seo/PageSeoAuditSection";
18
+ import { PageSeoProvider } from "../../../../../lib/seo/page-audit-context";
17
19
  import type { Database } from "@nextblock-cms/db";
18
20
  import DraftStatusActions from "../../../components/DraftStatusActions";
19
21
 
@@ -76,6 +78,20 @@ export default function EditPageClient({
76
78
  translationGroupId: page.translation_group_id,
77
79
  }}
78
80
  />
81
+ {/*
82
+ The provider has to enclose both `PageForm` and `BlockEditorArea`,
83
+ because the page-level SEO audit is assembled from halves they each own:
84
+ the form publishes the meta title and description, the block editor
85
+ publishes the live block array, and neither knows the other exists. It is
86
+ seeded from the server-rendered row so the panel has a real document to
87
+ grade on first paint rather than an empty one that scores zero until the
88
+ block editor finishes mounting.
89
+ */}
90
+ <PageSeoProvider
91
+ initialBlocks={page.blocks}
92
+ initialMetaDescription={page.meta_description}
93
+ initialMetaTitle={page.meta_title}
94
+ >
79
95
  <div className="space-y-8 w-full mx-auto px-6">
80
96
  <div className="flex justify-between items-center flex-wrap gap-4 w-full">
81
97
  <div className="flex items-center gap-3">
@@ -151,6 +167,9 @@ export default function EditPageClient({
151
167
  <PageForm
152
168
  page={page}
153
169
  formAction={updatePageAction}
170
+ // Read-only: the form never writes blocks, it only lets Cortex AI read them so it
171
+ // can summarize the page when drafting meta title and description.
172
+ contentBlocks={page.blocks}
154
173
  actionButtonText="Update Page Metadata"
155
174
  isEditing={true}
156
175
  availableLanguagesProp={allSiteLanguages}
@@ -158,6 +177,14 @@ export default function EditPageClient({
158
177
  initialFeatureImageId={initialFeatureImageId}
159
178
  />
160
179
 
180
+ {/*
181
+ Sits between the metadata form and the blocks because that is what it
182
+ grades: everything above it and everything below it, together. It
183
+ collapses to a single summary row so the block editor keeps its place
184
+ on the screen.
185
+ */}
186
+ <PageSeoAuditSection />
187
+
161
188
  <Separator className="my-8" />
162
189
 
163
190
  <div className="w-full mx-auto px-6">
@@ -170,6 +197,7 @@ export default function EditPageClient({
170
197
  />
171
198
  </div>
172
199
  </div>
200
+ </PageSeoProvider>
173
201
  </UploadFolderProvider>
174
202
  );
175
203
  }