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
@@ -0,0 +1,123 @@
1
+ import type { MetadataRoute } from 'next';
2
+ import { getSsgSupabaseClient } from '@nextblock-cms/db/server';
3
+ import { normalizeRobotsSettings, type RobotsSettings } from '@nextblock-cms/utils/seo';
4
+ import { buildRobotsMetadata } from '../lib/seo/robots-txt';
5
+ import { resolveSiteUrl, hasResolvedSiteUrl } from '../lib/site-url';
6
+
7
+ /**
8
+ * /robots.txt, generated from the operator's stored settings.
9
+ *
10
+ * This replaces the hand-rolled `app/robots.txt/route.ts` that used to live here,
11
+ * and the two CANNOT coexist: Next's `normalizeMetadataRoute` rewrites the page
12
+ * `/robots` to `/robots.txt` and then appends `/route`, which is character for
13
+ * character the app path the old handler occupied. Keeping both would be a
14
+ * duplicate-route collision, not a fallback — so the old file was deleted in the
15
+ * same change that added this one. The externally visible path is unchanged, which
16
+ * is why `isSetupAllowlisted()` in proxy.ts still allowlists the literal
17
+ * '/robots.txt' and needed no edit.
18
+ *
19
+ * The move from a route handler to the metadata route is what buys the caching
20
+ * below. Everything else about the response — the served path, the content type, the
21
+ * sandbox behaviour — is identical to what shipped before.
22
+ */
23
+
24
+ /**
25
+ * Cache the generated file and rebuild it at most once an hour, mirroring
26
+ * app/sitemap.ts. Crawlers get a fast, statically-served response while a change an
27
+ * operator saves in /cms/settings still takes effect without a redeploy.
28
+ *
29
+ * NextBlock does not enable Next.js Cache Components, so the route-segment
30
+ * `revalidate` config is the idiomatic caching control here. If `cacheComponents`
31
+ * were turned on, the equivalent would be a `'use cache'` body paired with
32
+ * `cacheLife('hours')` instead of this export.
33
+ */
34
+ export const revalidate = 3600;
35
+
36
+ /**
37
+ * The `site_settings` row the SEO screen writes, seeded by migration 30 and moved into
38
+ * the ADMIN-only write group by migration 31 — a WRITER could otherwise have PATCHed
39
+ * this row through PostgREST and de-indexed the whole site, since RLS, not the server
40
+ * action, is the boundary that actually holds.
41
+ */
42
+ const ROBOTS_SETTINGS_KEY = 'seo_robots_settings';
43
+
44
+ /**
45
+ * Reads the stored robots configuration, falling back to the permissive defaults on
46
+ * any failure whatsoever.
47
+ *
48
+ * The anon client is the right one here, and deliberately so: `seo_robots_settings`
49
+ * is a non-secret key and `site_settings`' read policy is already public for
50
+ * non-secret keys, so nothing on this path needs the service role. Handing a
51
+ * service-role client to a route that anonymous crawlers hit would be a needless
52
+ * escalation.
53
+ *
54
+ * That read has to stay anonymous, which is why migration 31 tightened only the
55
+ * INSERT/UPDATE/DELETE policies and left `site_settings_read_policy` alone. Adding
56
+ * this key to the read policy's sensitive array would not fail loudly — the query
57
+ * below would simply return no row, every crawler would be served the permissive
58
+ * defaults, and the operator's configuration would stop applying without a single
59
+ * error anywhere.
60
+ *
61
+ * The failure handling matters more than it looks. A crawler that receives a 500 for
62
+ * robots.txt may treat the entire site as disallowed until it next succeeds, so an
63
+ * unreachable database — or a `cms_redirects`-era install that has not yet run
64
+ * `npm run db:migrate`, where this settings row does not exist — must produce a
65
+ * valid, permissive file rather than an error. `normalizeRobotsSettings` handles the
66
+ * other half of that: whatever jsonb hands back, including null, a string or a
67
+ * half-migrated object, becomes a complete `RobotsSettings`.
68
+ */
69
+ async function loadRobotsSettings(): Promise<RobotsSettings> {
70
+ try {
71
+ const supabase = getSsgSupabaseClient();
72
+ const { data, error } = await supabase
73
+ .from('site_settings')
74
+ .select('value')
75
+ .eq('key', ROBOTS_SETTINGS_KEY)
76
+ .maybeSingle();
77
+
78
+ if (error) {
79
+ console.error('robots.txt: failed to read the robots settings; serving defaults.', error);
80
+ return normalizeRobotsSettings(undefined);
81
+ }
82
+
83
+ // A missing row is not an error — it is a site whose operator has never opened
84
+ // the SEO screen — and the defaults are exactly what that site should serve.
85
+ return normalizeRobotsSettings(data?.value);
86
+ } catch (error) {
87
+ console.error('robots.txt: robots settings lookup threw; serving defaults.', error);
88
+ return normalizeRobotsSettings(undefined);
89
+ }
90
+ }
91
+
92
+ export default async function robots(): Promise<MetadataRoute.Robots> {
93
+ const isSandbox = process.env.NEXT_PUBLIC_IS_SANDBOX === 'true';
94
+
95
+ // The sandbox answer ignores every stored setting, so there is nothing to read.
96
+ // Skipping the query keeps a disposable deployment's robots.txt working even when
97
+ // its database is asleep or being reset by the cron job. The reasoning for why the
98
+ // sandbox ALLOWS crawling rather than disallowing it lives on SANDBOX_USER_AGENT_RULE
99
+ // in lib/seo/robots-txt.ts — it is the counter-intuitive part of this feature and
100
+ // that is where it is written down in full.
101
+ if (isSandbox) {
102
+ return buildRobotsMetadata(normalizeRobotsSettings(undefined), {
103
+ isSandbox: true,
104
+ sitemapUrl: null,
105
+ });
106
+ }
107
+
108
+ // Explicit NEXT_PUBLIC_URL → Vercel production URL → local-dev fallback.
109
+ const siteUrl = resolveSiteUrl();
110
+
111
+ if (!hasResolvedSiteUrl()) {
112
+ console.warn(
113
+ 'Warning: no site URL is set for robots.txt (NEXT_PUBLIC_URL / Vercel production URL). Defaulting to http://localhost:3000. Set NEXT_PUBLIC_URL for production.'
114
+ );
115
+ }
116
+
117
+ const settings = await loadRobotsSettings();
118
+
119
+ return buildRobotsMetadata(settings, {
120
+ isSandbox: false,
121
+ sitemapUrl: `${siteUrl}/sitemap.xml`,
122
+ });
123
+ }
@@ -70,7 +70,7 @@ async function safe(
70
70
 
71
71
  export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
72
72
  // Disposable sandbox deployments should not advertise an indexable sitemap
73
- // (mirrors the legacy app/sitemap.xml route and app/robots.txt).
73
+ // (mirrors the legacy app/sitemap.xml route and app/robots.ts).
74
74
  if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
75
75
  return [];
76
76
  }
