create-nextblock 0.14.6 → 0.15.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.
@@ -0,0 +1,500 @@
1
+ 'use client';
2
+
3
+ import React, { useMemo, useState, useTransition } from 'react';
4
+ import { Button, Input, Label } from '@nextblock-cms/ui';
5
+ import { reviewScriptCode, type ScriptReview } from '@nextblock-cms/utils/script-safety';
6
+ import { toast } from 'sonner';
7
+
8
+ import {
9
+ SITE_SCRIPT_LOAD_STRATEGIES,
10
+ SITE_SCRIPT_PLACEMENTS,
11
+ type SiteScript,
12
+ } from '../../../../../lib/site-scripts/types';
13
+ import {
14
+ describeSiteScriptRevision,
15
+ type SiteScriptRevision,
16
+ } from '../../../../../lib/site-scripts/revisions';
17
+ import {
18
+ createSiteScript,
19
+ deleteSiteScript,
20
+ revertSiteScript,
21
+ setSiteScriptActive,
22
+ updateSiteScript,
23
+ type SiteScriptInput,
24
+ } from '../actions';
25
+
26
+ const REVISION_LABELS: Record<string, string> = {
27
+ create: 'Created',
28
+ delete: 'Deleted',
29
+ revert: 'Restored',
30
+ update: 'Edited',
31
+ };
32
+
33
+ const PLACEMENT_LABELS: Record<string, string> = {
34
+ body_end: 'End of <body> — after the markup (recommended)',
35
+ body_start: 'Start of <body>',
36
+ head: '<head> — before first paint (blocking)',
37
+ };
38
+
39
+ const EMPTY_DRAFT: SiteScriptInput = {
40
+ code: '',
41
+ description: '',
42
+ is_active: false,
43
+ load_strategy: 'default',
44
+ name: '',
45
+ placement: 'body_end',
46
+ sort_order: 0,
47
+ src: '',
48
+ };
49
+
50
+ const textareaClass =
51
+ 'w-full flex min-h-[220px] rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
52
+ const selectClass =
53
+ 'w-full h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
54
+
55
+ const LEVEL_STYLES: Record<string, string> = {
56
+ info: 'bg-muted text-muted-foreground',
57
+ notice: 'bg-amber-500/10 text-amber-700 dark:text-amber-400',
58
+ warning: 'bg-destructive/10 text-destructive',
59
+ };
60
+
61
+ /**
62
+ * What the code can actually reach, scanned from the source.
63
+ *
64
+ * Shown wherever a script is read or edited so the reviewer judges the code rather
65
+ * than its description — the description may have been written by an AI agent that
66
+ * read a hostile page.
67
+ */
68
+ function SafetyReview({ review }: { review: ScriptReview }) {
69
+ if (review.clean) {
70
+ return (
71
+ <p className="text-xs text-muted-foreground">
72
+ No notable capabilities detected. {review.disclaimer}
73
+ </p>
74
+ );
75
+ }
76
+
77
+ return (
78
+ <div className="space-y-2">
79
+ <div className="flex flex-wrap gap-1.5">
80
+ {review.capabilities.map((capability) => (
81
+ <span
82
+ key={capability.id}
83
+ title={capability.label}
84
+ className={`rounded-full px-2 py-0.5 text-xs ${LEVEL_STYLES[capability.level] ?? ''}`}
85
+ >
86
+ {capability.id}
87
+ </span>
88
+ ))}
89
+ </div>
90
+
91
+ {review.capabilities
92
+ .filter((capability) => capability.level === 'warning')
93
+ .map((capability) => (
94
+ <p key={capability.id} className="text-xs text-destructive">
95
+ {capability.label}
96
+ </p>
97
+ ))}
98
+
99
+ {review.externalHosts.length > 0 && (
100
+ <p className="text-xs text-muted-foreground">
101
+ Contacts: <span className="font-mono">{review.externalHosts.join(', ')}</span>
102
+ </p>
103
+ )}
104
+
105
+ <p className="text-xs text-muted-foreground">{review.disclaimer}</p>
106
+ </div>
107
+ );
108
+ }
109
+
110
+ function ScriptEditor({
111
+ draft,
112
+ onCancel,
113
+ onChange,
114
+ onSave,
115
+ saving,
116
+ title,
117
+ }: {
118
+ draft: SiteScriptInput;
119
+ onCancel: () => void;
120
+ onChange: (next: SiteScriptInput) => void;
121
+ onSave: () => void;
122
+ saving: boolean;
123
+ title: string;
124
+ }) {
125
+ const set = (patch: Partial<SiteScriptInput>) => onChange({ ...draft, ...patch });
126
+ // Recomputed as the author types, so the consequences are visible before saving.
127
+ const review = useMemo(
128
+ () => reviewScriptCode({ code: draft.code, src: draft.src }),
129
+ [draft.code, draft.src]
130
+ );
131
+
132
+ return (
133
+ <div className="space-y-4 rounded-lg border bg-muted/30 p-4">
134
+ <h3 className="text-sm font-semibold">{title}</h3>
135
+
136
+ <div className="grid gap-4 sm:grid-cols-2">
137
+ <div className="space-y-2">
138
+ <Label htmlFor="script-name">Name</Label>
139
+ <Input
140
+ id="script-name"
141
+ value={draft.name}
142
+ onChange={(e) => set({ name: e.target.value })}
143
+ placeholder="Scroll reveal animations"
144
+ />
145
+ </div>
146
+ <div className="space-y-2">
147
+ <Label htmlFor="script-placement">Placement</Label>
148
+ <select
149
+ id="script-placement"
150
+ className={selectClass}
151
+ value={draft.placement}
152
+ onChange={(e) => set({ placement: e.target.value })}
153
+ >
154
+ {SITE_SCRIPT_PLACEMENTS.map((placement) => (
155
+ <option key={placement} value={placement}>
156
+ {PLACEMENT_LABELS[placement] ?? placement}
157
+ </option>
158
+ ))}
159
+ </select>
160
+ </div>
161
+ </div>
162
+
163
+ <div className="space-y-2">
164
+ <Label htmlFor="script-description">Description</Label>
165
+ <Input
166
+ id="script-description"
167
+ value={draft.description ?? ''}
168
+ onChange={(e) => set({ description: e.target.value })}
169
+ placeholder="What this script does, and which pages rely on it"
170
+ />
171
+ </div>
172
+
173
+ <div className="space-y-2">
174
+ <Label htmlFor="script-code">JavaScript</Label>
175
+ <textarea
176
+ id="script-code"
177
+ className={textareaClass}
178
+ value={draft.code ?? ''}
179
+ onChange={(e) => set({ code: e.target.value })}
180
+ placeholder={"document.querySelectorAll('.nb-reveal').forEach((el) => {\n // ...\n});"}
181
+ />
182
+ <p className="text-xs text-muted-foreground">
183
+ Written without the surrounding &lt;script&gt; tag — NextBlock adds it, along with the
184
+ page&apos;s CSP nonce.
185
+ </p>
186
+ <p className="text-xs text-muted-foreground">
187
+ Pages are React-hydrated. Do <strong>not</strong> change the text, classes, or attributes
188
+ of existing markup — React reconciles afterwards and reverts your change (a counter
189
+ animates, then snaps back). Waiting for the <code>load</code> event is not enough;
190
+ hydration can still be running. Animate with the Web Animations API instead, which writes
191
+ no attributes:
192
+ </p>
193
+ <pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs">
194
+ <code>{`el.animate(
195
+ [{ opacity: 0 }, { opacity: 1 }],
196
+ { duration: 600, fill: 'both' }
197
+ );`}</code>
198
+ </pre>
199
+ <p className="text-xs text-muted-foreground">
200
+ Appending your own new elements is always safe — React does not own those. So is anything
201
+ you can express in CSS.
202
+ </p>
203
+ </div>
204
+
205
+ <div className="grid gap-4 sm:grid-cols-2">
206
+ <div className="space-y-2">
207
+ <Label htmlFor="script-src">External URL (optional)</Label>
208
+ <Input
209
+ id="script-src"
210
+ value={draft.src ?? ''}
211
+ onChange={(e) => set({ src: e.target.value })}
212
+ placeholder="https://example.com/widget.js"
213
+ />
214
+ <p className="text-xs text-muted-foreground">
215
+ When set, this file is loaded instead of the JavaScript above. Must be https.
216
+ </p>
217
+ </div>
218
+ <div className="space-y-2">
219
+ <Label htmlFor="script-strategy">Loading (external only)</Label>
220
+ <select
221
+ id="script-strategy"
222
+ className={selectClass}
223
+ value={draft.load_strategy}
224
+ onChange={(e) => set({ load_strategy: e.target.value })}
225
+ >
226
+ {SITE_SCRIPT_LOAD_STRATEGIES.map((strategy) => (
227
+ <option key={strategy} value={strategy}>
228
+ {strategy}
229
+ </option>
230
+ ))}
231
+ </select>
232
+ </div>
233
+ </div>
234
+
235
+ <label className="flex items-center gap-2 text-sm">
236
+ <input
237
+ type="checkbox"
238
+ checked={Boolean(draft.is_active)}
239
+ onChange={(e) => set({ is_active: e.target.checked })}
240
+ />
241
+ Enabled — run this on the public site
242
+ </label>
243
+
244
+ <div className="rounded-md border bg-background p-3">
245
+ <p className="mb-2 text-xs font-medium">What this code can reach</p>
246
+ <SafetyReview review={review} />
247
+ </div>
248
+
249
+ <div className="flex gap-2">
250
+ <Button onClick={onSave} disabled={saving}>
251
+ {saving ? 'Saving…' : 'Save script'}
252
+ </Button>
253
+ <Button variant="outline" onClick={onCancel} disabled={saving}>
254
+ Cancel
255
+ </Button>
256
+ </div>
257
+ </div>
258
+ );
259
+ }
260
+
261
+ function RevisionHistory({
262
+ onRestore,
263
+ pending,
264
+ revisions,
265
+ }: {
266
+ onRestore: (revisionId: string, label: string) => void;
267
+ pending: boolean;
268
+ revisions: SiteScriptRevision[];
269
+ }) {
270
+ const [open, setOpen] = useState(false);
271
+
272
+ return (
273
+ <div className="rounded-lg border">
274
+ <button
275
+ type="button"
276
+ className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium"
277
+ onClick={() => setOpen((value) => !value)}
278
+ >
279
+ <span>History &amp; audit log</span>
280
+ <span className="text-xs text-muted-foreground">
281
+ {revisions.length} {revisions.length === 1 ? 'entry' : 'entries'} {open ? '▲' : '▼'}
282
+ </span>
283
+ </button>
284
+
285
+ {open && (
286
+ <div className="border-t">
287
+ {revisions.length === 0 ? (
288
+ <p className="px-4 py-3 text-sm text-muted-foreground">
289
+ Nothing recorded yet. Every change to a site script is logged here.
290
+ </p>
291
+ ) : (
292
+ <ul className="divide-y">
293
+ {revisions.map((revision) => (
294
+ <li key={revision.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
295
+ <div className="min-w-0">
296
+ <div className="flex flex-wrap items-center gap-2 text-sm">
297
+ <span className="font-medium">
298
+ {REVISION_LABELS[revision.revision_type] ?? revision.revision_type}
299
+ </span>
300
+ <span className="truncate">{revision.script_name}</span>
301
+ <span
302
+ className={`rounded-full px-2 py-0.5 text-xs ${
303
+ revision.source === 'mcp'
304
+ ? 'bg-primary/10 text-primary'
305
+ : 'bg-muted text-muted-foreground'
306
+ }`}
307
+ title={
308
+ revision.source === 'mcp'
309
+ ? 'Made by an AI client through an MCP token'
310
+ : 'Made in the CMS dashboard'
311
+ }
312
+ >
313
+ {revision.source === 'mcp' ? 'MCP' : 'Dashboard'}
314
+ </span>
315
+ </div>
316
+ <p className="text-xs text-muted-foreground">
317
+ {new Date(revision.created_at).toLocaleString()}
318
+ {' — '}
319
+ {describeSiteScriptRevision(revision)}
320
+ </p>
321
+ </div>
322
+
323
+ <Button
324
+ variant="outline"
325
+ size="sm"
326
+ disabled={pending}
327
+ onClick={() =>
328
+ onRestore(
329
+ revision.id,
330
+ `${revision.script_name} (${new Date(revision.created_at).toLocaleString()})`
331
+ )
332
+ }
333
+ >
334
+ Restore this version
335
+ </Button>
336
+ </li>
337
+ ))}
338
+ </ul>
339
+ )}
340
+ </div>
341
+ )}
342
+ </div>
343
+ );
344
+ }
345
+
346
+ export default function SiteScriptManager({
347
+ initialRevisions,
348
+ initialScripts,
349
+ }: {
350
+ initialRevisions: SiteScriptRevision[];
351
+ initialScripts: SiteScript[];
352
+ }) {
353
+ const [isPending, startTransition] = useTransition();
354
+ const [creating, setCreating] = useState(false);
355
+ const [editingId, setEditingId] = useState<string | null>(null);
356
+ const [draft, setDraft] = useState<SiteScriptInput>(EMPTY_DRAFT);
357
+
358
+ const run = (action: () => Promise<{ ok: boolean; message?: string; error?: string }>) => {
359
+ startTransition(async () => {
360
+ const result = await action();
361
+ if (result.ok) {
362
+ toast.success(result.message ?? 'Saved.');
363
+ setCreating(false);
364
+ setEditingId(null);
365
+ setDraft(EMPTY_DRAFT);
366
+ } else {
367
+ toast.error(result.error ?? 'Something went wrong.');
368
+ }
369
+ });
370
+ };
371
+
372
+ const startEdit = (script: SiteScript) => {
373
+ setCreating(false);
374
+ setEditingId(script.id);
375
+ setDraft({
376
+ code: script.code,
377
+ description: script.description ?? '',
378
+ is_active: script.is_active,
379
+ load_strategy: script.load_strategy,
380
+ name: script.name,
381
+ placement: script.placement,
382
+ sort_order: script.sort_order,
383
+ src: script.src ?? '',
384
+ });
385
+ };
386
+
387
+ return (
388
+ <div className="space-y-6">
389
+ {!creating && !editingId && (
390
+ <Button
391
+ onClick={() => {
392
+ setCreating(true);
393
+ setDraft(EMPTY_DRAFT);
394
+ }}
395
+ >
396
+ Add script
397
+ </Button>
398
+ )}
399
+
400
+ {creating && (
401
+ <ScriptEditor
402
+ draft={draft}
403
+ onCancel={() => setCreating(false)}
404
+ onChange={setDraft}
405
+ onSave={() => run(() => createSiteScript(draft))}
406
+ saving={isPending}
407
+ title="New script"
408
+ />
409
+ )}
410
+
411
+ {initialScripts.length === 0 && !creating && (
412
+ <p className="text-sm text-muted-foreground">
413
+ No site scripts yet. Add one to run JavaScript on every page — or ask Cortex AI to create
414
+ it for you.
415
+ </p>
416
+ )}
417
+
418
+ <ul className="space-y-3">
419
+ {initialScripts.map((script) =>
420
+ editingId === script.id ? (
421
+ <li key={script.id}>
422
+ <ScriptEditor
423
+ draft={draft}
424
+ onCancel={() => setEditingId(null)}
425
+ onChange={setDraft}
426
+ onSave={() => run(() => updateSiteScript(script.id, draft))}
427
+ saving={isPending}
428
+ title={`Editing “${script.name}”`}
429
+ />
430
+ </li>
431
+ ) : (
432
+ <li
433
+ key={script.id}
434
+ className="flex flex-wrap items-start justify-between gap-3 rounded-lg border p-4"
435
+ >
436
+ <div className="min-w-0 space-y-1">
437
+ <div className="flex items-center gap-2">
438
+ <span className="font-medium">{script.name}</span>
439
+ <span
440
+ className={`rounded-full px-2 py-0.5 text-xs ${
441
+ script.is_active
442
+ ? 'bg-primary/10 text-primary'
443
+ : 'bg-muted text-muted-foreground'
444
+ }`}
445
+ >
446
+ {script.is_active ? 'Enabled' : 'Disabled'}
447
+ </span>
448
+ <span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
449
+ {script.placement}
450
+ </span>
451
+ </div>
452
+ {script.description && (
453
+ <p className="text-sm text-muted-foreground">{script.description}</p>
454
+ )}
455
+ <p className="truncate font-mono text-xs text-muted-foreground">
456
+ {script.src || `${script.code.slice(0, 90)}${script.code.length > 90 ? '…' : ''}`}
457
+ </p>
458
+ <SafetyReview review={reviewScriptCode({ code: script.code, src: script.src })} />
459
+ </div>
460
+
461
+ <div className="flex shrink-0 gap-2">
462
+ <Button
463
+ variant="outline"
464
+ size="sm"
465
+ disabled={isPending}
466
+ onClick={() => run(() => setSiteScriptActive(script.id, !script.is_active))}
467
+ >
468
+ {script.is_active ? 'Disable' : 'Enable'}
469
+ </Button>
470
+ <Button variant="outline" size="sm" onClick={() => startEdit(script)}>
471
+ Edit
472
+ </Button>
473
+ <Button
474
+ variant="destructive"
475
+ size="sm"
476
+ disabled={isPending}
477
+ onClick={() => {
478
+ if (!window.confirm(`Delete “${script.name}”? This cannot be undone.`)) return;
479
+ run(() => deleteSiteScript(script.id));
480
+ }}
481
+ >
482
+ Delete
483
+ </Button>
484
+ </div>
485
+ </li>
486
+ )
487
+ )}
488
+ </ul>
489
+
490
+ <RevisionHistory
491
+ pending={isPending}
492
+ revisions={initialRevisions}
493
+ onRestore={(revisionId, label) => {
494
+ if (!window.confirm(`Restore “${label}”? The current version is kept in the history.`)) return;
495
+ run(() => revertSiteScript(revisionId));
496
+ }}
497
+ />
498
+ </div>
499
+ );
500
+ }
@@ -0,0 +1,51 @@
1
+ // app/cms/settings/site-scripts/page.tsx
2
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@nextblock-cms/ui';
3
+
4
+ import { getSiteScriptRevisions, getSiteScripts } from './actions';
5
+ import SiteScriptManager from './components/SiteScriptManager';
6
+
7
+ export default async function SiteScriptsSettingsPage() {
8
+ const [scripts, revisions] = await Promise.all([getSiteScripts(), getSiteScriptRevisions()]);
9
+
10
+ return (
11
+ <div className="mx-auto max-w-5xl space-y-6">
12
+ <Card>
13
+ <CardHeader>
14
+ <CardTitle>Site Scripts</CardTitle>
15
+ <CardDescription>
16
+ JavaScript that runs on every page of the public site — animation helpers, chat widgets,
17
+ third-party embeds. Each script is injected with the page&apos;s Content-Security-Policy
18
+ nonce, so it runs without weakening the site&apos;s security headers. Disabled scripts are
19
+ never sent to visitors. Every change is logged and can be rolled back from the history
20
+ below.
21
+ </CardDescription>
22
+ </CardHeader>
23
+ <CardContent>
24
+ <SiteScriptManager initialRevisions={revisions} initialScripts={scripts} />
25
+ </CardContent>
26
+ </Card>
27
+
28
+ <Card>
29
+ <CardHeader>
30
+ <CardTitle>Where else code can live</CardTitle>
31
+ </CardHeader>
32
+ <CardContent className="space-y-2 text-sm text-muted-foreground">
33
+ <p>
34
+ <strong className="text-foreground">One page only?</strong> A rich-text block accepts an
35
+ inline <code>&lt;style&gt;</code> and <code>&lt;script&gt;</code> directly, so
36
+ page-specific effects do not need to live here.
37
+ </p>
38
+ <p>
39
+ <strong className="text-foreground">Site-wide styling?</strong> Use Themes &amp; CSS for
40
+ palettes and global stylesheets.
41
+ </p>
42
+ <p>
43
+ <strong className="text-foreground">Marketing or analytics tags?</strong> Put those under
44
+ Google Analytics instead — scripts there only fire once a visitor accepts cookies, which
45
+ is what consent law requires. Scripts on this page run unconditionally.
46
+ </p>
47
+ </CardContent>
48
+ </Card>
49
+ </div>
50
+ );
51
+ }
@@ -19,6 +19,8 @@ import {
19
19
  defaultThemeSlug,
20
20
  type SiteTheme,
21
21
  } from '../lib/themes/buildThemeCss';
22
+ import { SITE_SCRIPT_COLUMNS, type SiteScript } from '../lib/site-scripts/types';
23
+ import SiteScripts from '../components/SiteScripts';
22
24
  import { DeferredSpeedInsights } from '../components/DeferredSpeedInsights';
23
25
  import { DeferredVisualEditing } from '../components/visual-editing/DeferredVisualEditing';
24
26
  import {
@@ -194,6 +196,27 @@ const getCachedSiteThemes = unstable_cache(
194
196
  { revalidate: PUBLIC_LAYOUT_REVALIDATE_SECONDS, tags: ['public-layout-site-themes'] }
195
197
  );
196
198
 
199
+ const getCachedSiteScripts = unstable_cache(
200
+ async (): Promise<SiteScript[]> => {
201
+ const supabase = createStaticSupabaseClient();
202
+ const { data, error } = await supabase
203
+ .from('site_scripts')
204
+ .select(SITE_SCRIPT_COLUMNS)
205
+ .eq('is_active', true)
206
+ .order('sort_order');
207
+
208
+ if (error || !data) {
209
+ // A missing table (pre-migration install) must not take the site down — the
210
+ // site simply renders with no author scripts, exactly as before the feature.
211
+ return [];
212
+ }
213
+
214
+ return data as SiteScript[];
215
+ },
216
+ ['public-layout-site-scripts'],
217
+ { revalidate: PUBLIC_LAYOUT_REVALIDATE_SECONDS, tags: ['public-layout-site-scripts'] }
218
+ );
219
+
197
220
  const getCachedTranslations = unstable_cache(
198
221
  async () => {
199
222
  const supabase = createStaticSupabaseClient();
@@ -324,6 +347,7 @@ async function loadLayoutData() {
324
347
  isEcommerceActive: false,
325
348
  globalCss: '',
326
349
  siteThemes: [] as SiteTheme[],
350
+ siteScripts: [] as SiteScript[],
327
351
  privacySettings: DEFAULT_PRIVACY_SETTINGS,
328
352
  footerAttributionEnabled: true,
329
353
  rememberVisitorChoice: DEFAULT_LANGUAGE_DETECTION_SETTINGS.rememberVisitorChoice,
@@ -351,6 +375,7 @@ async function loadLayoutData() {
351
375
  copyrightSettingsResult,
352
376
  globalCssResult,
353
377
  siteThemesResult,
378
+ siteScriptsResult,
354
379
  translationsResult,
355
380
  isEcommerceActive,
356
381
  privacySettings,
@@ -364,6 +389,7 @@ async function loadLayoutData() {
364
389
  })),
365
390
  getCachedGlobalCss().catch(() => ''),
366
391
  getCachedSiteThemes().catch(() => [] as SiteTheme[]),
392
+ getCachedSiteScripts().catch(() => [] as SiteScript[]),
367
393
  getCachedTranslations().catch(() => []),
368
394
  verifyPackageOnline('ecommerce').catch(() => false),
369
395
  getPrivacySettings().catch(() => DEFAULT_PRIVACY_SETTINGS),
@@ -398,6 +424,7 @@ async function loadLayoutData() {
398
424
 
399
425
  const globalCss = typeof globalCssResult === 'string' ? globalCssResult : '';
400
426
  const siteThemes = Array.isArray(siteThemesResult) ? siteThemesResult : [];
427
+ const siteScripts = Array.isArray(siteScriptsResult) ? siteScriptsResult : [];
401
428
  const translations = Array.isArray(translationsResult) ? translationsResult : [];
402
429
 
403
430
  const hasSupabaseEnv = isSupabaseConfigured();
@@ -434,6 +461,7 @@ async function loadLayoutData() {
434
461
  isEcommerceActive,
435
462
  globalCss,
436
463
  siteThemes,
464
+ siteScripts,
437
465
  privacySettings,
438
466
  footerAttributionEnabled,
439
467
  rememberVisitorChoice: languageDetectionSettings.rememberVisitorChoice,
@@ -517,6 +545,7 @@ export default async function RootLayout({
517
545
  isEcommerceActive,
518
546
  globalCss,
519
547
  siteThemes,
548
+ siteScripts,
520
549
  privacySettings,
521
550
  footerAttributionEnabled,
522
551
  rememberVisitorChoice,
@@ -564,8 +593,10 @@ export default async function RootLayout({
564
593
  <meta name="viewport" content="width=device-width, initial-scale=1" />
565
594
  {themeCss && <style id="nb-theme-tokens" dangerouslySetInnerHTML={{ __html: themeCss }} />}
566
595
  {globalCss && <style dangerouslySetInnerHTML={{ __html: globalCss }} />}
596
+ <SiteScripts nonce={nonce} placement="head" scripts={siteScripts} />
567
597
  </head>
568
598
  <body className="min-h-screen">
599
+ <SiteScripts nonce={nonce} placement="body_start" scripts={siteScripts} />
569
600
  {/* Sets window.__NEXTBLOCK_PUBLIC_ENV__ synchronously during render, before any
570
601
  descendant calls the browser Supabase client — the local-dev runtime fallback. */}
571
602
  <PublicEnvBootstrap
@@ -628,6 +659,8 @@ export default async function RootLayout({
628
659
  customScripts={privacySettings.custom_scripts}
629
660
  nonce={nonce}
630
661
  />
662
+ {/* Last in <body> so the DOM these snippets query is already present. */}
663
+ <SiteScripts nonce={nonce} placement="body_end" scripts={siteScripts} />
631
664
  </body>
632
665
  </html>
633
666
  );
@@ -14,6 +14,7 @@ import { headers } from "next/headers";
14
14
  type Block = Database['public']['Tables']['blocks']['Row'];
15
15
  import SectionBlockRenderer from "./blocks/renderers/SectionBlockRenderer"; // Static import for LCP
16
16
  import ClientTextBlockRenderer from "./blocks/renderers/ClientTextBlockRenderer"; // Static import for client component
17
+ import { addNonceToInlineScripts } from "../lib/blocks/inlineScriptNonce";
17
18
  import { getCachedCustomBlockDefinitionBySlug } from "../lib/custom-block-definitions";
18
19
  import { CachedDynamicLayoutEngine } from "./renderers/CachedDynamicLayoutEngine";
19
20
  import { resolveBlockRelations } from "../lib/resolve-block-relations";
@@ -144,13 +145,18 @@ async function renderLoadedBlock({
144
145
 
145
146
  // Keep common LCP-adjacent text blocks out of the dynamic renderer manifest.
146
147
  if (block.block_type === 'text') {
147
- // Top-level text blocks bypass the server TextBlockRenderer, so resolve any
148
- // merge tags (e.g. {{privacy_email}} on the Privacy/Terms pages) here.
148
+ // Top-level text blocks bypass the server TextBlockRenderer, so everything it
149
+ // would have done to the HTML has to happen here too: merge tags (e.g.
150
+ // {{privacy_email}} on the Privacy/Terms pages) and — because the CSP carries a
151
+ // nonce, which makes browsers ignore 'unsafe-inline' — the inline-script nonce.
152
+ // Without the latter, an inline <script> an editor wrote into a rich-text block
153
+ // is dropped by the browser with nothing failing server-side.
149
154
  const textContent = block.content as { html_content?: string } | null;
150
155
  const rawHtml = typeof textContent?.html_content === 'string' ? textContent.html_content : '';
151
- const html = rawHtml.includes('{{')
156
+ const merged = rawHtml.includes('{{')
152
157
  ? await substitutePrivacyMergeTags(rawHtml)
153
158
  : rawHtml;
159
+ const html = addNonceToInlineScripts(merged, scriptNonce ?? '');
154
160
  return (
155
161
  <ClientTextBlockRenderer
156
162
  content={{ ...(textContent as any), html_content: html }}