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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +190 -1
- package/templates/nextblock-template/app/api/mcp/route.ts +82 -13
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +5 -1
- package/templates/nextblock-template/app/cms/media/import-external-image.ts +97 -11
- package/templates/nextblock-template/app/cms/settings/site-scripts/actions.ts +346 -0
- package/templates/nextblock-template/app/cms/settings/site-scripts/components/SiteScriptManager.tsx +500 -0
- package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -0
- package/templates/nextblock-template/app/layout.tsx +33 -0
- package/templates/nextblock-template/components/BlockRenderer.tsx +9 -3
- package/templates/nextblock-template/components/SiteScripts.tsx +56 -0
- package/templates/nextblock-template/components/blocks/renderers/TextBlockRenderer.tsx +1 -9
- package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +136 -0
- package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -0
- package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -0
- package/templates/nextblock-template/lib/site-scripts/types.ts +46 -0
- package/templates/nextblock-template/next-env.d.ts +2 -2
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
package/templates/nextblock-template/app/cms/settings/site-scripts/components/SiteScriptManager.tsx
ADDED
|
@@ -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 <script> tag — NextBlock adds it, along with the
|
|
184
|
+
page'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 & 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's Content-Security-Policy
|
|
18
|
+
nonce, so it runs without weakening the site'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><style></code> and <code><script></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 & 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
|
|
148
|
-
//
|
|
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
|
|
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 }}
|