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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -0
- package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +193 -1
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +7 -1
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +27 -0
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -229
- package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +171 -6
- package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -245
- package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +177 -2
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +28 -0
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -406
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +33 -0
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +618 -383
- package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -0
- package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -0
- package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -0
- package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -0
- package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -0
- package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -0
- package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -0
- package/templates/nextblock-template/app/layout.tsx +1 -1
- package/templates/nextblock-template/app/robots.ts +123 -0
- package/templates/nextblock-template/app/sitemap.ts +1 -1
- package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -0
- package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -0
- package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -0
- package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -0
- package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -0
- package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -0
- package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -0
- package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -0
- package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -0
- package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -0
- package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -0
- package/templates/nextblock-template/lib/seo/block-content.ts +123 -0
- package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -0
- package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -0
- package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -0
- package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -0
- package/templates/nextblock-template/lib/seo/page-document.ts +412 -0
- package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -0
- package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -0
- package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -0
- package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -0
- package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -0
- package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +10 -0
- package/templates/nextblock-template/next-env.d.ts +2 -2
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +240 -20
- 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
|
-
<
|
|
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
|
}
|