create-nextblock 0.15.10 → 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
|
@@ -3,27 +3,50 @@
|
|
|
3
3
|
|
|
4
4
|
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
5
5
|
import dynamic from 'next/dynamic';
|
|
6
|
-
import type { Extensions } from '@tiptap/core';
|
|
6
|
+
import type { Editor, Extensions } from '@tiptap/core';
|
|
7
|
+
import { PanelRightClose, PanelRightOpen } from 'lucide-react';
|
|
7
8
|
import MediaPickerDialog from '../../media/components/MediaPickerDialog';
|
|
8
|
-
import { Label } from '@nextblock-cms/ui';
|
|
9
|
+
import { Button, Label } from '@nextblock-cms/ui';
|
|
9
10
|
import { BlockEditorProps } from '../components/BlockEditorModal';
|
|
10
11
|
import { resolveMediaUrl } from '../../../../lib/media/resolveMediaUrl';
|
|
11
12
|
import { useCortexAiActive } from '../../components/CortexAiActiveContext';
|
|
13
|
+
import { SeoAuditPanel } from '../../../../components/seo/SeoAuditPanel';
|
|
14
|
+
import { usePageSeo } from '../../../../lib/seo/page-audit-context';
|
|
12
15
|
import {
|
|
13
16
|
createDynamicCustomBlockExtensions,
|
|
14
17
|
type DynamicCustomBlockEditorDefinition,
|
|
15
18
|
} from '../../../../lib/editor/dynamic-extensions';
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
/**
|
|
21
|
+
* A hand-maintained copy of the real `NotionEditorProps`.
|
|
22
|
+
*
|
|
23
|
+
* `next/dynamic` needs a concrete type argument at the call site, and the real
|
|
24
|
+
* interface lives behind a lazy `import()` that TypeScript will not unwrap for
|
|
25
|
+
* us here. That makes this a second, independent declaration of the same
|
|
26
|
+
* contract: a prop added to `libs/editor/src/lib/NotionEditor.tsx` and not added
|
|
27
|
+
* here is invisible to this file and fails to type-check the moment it is
|
|
28
|
+
* passed. Both `onEditorReady` and `sidePanel` below exist for that reason.
|
|
29
|
+
*/
|
|
18
30
|
type NotionEditorProps = {
|
|
19
31
|
content: string;
|
|
20
32
|
onChange: (html: string) => void;
|
|
33
|
+
onEditorReady?: (editor: Editor | null) => void;
|
|
21
34
|
openImagePicker?: () => Promise<{ src: string; alt?: string; width?: number | null; height?: number | null; blurDataURL?: string | null } | null>;
|
|
22
35
|
className?: string;
|
|
23
36
|
showAiPrompt?: boolean;
|
|
37
|
+
sidePanel?: React.ReactNode;
|
|
24
38
|
dynamicExtensions?: Extensions;
|
|
25
39
|
};
|
|
26
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Where the "is the SEO panel open?" choice is remembered.
|
|
43
|
+
*
|
|
44
|
+
* Per-browser rather than per-user or per-block: it is a workspace preference in
|
|
45
|
+
* the same family as a collapsed sidebar, not content, and round-tripping it
|
|
46
|
+
* through the database would mean a write on every toggle.
|
|
47
|
+
*/
|
|
48
|
+
const SEO_PANEL_STORAGE_KEY = 'nextblock_seo_audit_panel_open';
|
|
49
|
+
|
|
27
50
|
// Use the alias that resolves in your repo; if you mapped @nextblock-cms/editor, swap it here.
|
|
28
51
|
const NotionEditor = dynamic<NotionEditorProps>(
|
|
29
52
|
() => import('@nextblock-cms/editor').then((m) => m.NotionEditor),
|
|
@@ -44,7 +67,88 @@ export default function TextBlockEditor({
|
|
|
44
67
|
const [dynamicDefinitions, setDynamicDefinitions] = useState<DynamicCustomBlockEditorDefinition[]>([]);
|
|
45
68
|
const [dynamicDefinitionsLoaded, setDynamicDefinitionsLoaded] = useState(false);
|
|
46
69
|
const isCortexAiActive = useCortexAiActive();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The live Tiptap instance, published by `NotionEditor` through
|
|
73
|
+
* `onEditorReady`. This is what lets the SEO panel write a Cortex AI fix back
|
|
74
|
+
* into the document; without it the panel still grades the content but shows
|
|
75
|
+
* no fix buttons. Deliberately not `window.__nextblockEditor`, which is a
|
|
76
|
+
* single global slot and therefore wrong as soon as a second editor mounts.
|
|
77
|
+
*/
|
|
78
|
+
const [editor, setEditor] = useState<Editor | null>(null);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The focus keyphrase, preferring the page's copy when there is one.
|
|
82
|
+
*
|
|
83
|
+
* A keyphrase is a property of the page, not of a paragraph, so when this
|
|
84
|
+
* editor is opened from a page or post edit screen it reads and writes the
|
|
85
|
+
* shared `PageSeoProvider` value: type it once on the page panel and every
|
|
86
|
+
* block editor opened afterwards is already grading against it, and typing it
|
|
87
|
+
* in a block sends it back the other way. The local state below is the
|
|
88
|
+
* fallback for surfaces with no page-level panel — the product editor — where
|
|
89
|
+
* an ephemeral per-block keyphrase is still better than a dead field.
|
|
90
|
+
*/
|
|
91
|
+
const pageSeo = usePageSeo();
|
|
92
|
+
const [localSeoKeyword, setLocalSeoKeyword] = useState('');
|
|
93
|
+
const seoKeyword = pageSeo ? pageSeo.keyword : localSeoKeyword;
|
|
94
|
+
const handleSeoKeywordChange = useCallback(
|
|
95
|
+
(next: string) => {
|
|
96
|
+
if (pageSeo) {
|
|
97
|
+
pageSeo.setKeyword(next);
|
|
98
|
+
} else {
|
|
99
|
+
setLocalSeoKeyword(next);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
[pageSeo]
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Starts closed for the very first render on the server and on a cold client,
|
|
106
|
+
// then adopts the stored preference in the effect below. Reading storage in
|
|
107
|
+
// the initialiser instead would make the server's HTML and the client's first
|
|
108
|
+
// render disagree for anyone who had opened the panel before.
|
|
109
|
+
const [isSeoPanelOpen, setIsSeoPanelOpen] = useState(false);
|
|
110
|
+
|
|
47
111
|
const resolverRef = useRef<null | ((v: any) => void)>(null);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Adopt the remembered preference once, after mount.
|
|
115
|
+
*
|
|
116
|
+
* Every access is wrapped because `localStorage` is not merely empty in a
|
|
117
|
+
* private window or with site data blocked — the property access itself
|
|
118
|
+
* throws, and an uncaught throw here would take the whole block editor down
|
|
119
|
+
* over a panel toggle. Defaulting to open on a first visit is deliberate: the
|
|
120
|
+
* feature is invisible otherwise, and one click hides it for good afterwards.
|
|
121
|
+
*/
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
try {
|
|
124
|
+
const stored = window.localStorage.getItem(SEO_PANEL_STORAGE_KEY);
|
|
125
|
+
setIsSeoPanelOpen(stored === null ? true : stored === 'true');
|
|
126
|
+
} catch {
|
|
127
|
+
setIsSeoPanelOpen(true);
|
|
128
|
+
}
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
const toggleSeoPanel = useCallback(() => {
|
|
132
|
+
setIsSeoPanelOpen((previous) => {
|
|
133
|
+
const next = !previous;
|
|
134
|
+
try {
|
|
135
|
+
window.localStorage.setItem(SEO_PANEL_STORAGE_KEY, String(next));
|
|
136
|
+
} catch {
|
|
137
|
+
// The preference is a convenience; losing it is not worth an error.
|
|
138
|
+
}
|
|
139
|
+
return next;
|
|
140
|
+
});
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Wrapped rather than passing `setEditor` straight in. A state setter treats a
|
|
145
|
+
* function argument as an updater callback, and while a Tiptap `Editor` is an
|
|
146
|
+
* object today, routing it through an explicit assignment means this cannot
|
|
147
|
+
* quietly break if that ever stops being true.
|
|
148
|
+
*/
|
|
149
|
+
const handleEditorReady = useCallback((instance: Editor | null) => {
|
|
150
|
+
setEditor(instance);
|
|
151
|
+
}, []);
|
|
48
152
|
const openImagePicker = useCallback(() => {
|
|
49
153
|
setPickerOpen(true);
|
|
50
154
|
return new Promise<{ src: string; alt?: string; width?: number | null; height?: number | null; blurDataURL?: string | null } | null>((resolve) => {
|
|
@@ -103,26 +207,87 @@ export default function TextBlockEditor({
|
|
|
103
207
|
};
|
|
104
208
|
}, []);
|
|
105
209
|
|
|
210
|
+
// The one string the editor and the audit both read. Deriving it once keeps
|
|
211
|
+
// the panel from ever grading something other than what is on screen.
|
|
212
|
+
const htmlContent = content?.html_content ?? '';
|
|
213
|
+
|
|
106
214
|
return (
|
|
107
215
|
<div className="h-full flex flex-col">
|
|
108
216
|
<Label htmlFor={labelId} className="sr-only">
|
|
109
217
|
Text Content
|
|
110
218
|
</Label>
|
|
111
219
|
|
|
220
|
+
<div className="mb-2 flex items-center justify-end">
|
|
221
|
+
<Button
|
|
222
|
+
aria-pressed={isSeoPanelOpen}
|
|
223
|
+
className="h-7 text-xs"
|
|
224
|
+
disabled={!dynamicDefinitionsLoaded}
|
|
225
|
+
onClick={toggleSeoPanel}
|
|
226
|
+
size="sm"
|
|
227
|
+
type="button"
|
|
228
|
+
variant="outline"
|
|
229
|
+
>
|
|
230
|
+
{isSeoPanelOpen ? (
|
|
231
|
+
<PanelRightClose className="mr-1.5 h-4 w-4" />
|
|
232
|
+
) : (
|
|
233
|
+
<PanelRightOpen className="mr-1.5 h-4 w-4" />
|
|
234
|
+
)}
|
|
235
|
+
{isSeoPanelOpen ? 'Hide SEO analysis' : 'Show SEO analysis'}
|
|
236
|
+
</Button>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
112
239
|
<div id={labelId} role="group" aria-labelledby={labelId} className="flex-1 min-h-0 flex flex-col">
|
|
113
240
|
{dynamicDefinitionsLoaded ? (
|
|
114
241
|
<NotionEditor
|
|
115
242
|
key={dynamicDefinitions.map((definition) => definition.id).join('|') || 'static'}
|
|
116
|
-
content={
|
|
243
|
+
content={htmlContent}
|
|
117
244
|
dynamicExtensions={dynamicExtensions}
|
|
118
245
|
onChange={(html) => onChange({ html_content: html })}
|
|
246
|
+
onEditorReady={handleEditorReady}
|
|
119
247
|
openImagePicker={openImagePicker}
|
|
120
248
|
className={className}
|
|
121
249
|
showAiPrompt={isCortexAiActive}
|
|
250
|
+
/*
|
|
251
|
+
* Passing `undefined` rather than a hidden panel is what keeps the
|
|
252
|
+
* collapsed state honest: `NotionEditor` renders its original
|
|
253
|
+
* single-column tree when `sidePanel` is absent, so hiding the panel
|
|
254
|
+
* gives the prose the full width back instead of leaving an empty
|
|
255
|
+
* column behind it.
|
|
256
|
+
*/
|
|
257
|
+
sidePanel={
|
|
258
|
+
isSeoPanelOpen ? (
|
|
259
|
+
/*
|
|
260
|
+
* Block scope, and this is the fix for the complaint that
|
|
261
|
+
* started all of this: mounted here the panel can only see one
|
|
262
|
+
* block, so grading it as a page reported "no H1" and "fewer
|
|
263
|
+
* than 300 words" for a single paragraph — neither of which a
|
|
264
|
+
* block can be responsible for. The page-wide checks now live on
|
|
265
|
+
* the page editor's own panel; what stays here are the checks a
|
|
266
|
+
* block genuinely owns, plus the one thing this mount has and
|
|
267
|
+
* the page mount never will: a live editor instance, which is
|
|
268
|
+
* what makes "Fix with Cortex AI" able to write anything at all.
|
|
269
|
+
*/
|
|
270
|
+
<SeoAuditPanel
|
|
271
|
+
content={htmlContent}
|
|
272
|
+
editor={editor}
|
|
273
|
+
isCortexAiActive={isCortexAiActive}
|
|
274
|
+
keyword={seoKeyword}
|
|
275
|
+
onKeywordChange={handleSeoKeywordChange}
|
|
276
|
+
scope="block"
|
|
277
|
+
/>
|
|
278
|
+
) : undefined
|
|
279
|
+
}
|
|
122
280
|
/>
|
|
123
281
|
) : (
|
|
124
|
-
|
|
125
|
-
|
|
282
|
+
/*
|
|
283
|
+
* The editor is `ssr: false` and gated behind the custom-block
|
|
284
|
+
* definition fetch, so neither it nor the SEO panel exists on first
|
|
285
|
+
* paint. Saying which pieces are still coming makes the gap read as a
|
|
286
|
+
* load rather than as a panel that failed to appear.
|
|
287
|
+
*/
|
|
288
|
+
<div className="flex min-h-[500px] flex-1 flex-col items-center justify-center gap-1 rounded-lg border bg-background text-sm text-muted-foreground">
|
|
289
|
+
<span>Loading editor...</span>
|
|
290
|
+
<span className="text-xs">Custom blocks and the SEO analysis appear once it is ready.</span>
|
|
126
291
|
</div>
|
|
127
292
|
)}
|
|
128
293
|
|