@@ -0,0 +1,137 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Loader2, Sparkles } from 'lucide-react';
5
+ import { toast } from 'sonner';
6
+ import { Button } from '@nextblock-cms/ui';
7
+
8
+ import { buildCortexAiRequestHeaders } from '../../lib/cortex-ai/sandbox-headers';
9
+
10
+ /**
11
+ * The four strings `/api/ai/seo/metadata` writes back.
12
+ *
13
+ * Note what is NOT here: an image. The Open Graph image on a page or post is derived from
14
+ * `feature_image_id`, and nothing in this flow changes that. The model writes copy; the
15
+ * operator picks the picture.
16
+ */
17
+ export interface GeneratedSeoMetadata {
18
+ metaDescription: string;
19
+ metaTitle: string;
20
+ ogDescription: string;
21
+ ogTitle: string;
22
+ }
23
+
24
+ interface GenerateMetaButtonProps {
25
+ /**
26
+ * Body prose the model reads in order to write about the page. The caller flattens its
27
+ * blocks; an empty string disables the button, because a model asked to summarize
28
+ * nothing invents something, and invented metadata is worse than none.
29
+ */
30
+ content: string;
31
+ /** Optional term the copy should be built around, when the caller knows one. */
32
+ focusKeyword?: string | null;
33
+ /** Language code (`en`, `fr`, …) so the copy comes back in the content's language. */
34
+ locale?: string | null;
35
+ onGenerated: (result: GeneratedSeoMetadata) => void;
36
+ /** Site title, when cheaply available, so the model can shape a title that suffixes well. */
37
+ siteTitle?: string | null;
38
+ /** The page's own title — the strongest single hint about what the page is. */
39
+ title?: string | null;
40
+ }
41
+
42
+ /**
43
+ * Guard against a mistake that would otherwise be silent: if the route ever answers 200
44
+ * with a partial body, writing `undefined` into a controlled input flips it to
45
+ * uncontrolled and React warns once, at runtime, in a place far from the cause. Coercing
46
+ * here means a malformed response degrades to empty strings the operator can see.
47
+ */
48
+ function readString(payload: Record<string, unknown>, key: string): string {
49
+ const value = payload[key];
50
+ return typeof value === 'string' ? value : '';
51
+ }
52
+
53
+ export default function GenerateMetaButton({
54
+ content,
55
+ focusKeyword,
56
+ locale,
57
+ onGenerated,
58
+ siteTitle,
59
+ title,
60
+ }: GenerateMetaButtonProps) {
61
+ const [isGenerating, setIsGenerating] = useState(false);
62
+
63
+ const trimmedContent = content.trim();
64
+ const canGenerate = trimmedContent.length > 0 && !isGenerating;
65
+
66
+ const handleGenerate = async () => {
67
+ if (!canGenerate) {
68
+ return;
69
+ }
70
+
71
+ setIsGenerating(true);
72
+
73
+ try {
74
+ const response = await fetch('/api/ai/seo/metadata', {
75
+ // Optional fields are omitted rather than sent as null: the contract types them
76
+ // as `string | undefined`, and a null would be a different, likely rejected, value.
77
+ body: JSON.stringify({
78
+ content: trimmedContent,
79
+ ...(focusKeyword?.trim() ? { focusKeyword: focusKeyword.trim() } : {}),
80
+ ...(locale?.trim() ? { locale: locale.trim() } : {}),
81
+ ...(siteTitle?.trim() ? { siteTitle: siteTitle.trim() } : {}),
82
+ ...(title?.trim() ? { title: title.trim() } : {}),
83
+ }),
84
+ headers: buildCortexAiRequestHeaders(),
85
+ method: 'POST',
86
+ });
87
+
88
+ const payload = (await response.json().catch(() => null)) as Record<string, unknown> | null;
89
+
90
+ if (!response.ok || !payload) {
91
+ // Every error shape from these routes is `{ error: string }` at 400/403/500, so a
92
+ // missing `error` means something upstream of the route failed (a proxy, a crash
93
+ // before the handler) and a generic message is the honest thing to show.
94
+ const message =
95
+ payload && typeof payload['error'] === 'string'
96
+ ? (payload['error'] as string)
97
+ : 'Cortex AI could not generate metadata.';
98
+ throw new Error(message);
99
+ }
100
+
101
+ onGenerated({
102
+ metaDescription: readString(payload, 'metaDescription'),
103
+ metaTitle: readString(payload, 'metaTitle'),
104
+ ogDescription: readString(payload, 'ogDescription'),
105
+ ogTitle: readString(payload, 'ogTitle'),
106
+ });
107
+ toast.success('Cortex AI drafted your SEO metadata.');
108
+ } catch (error) {
109
+ toast.error(error instanceof Error ? error.message : 'Cortex AI could not generate metadata.');
110
+ } finally {
111
+ setIsGenerating(false);
112
+ }
113
+ };
114
+
115
+ return (
116
+ <Button
117
+ className="h-7 gap-1.5 px-2 text-[11px]"
118
+ disabled={!canGenerate}
119
+ onClick={handleGenerate}
120
+ size="sm"
121
+ title={
122
+ trimmedContent
123
+ ? 'Draft the meta title and description from this page’s content'
124
+ : 'Add some content to this page first — Cortex AI needs something to summarize'
125
+ }
126
+ type="button"
127
+ variant="outline"
128
+ >
129
+ {isGenerating ? (
130
+ <Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
131
+ ) : (
132
+ <Sparkles aria-hidden="true" className="h-3.5 w-3.5 text-amber-500" />
133
+ )}
134
+ {isGenerating ? 'Generating…' : 'Generate Meta Data with AI'}
135
+ </Button>
136
+ );
137
+ }
@@ -0,0 +1,244 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * The page-level SEO audit, as it appears on the page and post edit screens.
5
+ *
6
+ * WHAT THIS IS FOR. `SeoAuditPanel` grades whatever document it is handed; this
7
+ * component is the thing that decides *which* document that is on an edit screen
8
+ * and how much room the result is allowed to take. It reads the shared
9
+ * `PageSeoProvider` state — the live block array published by `BlockEditorArea`
10
+ * and the meta title and description published by the page or post form — merges
11
+ * them with `buildPageSeoDocument`, and hands the result to the panel at page
12
+ * scope. That merge is the entire point of the feature: headings live both in
13
+ * standalone `heading` blocks and inside a text block's HTML, and section blocks
14
+ * carry more blocks in their columns, so "does this page have exactly one H1"
15
+ * simply cannot be answered from inside one block editor.
16
+ *
17
+ * WHY IT IS COLLAPSED BY DEFAULT. The block editor is the reason anyone opens
18
+ * this screen. A full analysis panel expanded above it would push the first
19
+ * block below the fold on a laptop every time, which is a tax paid on every
20
+ * edit for a report most sessions never read. Collapsed, the section is a single
21
+ * row: the score, the number of findings, and a button. That row is enough to
22
+ * decide whether the report is worth opening, which is all a summary owes you.
23
+ *
24
+ * WHY THE PANEL STAYS MOUNTED WHILE COLLAPSED. The summary row shows a live
25
+ * score, and the only honest way to have one is to let the panel keep computing
26
+ * it — so the collapsed state hides the detail with `hidden` rather than
27
+ * unmounting the panel, and the panel reports each result back through
28
+ * `onAuditChange`. Unmounting instead would either freeze the summary at its
29
+ * last value or force this component to run a second, competing audit that could
30
+ * disagree with the one behind the toggle.
31
+ *
32
+ * WHY THE DOCUMENT IS REBUILT ON A DELAY. `blocks` is a new array on every
33
+ * keystroke in any block editor, and rebuilding the page document means walking
34
+ * every block and parsing every rich-text body. Doing that during the render of
35
+ * each keystroke would be pure waste, since only the last one is ever graded. The
36
+ * rebuild is therefore deferred to the same trailing pause the panel already
37
+ * waits for, which does mean the page score settles roughly two debounce
38
+ * intervals after typing stops — a deliberate trade of latency nobody is watching
39
+ * for work nobody needs.
40
+ *
41
+ * Rendering nothing without a provider is intentional, not defensive padding:
42
+ * the same edit-screen components are reachable from the product editor, which
43
+ * has no page-level audit, and `usePageSeo()` returning `null` is the supported
44
+ * way that surface says so.
45
+ */
46
+
47
+ import * as React from 'react';
48
+
49
+ import { Button } from '@nextblock-cms/ui';
50
+ import { cn } from '@nextblock-cms/utils';
51
+ import type { SeoAuditResult, SeoScoreBand } from '@nextblock-cms/utils/seo';
52
+ import { ChevronDown, ChevronUp, Gauge } from 'lucide-react';
53
+
54
+ import { useCortexAiActive } from '../../app/cms/components/CortexAiActiveContext';
55
+ import { usePageSeo } from '../../lib/seo/page-audit-context';
56
+ import { buildPageSeoDocument } from '../../lib/seo/page-document';
57
+ import { SEO_LIVE_AUDIT_DEBOUNCE_MS, SeoAuditPanel } from './SeoAuditPanel';
58
+
59
+ /**
60
+ * Where the open/closed choice is remembered.
61
+ *
62
+ * Per-browser like the block editor's own panel toggle, and for the same reason:
63
+ * it is a workspace preference in the family of a collapsed sidebar, not content,
64
+ * and persisting it server-side would mean a write on every click.
65
+ */
66
+ const PAGE_SEO_PANEL_STORAGE_KEY = 'nextblock_page_seo_audit_open';
67
+
68
+ /**
69
+ * Badge colours for the collapsed summary, following the CMS convention: emerald
70
+ * for fine, amber for a caution, red for a failure. `excellent` and `good` share
71
+ * emerald because the distinction between them is carried by the number itself.
72
+ */
73
+ const BAND_BADGE_STYLE: Record<SeoScoreBand, string> = {
74
+ excellent: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
75
+ fair: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
76
+ good: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
77
+ poor: 'bg-red-500/15 text-red-700 dark:text-red-400',
78
+ };
79
+
80
+ export interface PageSeoAuditSectionProps {
81
+ className?: string;
82
+ }
83
+
84
+ export function PageSeoAuditSection({ className }: PageSeoAuditSectionProps) {
85
+ const pageSeo = usePageSeo();
86
+ const detailsId = React.useId();
87
+ /**
88
+ * Forwarded so the panel can explain where the one-click fixes went.
89
+ *
90
+ * There is no Fix button at page scope — a page has no single editor to write
91
+ * a rewrite into — and the panel only says so when Cortex AI is actually
92
+ * activated. Without this flag an activated install would show nothing at all
93
+ * where a Fix button sits at block scope, which reads as a bug rather than as
94
+ * a deliberate absence.
95
+ */
96
+ const isCortexAiActive = useCortexAiActive();
97
+
98
+ // Read through optional chaining so every hook below runs unconditionally: the
99
+ // "no provider" case is handled by returning null *after* the hooks, never by
100
+ // skipping them, which would break the rules of hooks the moment a provider
101
+ // appeared or disappeared above this component.
102
+ const liveBlocks = pageSeo?.snapshot.blocks;
103
+
104
+ const [audit, setAudit] = React.useState<SeoAuditResult | null>(null);
105
+ const [isOpen, setIsOpen] = React.useState(false);
106
+ const [settledBlocks, setSettledBlocks] = React.useState<unknown[]>(() => liveBlocks ?? []);
107
+
108
+ /**
109
+ * Adopt the remembered preference once, after mount.
110
+ *
111
+ * Every access is wrapped because `localStorage` does not merely come back
112
+ * empty in a private window or with site data blocked — the property access
113
+ * itself throws, and an uncaught throw here would take the whole edit screen
114
+ * down over a panel toggle. A first visit stays collapsed, which is the state
115
+ * that keeps the block editor where the author expects to find it.
116
+ */
117
+ React.useEffect(() => {
118
+ try {
119
+ setIsOpen(window.localStorage.getItem(PAGE_SEO_PANEL_STORAGE_KEY) === 'true');
120
+ } catch {
121
+ setIsOpen(false);
122
+ }
123
+ }, []);
124
+
125
+ const toggleOpen = React.useCallback(() => {
126
+ setIsOpen((previous) => {
127
+ const next = !previous;
128
+ try {
129
+ window.localStorage.setItem(PAGE_SEO_PANEL_STORAGE_KEY, String(next));
130
+ } catch {
131
+ // The preference is a convenience; losing it is not worth an error.
132
+ }
133
+ return next;
134
+ });
135
+ }, []);
136
+
137
+ // The deferred rebuild described in the module docblock. `liveBlocks` changes
138
+ // identity on every keystroke anywhere in the page, so each change restarts
139
+ // this timer and only the array that survives a pause is ever walked.
140
+ React.useEffect(() => {
141
+ if (liveBlocks === undefined) {
142
+ return;
143
+ }
144
+
145
+ const timeoutId = window.setTimeout(() => {
146
+ setSettledBlocks(liveBlocks);
147
+ }, SEO_LIVE_AUDIT_DEBOUNCE_MS);
148
+
149
+ return () => window.clearTimeout(timeoutId);
150
+ }, [liveBlocks]);
151
+
152
+ // Memoised because the document's identity is one of the panel's debounce
153
+ // dependencies: a fresh object on every render would reset the panel's timer
154
+ // forever and no score would ever appear.
155
+ const pageDocument = React.useMemo(() => buildPageSeoDocument(settledBlocks), [settledBlocks]);
156
+
157
+ const handleAuditChange = React.useCallback((next: SeoAuditResult | null) => {
158
+ setAudit(next);
159
+ }, []);
160
+
161
+ if (!pageSeo) {
162
+ return null;
163
+ }
164
+
165
+ const issueCount = audit?.issues.length ?? 0;
166
+ const errorCount = audit?.issues.filter((issue) => issue.severity === 'error').length ?? 0;
167
+ const summary = !audit
168
+ ? 'Reading every block on this page…'
169
+ : issueCount === 0
170
+ ? 'No issues found across this page.'
171
+ : `${issueCount} ${issueCount === 1 ? 'finding' : 'findings'} across this page` +
172
+ (errorCount > 0 ? `, ${errorCount} of them ${errorCount === 1 ? 'an error' : 'errors'}.` : '.');
173
+
174
+ return (
175
+ <section className={cn('rounded-lg border bg-background', className)}>
176
+ <div className="flex flex-wrap items-center justify-between gap-3 p-3">
177
+ <div className="flex min-w-0 items-center gap-3">
178
+ <Gauge aria-hidden="true" className="h-5 w-5 shrink-0 text-muted-foreground" />
179
+ <div className="min-w-0">
180
+ <h2 className="text-sm font-semibold mt-0">Page SEO analysis</h2>
181
+ <p className="text-xs text-muted-foreground">{summary}</p>
182
+ </div>
183
+ </div>
184
+
185
+ <div className="flex shrink-0 items-center gap-3">
186
+ {audit && (
187
+ /* The band's colour repeats what the number already says, so it is
188
+ hidden from assistive technology and spelled out in the button's
189
+ accessible name instead of being announced twice. */
190
+ <span
191
+ aria-hidden="true"
192
+ className={cn(
193
+ 'rounded-full px-2.5 py-1 text-xs font-semibold tabular-nums',
194
+ BAND_BADGE_STYLE[audit.scoreBand]
195
+ )}
196
+ >
197
+ {audit.score}/100
198
+ </span>
199
+ )}
200
+ <Button
201
+ aria-controls={detailsId}
202
+ aria-expanded={isOpen}
203
+ className="h-8 text-xs"
204
+ onClick={toggleOpen}
205
+ size="sm"
206
+ type="button"
207
+ variant="outline"
208
+ >
209
+ {isOpen ? (
210
+ <ChevronUp aria-hidden="true" className="mr-1.5 h-4 w-4" />
211
+ ) : (
212
+ <ChevronDown aria-hidden="true" className="mr-1.5 h-4 w-4" />
213
+ )}
214
+ {isOpen ? 'Hide SEO analysis' : 'Show SEO analysis'}
215
+ <span className="sr-only">
216
+ {audit ? ` — currently scoring ${audit.score} out of 100.` : ''}
217
+ </span>
218
+ </Button>
219
+ </div>
220
+ </div>
221
+
222
+ {/*
223
+ `hidden` rather than a conditional render: the panel has to stay mounted
224
+ for the summary above to keep updating. Tailwind's `hidden` is
225
+ `display: none`, so a collapsed section costs no layout and the block
226
+ editor below it does not move.
227
+ */}
228
+ <div className={cn('border-t', !isOpen && 'hidden')} id={detailsId}>
229
+ <SeoAuditPanel
230
+ document={pageDocument}
231
+ isCortexAiActive={isCortexAiActive}
232
+ keyword={pageSeo.keyword}
233
+ metaDescription={pageSeo.snapshot.metaDescription}
234
+ metaTitle={pageSeo.snapshot.metaTitle}
235
+ onAuditChange={handleAuditChange}
236
+ onKeywordChange={pageSeo.setKeyword}
237
+ scope="page"
238
+ />
239
+ </div>
240
+ </section>
241
+ );
242
+ }
243
+
244
+ export default PageSeoAuditSection;