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
@@ -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
- // Props expected by NotionEditor
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={content?.html_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
- <div className="flex min-h-[500px] flex-1 items-center justify-center rounded-lg border bg-background text-sm text-muted-foreground">
125
- Loading editor...
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