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
|
@@ -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.
|
|
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;
|