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,510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the operator's stored robots settings into the two shapes the app needs:
|
|
3
|
+
* the `MetadataRoute.Robots` object Next.js serialises for `app/robots.ts`, and the
|
|
4
|
+
* literal robots.txt text the admin preview shows, because somebody has to be able to
|
|
5
|
+
* read the file they are about to publish.
|
|
6
|
+
*
|
|
7
|
+
* Those two are not siblings, and that is the whole point of this module. The
|
|
8
|
+
* metadata object is the single source of truth; the text is produced from it by
|
|
9
|
+
* {@link renderRobotsMetadata}, a line-for-line port of the serialiser Next actually
|
|
10
|
+
* runs — `resolveRobots` in
|
|
11
|
+
* `next/dist/build/webpack/loaders/metadata/resolve-route-data`, which the route
|
|
12
|
+
* module Next generates for a dynamic `robots.ts` calls as
|
|
13
|
+
* `resolveRouteData(await handler(), 'robots')`. The preview is therefore the file,
|
|
14
|
+
* byte for byte, and not a second opinion about it.
|
|
15
|
+
*
|
|
16
|
+
* It used to be a second opinion, and that was a bug worth recording. `buildRobotsTxt`
|
|
17
|
+
* appended `settings.customRules` verbatim while `buildRobotsMetadata` could only
|
|
18
|
+
* re-express the standard directives it managed to parse, so every other custom line
|
|
19
|
+
* was shown to the operator as live and never reached a crawler. The wrong assumption
|
|
20
|
+
* behind that split was that `MetadataRoute.Robots` has no escape hatch for arbitrary
|
|
21
|
+
* lines. It has one: each rule's `other` record, which Next emits verbatim as
|
|
22
|
+
* `key: value`. Custom lines now genuinely reach the served file, and the two kinds
|
|
23
|
+
* that still cannot be represented — comments, and lines with no `:` at all, neither
|
|
24
|
+
* of which any crawler acts on — are reported by {@link listUnservedCustomRuleLines}
|
|
25
|
+
* so the SEO screen can say so, rather than being displayed as though they were live.
|
|
26
|
+
*
|
|
27
|
+
* Nothing here throws or reads the environment. Callers hand in `isSandbox` and the
|
|
28
|
+
* sitemap URL, which keeps the module pure enough to test and — more usefully —
|
|
29
|
+
* means the admin preview can render "what production would serve" rather than
|
|
30
|
+
* "what this dev machine would serve".
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { MetadataRoute } from 'next';
|
|
34
|
+
import type { RobotsSettings } from '@nextblock-cms/utils/seo';
|
|
35
|
+
|
|
36
|
+
export interface RobotsTxtOptions {
|
|
37
|
+
/**
|
|
38
|
+
* True on a disposable sandbox deployment. This overrides every stored setting,
|
|
39
|
+
* so it is passed in rather than read from `process.env` here — the admin preview
|
|
40
|
+
* must be able to show the production answer, and the sandbox's demo admin login
|
|
41
|
+
* must not be able to change what the sandbox serves.
|
|
42
|
+
*/
|
|
43
|
+
isSandbox: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Absolute URL of the sitemap, or null when there is nothing to advertise. Still
|
|
46
|
+
* subject to `settings.sitemapEnabled`: a null (or blank) URL means "we could not
|
|
47
|
+
* build one", the setting means "the operator does not want one".
|
|
48
|
+
*/
|
|
49
|
+
sitemapUrl: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One entry of `MetadataRoute.Robots['rules']`, derived from Next's own type rather
|
|
54
|
+
* than restated, so a future change to the metadata contract surfaces here as a
|
|
55
|
+
* compile error instead of as a silently ignored field.
|
|
56
|
+
*/
|
|
57
|
+
type RobotsRuleList = Extract<MetadataRoute.Robots['rules'], readonly unknown[]>;
|
|
58
|
+
type RobotsRule = RobotsRuleList[number];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The sandbox file, and the reasoning that has to travel with it.
|
|
62
|
+
*
|
|
63
|
+
* Sandbox is a copy of production, so it must stay out of the search index. The
|
|
64
|
+
* counter-intuitive part is that we ALLOW crawling in order to achieve that. Every
|
|
65
|
+
* sandbox response already carries `noindex` — the `X-Robots-Tag` header from
|
|
66
|
+
* next.config.js plus the robots meta in app/layout.tsx — and Googlebot can only
|
|
67
|
+
* obey a noindex it is allowed to fetch. `Disallow: /` would block the fetch, so
|
|
68
|
+
* Google would never read the noindex and could leave URL-only entries in the index
|
|
69
|
+
* forever: the exact opposite of what we want. The Sitemap line is omitted because
|
|
70
|
+
* the sandbox sitemap is empty by design (see app/sitemap.ts).
|
|
71
|
+
*
|
|
72
|
+
* This branch ignores the stored settings wholesale, deliberately. A sandbox visitor
|
|
73
|
+
* signs in with a shared demo admin account, and "the demo admin flipped indexing
|
|
74
|
+
* off" must not be able to turn the sandbox into a permanently half-indexed ghost.
|
|
75
|
+
*/
|
|
76
|
+
const SANDBOX_USER_AGENT_RULE = { allow: ['/'], disallow: [], userAgent: '*' } as const;
|
|
77
|
+
|
|
78
|
+
/** The file served when an operator switches indexing off for the whole site. */
|
|
79
|
+
const INDEXING_DISABLED_USER_AGENT_RULE = { allow: [], disallow: ['/'], userAgent: '*' } as const;
|
|
80
|
+
|
|
81
|
+
/** What a site with no usable configuration gets: fully crawlable. */
|
|
82
|
+
const PERMISSIVE_USER_AGENT_RULE = { allow: ['/'], disallow: [], userAgent: '*' } as const;
|
|
83
|
+
|
|
84
|
+
interface ResolvedUserAgentRule {
|
|
85
|
+
allow: string[];
|
|
86
|
+
disallow: string[];
|
|
87
|
+
userAgent: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The single decision layer both serialisers run through.
|
|
92
|
+
*
|
|
93
|
+
* Returning the resolved groups rather than a formatted string is what makes the two
|
|
94
|
+
* public functions incapable of disagreeing about who may crawl what: they differ
|
|
95
|
+
* only in how they render this answer, never in what it is.
|
|
96
|
+
*/
|
|
97
|
+
function resolveUserAgentRules(
|
|
98
|
+
settings: RobotsSettings,
|
|
99
|
+
options: RobotsTxtOptions,
|
|
100
|
+
): ResolvedUserAgentRule[] {
|
|
101
|
+
if (options.isSandbox) {
|
|
102
|
+
return [{ ...SANDBOX_USER_AGENT_RULE, allow: [...SANDBOX_USER_AGENT_RULE.allow], disallow: [] }];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!settings.isIndexingEnabled) {
|
|
106
|
+
return [
|
|
107
|
+
{
|
|
108
|
+
...INDEXING_DISABLED_USER_AGENT_RULE,
|
|
109
|
+
allow: [],
|
|
110
|
+
disallow: [...INDEXING_DISABLED_USER_AGENT_RULE.disallow],
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const rules = settings.userAgentRules.map((rule) => ({
|
|
116
|
+
allow: [...rule.allow],
|
|
117
|
+
disallow: [...rule.disallow],
|
|
118
|
+
userAgent: rule.userAgent,
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
// A robots.txt with no User-agent group is not a stricter file, it is a
|
|
122
|
+
// meaningless one, and crawlers disagree about how to read one — so a
|
|
123
|
+
// configuration that reduced to nothing falls back to the permissive wildcard
|
|
124
|
+
// rather than to an empty file.
|
|
125
|
+
return rules.length > 0
|
|
126
|
+
? rules
|
|
127
|
+
: [{ ...PERMISSIVE_USER_AGENT_RULE, allow: [...PERMISSIVE_USER_AGENT_RULE.allow], disallow: [] }];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The operator's free-form lines, normalised for parsing.
|
|
132
|
+
*
|
|
133
|
+
* CRLF is folded to LF because the textarea these come from submits whatever line
|
|
134
|
+
* endings the operator's browser produced, and a Windows-authored block would
|
|
135
|
+
* otherwise put stray carriage returns into a file that is parsed line by line.
|
|
136
|
+
* Surrounding whitespace is trimmed so a block that is nothing but blank lines
|
|
137
|
+
* reduces to the empty string.
|
|
138
|
+
*/
|
|
139
|
+
function normalizeCustomRules(customRules: string): string {
|
|
140
|
+
if (typeof customRules !== 'string') {
|
|
141
|
+
return '';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return customRules.replace(/\r\n/g, '\n').trim();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The sitemap line's URL, or null when there is nothing worth advertising.
|
|
149
|
+
*
|
|
150
|
+
* A whitespace-only URL is treated as absent rather than emitted, because
|
|
151
|
+
* `Sitemap:` with a blank value is a line a crawler will try to fetch and fail on.
|
|
152
|
+
*/
|
|
153
|
+
function resolveSitemapUrl(settings: RobotsSettings, options: RobotsTxtOptions): string | null {
|
|
154
|
+
if (options.isSandbox || !settings.isIndexingEnabled || !settings.sitemapEnabled) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const trimmed = (options.sitemapUrl ?? '').trim();
|
|
159
|
+
return trimmed === '' ? null : trimmed;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** One custom-rules group while it is still being collected, before it becomes a rule. */
|
|
163
|
+
interface CustomRuleGroup {
|
|
164
|
+
allow: string[];
|
|
165
|
+
crawlDelay?: number;
|
|
166
|
+
disallow: string[];
|
|
167
|
+
/** Directive name (original casing) to the values typed under it, in order. */
|
|
168
|
+
other: Record<string, string[]>;
|
|
169
|
+
userAgents: string[];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface ParsedCustomRules {
|
|
173
|
+
rules: RobotsRule[];
|
|
174
|
+
/**
|
|
175
|
+
* Lines the served file cannot carry, in the order they were typed. Every one of
|
|
176
|
+
* them is a line crawlers would ignore anyway — see {@link parseCustomRules} — but
|
|
177
|
+
* they are surfaced rather than swallowed so the operator is never left looking for
|
|
178
|
+
* a line that quietly disappeared.
|
|
179
|
+
*/
|
|
180
|
+
unserved: string[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Turns the free-form custom-rules block into `MetadataRoute.Robots` rules.
|
|
185
|
+
*
|
|
186
|
+
* Standard directives become the typed fields Next understands; everything else rides
|
|
187
|
+
* the per-rule `other` record, which Next's serialiser emits verbatim as
|
|
188
|
+
* `key: value`, preserving the key's casing. That covers the non-standard directives
|
|
189
|
+
* operators actually write — `Clean-param`, `Request-rate`, `Host`, an extra
|
|
190
|
+
* `Sitemap` — none of which had any representation before.
|
|
191
|
+
*
|
|
192
|
+
* Two shapes still cannot be represented, and both are reported as unserved rather
|
|
193
|
+
* than pretended away:
|
|
194
|
+
*
|
|
195
|
+
* - comments, because every line Next emits is a `name: value` pair; and
|
|
196
|
+
* - lines with no `:` (or an empty name), which are not robots.txt syntax at all.
|
|
197
|
+
*
|
|
198
|
+
* Neither changes what a crawler does, so the served file loses no meaning — but the
|
|
199
|
+
* preview must not show them, and the operator deserves to be told why they are gone.
|
|
200
|
+
*
|
|
201
|
+
* Note that `other` lines render after their group's Allow/Disallow/Crawl-delay
|
|
202
|
+
* regardless of where the operator typed them. That reordering is harmless (robots.txt
|
|
203
|
+
* groups are unordered sets of directives) and, more to the point, it is visible: the
|
|
204
|
+
* preview is generated from these same rules, so what the operator reads is what
|
|
205
|
+
* crawlers get.
|
|
206
|
+
*/
|
|
207
|
+
function parseCustomRules(customRules: string): ParsedCustomRules {
|
|
208
|
+
const normalized = normalizeCustomRules(customRules);
|
|
209
|
+
if (normalized === '') {
|
|
210
|
+
return { rules: [], unserved: [] };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const groups: CustomRuleGroup[] = [];
|
|
214
|
+
const unserved: string[] = [];
|
|
215
|
+
|
|
216
|
+
for (const rawLine of normalized.split('\n')) {
|
|
217
|
+
const line = rawLine.trim();
|
|
218
|
+
if (line === '') {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (line.startsWith('#')) {
|
|
223
|
+
unserved.push(line);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const separator = line.indexOf(':');
|
|
228
|
+
if (separator === -1) {
|
|
229
|
+
unserved.push(line);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const name = line.slice(0, separator).trim();
|
|
234
|
+
const value = line.slice(separator + 1).trim();
|
|
235
|
+
if (name === '') {
|
|
236
|
+
unserved.push(line);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const directive = name.toLowerCase();
|
|
241
|
+
let current = groups[groups.length - 1];
|
|
242
|
+
|
|
243
|
+
if (directive === 'user-agent') {
|
|
244
|
+
// A blank agent name would open a group that applies to nobody, silently
|
|
245
|
+
// orphaning every directive typed under it, so it is rejected outright.
|
|
246
|
+
if (value === '') {
|
|
247
|
+
unserved.push(line);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Consecutive `User-agent:` lines share the rules that follow them, which is
|
|
252
|
+
// standard robots.txt and something operators genuinely write. Only start a
|
|
253
|
+
// new group once the previous one has actually collected a directive.
|
|
254
|
+
if (current && isEmptyGroup(current)) {
|
|
255
|
+
current.userAgents.push(value);
|
|
256
|
+
} else {
|
|
257
|
+
groups.push({ allow: [], disallow: [], other: {}, userAgents: [value] });
|
|
258
|
+
}
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// A directive with no preceding `User-agent:` is malformed robots.txt, but the
|
|
263
|
+
// operator's intent is unambiguous, so it is attributed to the wildcard agent
|
|
264
|
+
// rather than discarded.
|
|
265
|
+
if (!current) {
|
|
266
|
+
current = { allow: [], disallow: [], other: {}, userAgents: ['*'] };
|
|
267
|
+
groups.push(current);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (directive === 'allow') {
|
|
271
|
+
// An empty value is kept rather than skipped: `Disallow:` with nothing after it
|
|
272
|
+
// is the specified way of saying "nothing is disallowed", and dropping it would
|
|
273
|
+
// turn a meaningful line into a missing one.
|
|
274
|
+
current.allow.push(value);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (directive === 'disallow') {
|
|
279
|
+
current.disallow.push(value);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (directive === 'crawl-delay') {
|
|
284
|
+
const delay = Number.parseFloat(value);
|
|
285
|
+
// Next emits the typed `crawlDelay` field only when it is truthy, so a zero,
|
|
286
|
+
// a second crawl-delay in the same group, or something unparseable would be
|
|
287
|
+
// dropped from the served file. Those go through `other` instead, where they
|
|
288
|
+
// are emitted verbatim — the operator's line survives either way.
|
|
289
|
+
if (current.crawlDelay === undefined && Number.isFinite(delay) && delay > 0) {
|
|
290
|
+
current.crawlDelay = delay;
|
|
291
|
+
} else {
|
|
292
|
+
pushOther(current, name, value);
|
|
293
|
+
}
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
pushOther(current, name, value);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { rules: groups.map(toCustomRule), unserved };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** True while a group has collected nothing but `User-agent:` lines. */
|
|
304
|
+
function isEmptyGroup(group: CustomRuleGroup): boolean {
|
|
305
|
+
return (
|
|
306
|
+
group.allow.length === 0 &&
|
|
307
|
+
group.disallow.length === 0 &&
|
|
308
|
+
group.crawlDelay === undefined &&
|
|
309
|
+
Object.keys(group.other).length === 0
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Records a non-standard directive. Repeats accumulate into an array because Next
|
|
315
|
+
* emits one line per array entry, which is the only way a group can carry the same
|
|
316
|
+
* directive name twice.
|
|
317
|
+
*/
|
|
318
|
+
function pushOther(group: CustomRuleGroup, name: string, value: string): void {
|
|
319
|
+
const existing = group.other[name];
|
|
320
|
+
if (existing) {
|
|
321
|
+
existing.push(value);
|
|
322
|
+
} else {
|
|
323
|
+
group.other[name] = [value];
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function toCustomRule(group: CustomRuleGroup): RobotsRule {
|
|
328
|
+
const rule: RobotsRule = {
|
|
329
|
+
userAgent: group.userAgents.length === 1 ? group.userAgents[0] : group.userAgents,
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
if (group.allow.length > 0) {
|
|
333
|
+
rule.allow = group.allow;
|
|
334
|
+
}
|
|
335
|
+
if (group.disallow.length > 0) {
|
|
336
|
+
rule.disallow = group.disallow;
|
|
337
|
+
}
|
|
338
|
+
if (group.crawlDelay !== undefined) {
|
|
339
|
+
rule.crawlDelay = group.crawlDelay;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const otherKeys = Object.keys(group.other);
|
|
343
|
+
if (otherKeys.length > 0) {
|
|
344
|
+
const other: Record<string, string | string[]> = {};
|
|
345
|
+
for (const key of otherKeys) {
|
|
346
|
+
const values = group.other[key];
|
|
347
|
+
other[key] = values.length === 1 ? values[0] : values;
|
|
348
|
+
}
|
|
349
|
+
rule.other = other;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return rule;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The `MetadataRoute.Robots` object served at /robots.txt by `app/robots.ts`, and the
|
|
357
|
+
* object {@link buildRobotsTxt} renders for the admin preview.
|
|
358
|
+
*
|
|
359
|
+
* Empty `allow` / `disallow` arrays are omitted rather than passed through: Next
|
|
360
|
+
* renders each entry as its own line, so an empty array contributes nothing while
|
|
361
|
+
* still occupying a field, and leaving it out keeps the emitted object honest about
|
|
362
|
+
* what the operator actually configured.
|
|
363
|
+
*/
|
|
364
|
+
export function buildRobotsMetadata(
|
|
365
|
+
settings: RobotsSettings,
|
|
366
|
+
options: RobotsTxtOptions,
|
|
367
|
+
): MetadataRoute.Robots {
|
|
368
|
+
const rules: RobotsRule[] = resolveUserAgentRules(settings, options).map((rule) => {
|
|
369
|
+
const metadataRule: RobotsRule = { userAgent: rule.userAgent };
|
|
370
|
+
if (rule.allow.length > 0) {
|
|
371
|
+
metadataRule.allow = rule.allow;
|
|
372
|
+
}
|
|
373
|
+
if (rule.disallow.length > 0) {
|
|
374
|
+
metadataRule.disallow = rule.disallow;
|
|
375
|
+
}
|
|
376
|
+
// A group consisting of nothing but its `User-agent:` header is malformed, and
|
|
377
|
+
// some parsers discard the groups that follow it. An empty `Disallow` value is
|
|
378
|
+
// the specified way to say "nothing is disallowed", so that is what an otherwise
|
|
379
|
+
// empty group carries.
|
|
380
|
+
if (rule.allow.length === 0 && rule.disallow.length === 0) {
|
|
381
|
+
metadataRule.disallow = [''];
|
|
382
|
+
}
|
|
383
|
+
return metadataRule;
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// Custom rules survive an indexing-off switch, because they are how an operator
|
|
387
|
+
// carves out the exceptions that must keep working regardless (a `.well-known`
|
|
388
|
+
// path, a verification file). They do NOT survive the sandbox, for the reason
|
|
389
|
+
// recorded on SANDBOX_USER_AGENT_RULE.
|
|
390
|
+
if (!options.isSandbox) {
|
|
391
|
+
rules.push(...parseCustomRules(settings.customRules).rules);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const result: MetadataRoute.Robots = { rules };
|
|
395
|
+
|
|
396
|
+
const sitemapUrl = resolveSitemapUrl(settings, options);
|
|
397
|
+
if (sitemapUrl) {
|
|
398
|
+
result.sitemap = sitemapUrl;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return result;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The literal robots.txt text for a settings object — what the admin preview shows.
|
|
406
|
+
*
|
|
407
|
+
* This is {@link buildRobotsMetadata} put through {@link renderRobotsMetadata}, which
|
|
408
|
+
* is what makes the preview incapable of disagreeing with the served file: there is
|
|
409
|
+
* one object, one renderer, and the renderer is Next's own algorithm. The route
|
|
410
|
+
* itself still returns the metadata object rather than this string, so caching,
|
|
411
|
+
* revalidation and content type are handled exactly the way they are for the sitemap.
|
|
412
|
+
*/
|
|
413
|
+
export function buildRobotsTxt(settings: RobotsSettings, options: RobotsTxtOptions): string {
|
|
414
|
+
return renderRobotsMetadata(buildRobotsMetadata(settings, options));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The custom-rules lines that will not appear in the served file, so the SEO screen
|
|
419
|
+
* can tell the operator instead of leaving them to notice the absence.
|
|
420
|
+
*
|
|
421
|
+
* In the sandbox that is the entire block: the sandbox answer ignores stored settings
|
|
422
|
+
* wholesale (see SANDBOX_USER_AGENT_RULE), so every custom line is inert there.
|
|
423
|
+
*/
|
|
424
|
+
export function listUnservedCustomRuleLines(
|
|
425
|
+
settings: RobotsSettings,
|
|
426
|
+
options: RobotsTxtOptions,
|
|
427
|
+
): string[] {
|
|
428
|
+
const normalized = normalizeCustomRules(settings.customRules);
|
|
429
|
+
|
|
430
|
+
if (options.isSandbox) {
|
|
431
|
+
return normalized === ''
|
|
432
|
+
? []
|
|
433
|
+
: normalized
|
|
434
|
+
.split('\n')
|
|
435
|
+
.map((line) => line.trim())
|
|
436
|
+
.filter((line) => line !== '');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return parseCustomRules(normalized).unserved;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Serialises a `MetadataRoute.Robots` object exactly as Next.js does.
|
|
444
|
+
*
|
|
445
|
+
* This is a deliberate port of `resolveRobots` in
|
|
446
|
+
* `next/dist/build/webpack/loaders/metadata/resolve-route-data`, down to the
|
|
447
|
+
* capitalised `User-Agent:`, the blank line after every group including the last, and
|
|
448
|
+
* the order of fields within a group. It exists so the admin preview can be the
|
|
449
|
+
* served bytes rather than an approximation of them; the colocated test asserts the
|
|
450
|
+
* two agree by importing Next's serialiser directly, so a change to Next's rendering
|
|
451
|
+
* fails a test here instead of silently turning the preview back into a lie.
|
|
452
|
+
*/
|
|
453
|
+
export function renderRobotsMetadata(metadata: MetadataRoute.Robots): string {
|
|
454
|
+
let content = '';
|
|
455
|
+
|
|
456
|
+
const rules = Array.isArray(metadata.rules) ? metadata.rules : [metadata.rules];
|
|
457
|
+
|
|
458
|
+
for (const rule of rules) {
|
|
459
|
+
for (const agent of toArray(rule.userAgent || ['*'])) {
|
|
460
|
+
content += `User-Agent: ${agent}\n`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (rule.allow) {
|
|
464
|
+
for (const item of toArray(rule.allow)) {
|
|
465
|
+
content += `Allow: ${item}\n`;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (rule.disallow) {
|
|
470
|
+
for (const item of toArray(rule.disallow)) {
|
|
471
|
+
content += `Disallow: ${item}\n`;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (rule.crawlDelay) {
|
|
476
|
+
content += `Crawl-delay: ${rule.crawlDelay}\n`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (rule.other) {
|
|
480
|
+
for (const key of Object.keys(rule.other)) {
|
|
481
|
+
const value = rule.other[key];
|
|
482
|
+
if (value === null || value === undefined) {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
for (const entry of toArray(value)) {
|
|
486
|
+
content += `${key}: ${entry}\n`;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
content += '\n';
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (metadata.host) {
|
|
495
|
+
content += `Host: ${metadata.host}\n`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (metadata.sitemap) {
|
|
499
|
+
for (const item of toArray(metadata.sitemap)) {
|
|
500
|
+
content += `Sitemap: ${item}\n`;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return content;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Next's `resolveArray`: a bare value becomes a one-element list, a list is left alone. */
|
|
508
|
+
function toArray<T>(value: T | T[]): T[] {
|
|
509
|
+
return Array.isArray(value) ? value : [value];
|
|
510
|
+
}
|
|
@@ -163,5 +163,15 @@ export const MIGRATIONS_BUNDLE: BundledMigration[] = [
|
|
|
163
163
|
"version": "00000000000029",
|
|
164
164
|
"name": "00000000000029_form_endpoints_default_empty.sql",
|
|
165
165
|
"sql": "-- A contact form has no address of its own by default.\n--\n-- The starter content ships a contact page addressed to `contact@example.com`, and\n-- migration 27 faithfully carried that placeholder into `form_endpoints`. Faithful was\n-- the wrong call. `example.com` is reserved by RFC 2606 precisely so it can never be\n-- registered, so the address is not merely unhelpful — it is guaranteed undeliverable,\n-- while looking like a real setting to every layer downstream. The visitor is thanked,\n-- the relay accepts the message, and nobody is ever notified. Nothing errors, so an\n-- install can run that way indefinitely.\n--\n-- Since the messaging system arrived, a per-form address is not something an operator\n-- needs to think about at all. Submissions are stored as threads and answered in\n-- CMS → Messages; the notification address is a single site-wide setting, and a form\n-- only carries its own address when someone deliberately wants that form routed\n-- elsewhere (a careers form to HR, say).\n--\n-- So the default becomes NULL, meaning \"use the site contact address\", which resolves\n-- to the address set in CMS → Messages and finally to the first admin's own login. An\n-- install therefore reaches a real human out of the box without configuring anything.\n--\n-- The sandbox is unaffected: `resolveFormRecipient` overrides everything with\n-- SANDBOX_CONTACT_EMAIL when NEXT_PUBLIC_IS_SANDBOX is set, so the hosted demo keeps\n-- routing to the operator's own inbox without storing an address here.\n--\n-- Forward-only and idempotent.\n\n-- Reserved domains from RFC 2606 / RFC 6761. Matching on the domain rather than the\n-- exact seeded string also catches an operator who typed their own placeholder.\nUPDATE public.form_endpoints\n SET recipient_email = NULL\n WHERE recipient_email IS NOT NULL\n AND (\n lower(recipient_email) LIKE '%@example.com'\n OR lower(recipient_email) LIKE '%@example.org'\n OR lower(recipient_email) LIKE '%@example.net'\n OR lower(recipient_email) LIKE '%@example.edu'\n OR lower(recipient_email) LIKE '%.example'\n OR lower(recipient_email) LIKE '%.invalid'\n OR lower(recipient_email) LIKE '%.test'\n OR lower(recipient_email) LIKE '%.localhost'\n OR lower(recipient_email) LIKE '%.local'\n );\n\nCOMMENT ON COLUMN public.form_endpoints.recipient_email IS 'Per-form override. NULL means \"use the site contact address\" (CMS -> Messages, falling back to the first admin), which is the default and the usual case.';\n\n-- Same reasoning for the site-wide rows: a placeholder there is worse than an empty\n-- value, because an empty one falls through to the first admin and actually arrives.\nUPDATE public.site_settings\n SET value = jsonb_set(value, '{contactEmail}', '\"\"'::jsonb)\n WHERE key IN ('forms_contact', 'store_contact')\n AND value ? 'contactEmail'\n AND lower(value->>'contactEmail') LIKE '%@example.%';\n"
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"version": "00000000000030",
|
|
169
|
+
"name": "00000000000030_seo_redirects_and_robots.sql",
|
|
170
|
+
"sql": "-- SEO engine: managed 301/302 redirects and operator-configurable robots directives.\n--\n-- Two unrelated-looking things ship in one migration because they are the same\n-- feature from an operator's point of view: the `/cms/settings/seo` screen is where\n-- someone goes to say \"this URL moved\" and \"do not crawl that\". Splitting them\n-- across two migrations would only mean two files that must always be applied\n-- together.\n--\n-- WHY A TABLE AND NOT next.config.js redirects(). Redirects are content, not\n-- configuration. An editor who renames a page's slug needs the old URL to keep\n-- working immediately, without a redeploy and without touching source control.\n-- That rules out the build-time array; it has to be data.\n--\n-- WHY THE PROXY READS THIS WITH THE ANON KEY. apps/nextblock/proxy.ts (Next 16's\n-- renamed middleware) resolves redirects before rendering, and it holds a Supabase\n-- client built from the anon key. So the public SELECT policy below is load-bearing:\n-- without an explicit `TO authenticated, anon` grant the proxy's lookup would return\n-- zero rows for every anonymous visitor -- silently, with no error -- and no redirect\n-- would ever fire. Only is_active rows are exposed, so a half-written rule is never\n-- live.\n--\n-- WHY status_code IS AN integer AND NOT AN ENUM. The same reasoning migration 27\n-- recorded for `source`: a Postgres enum cannot be extended and used inside the same\n-- transaction, which is exactly the scope of one migration file. A CHECK constraint\n-- is replaceable in a single statement. 301 and 302 are the only two values the\n-- admin UI offers; 307/308 are deliberately not exposed, because their\n-- method-preserving semantics surprise operators who just want \"this page moved\".\n--\n-- LOOP SAFETY is enforced in application code (wouldCreateLoop in\n-- @nextblock-cms/utils, called by the admin server actions) rather than by a\n-- constraint, because detecting a cycle requires walking the whole table and a CHECK\n-- constraint can only see one row. The self-redirect case IS cheap to check per row,\n-- so that one is a constraint -- it is the cycle operators actually hit.\n--\n-- SECURITY POSTURE. A redirect can send every visitor of a path to an arbitrary\n-- external origin, which makes this table an open-redirect surface and a phishing\n-- lever. Writes are therefore ADMIN-only -- WRITER is deliberately excluded, matching\n-- site_scripts rather than the content tables. Reads are public but limited to active\n-- rows. The robots settings live in site_settings, whose existing read policy is\n-- already public for non-secret keys; nothing here is secret.\n--\n-- Forward-only and idempotent.\n\nCREATE TABLE IF NOT EXISTS public.cms_redirects (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n -- The incoming site-relative path to match, normalized by the application to a\n -- leading slash with no trailing slash (except root). Matching is exact: prefix\n -- and wildcard rules are deliberately not supported, because they are the usual\n -- way an operator builds an accidental loop.\n source_path text NOT NULL,\n -- Where to send the visitor. Either another site-relative path or a fully\n -- qualified https URL for an off-site move.\n destination_path text NOT NULL,\n -- 301 permanent (the SEO-meaningful one: search engines transfer ranking signals\n -- and browsers cache it aggressively) or 302 temporary.\n status_code integer DEFAULT 301 NOT NULL,\n is_active boolean DEFAULT true NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT cms_redirects_pkey PRIMARY KEY (id),\n -- One rule per source. This UNIQUE constraint also provides the index the proxy\n -- lookup relies on, so no separate plain index on source_path is needed.\n CONSTRAINT cms_redirects_source_path_key UNIQUE (source_path),\n CONSTRAINT cms_redirects_status_code_check\n CHECK ((status_code = ANY (ARRAY[301, 302]))),\n -- A source is always a path on this site; accepting an absolute URL here would\n -- silently never match, since the proxy only ever compares pathnames.\n CONSTRAINT cms_redirects_source_path_check\n CHECK ((source_path ~ '^/')),\n -- A destination is either a site-relative path or an https URL. Plain http is\n -- refused so a redirect can never downgrade a visitor to cleartext.\n CONSTRAINT cms_redirects_destination_path_check\n CHECK (((destination_path ~ '^/') OR (destination_path ~ '^https://'))),\n -- The one cycle a single row can express, and the one operators actually create.\n CONSTRAINT cms_redirects_no_self_redirect_check\n CHECK ((source_path <> destination_path))\n);\n\nCOMMENT ON TABLE public.cms_redirects IS\n 'Operator-managed 301/302 redirects resolved by apps/nextblock/proxy.ts before rendering. Only is_active rows are publicly readable; only ADMIN may write, because a redirect rule is an open-redirect surface.';\nCOMMENT ON COLUMN public.cms_redirects.source_path IS\n 'Exact site-relative path to match, normalized to a leading slash and no trailing slash (except root). No wildcards, by design.';\nCOMMENT ON COLUMN public.cms_redirects.destination_path IS\n 'Site-relative path or absolute https URL to send the visitor to.';\nCOMMENT ON COLUMN public.cms_redirects.status_code IS\n '301 permanent or 302 temporary. 307/308 are not offered by the admin UI.';\nCOMMENT ON COLUMN public.cms_redirects.is_active IS\n 'Only active rows are readable by anon, and only active rows are matched by the proxy.';\n\n-- The proxy loads the whole active set once per cache window rather than querying per\n-- request, so the hot query is \"all active rows\" and this partial index is what serves\n-- it. Carrying source_path in the index keeps that read index-only.\nCREATE INDEX IF NOT EXISTS cms_redirects_active_source_idx\n ON public.cms_redirects USING btree (source_path) WHERE (is_active);\n\nDROP TRIGGER IF EXISTS set_cms_redirects_updated_at ON public.cms_redirects;\nCREATE TRIGGER set_cms_redirects_updated_at\n BEFORE UPDATE ON public.cms_redirects\n FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at();\n\nALTER TABLE public.cms_redirects ENABLE ROW LEVEL SECURITY;\n\nGRANT ALL ON TABLE public.cms_redirects TO anon;\nGRANT ALL ON TABLE public.cms_redirects TO authenticated;\nGRANT ALL ON TABLE public.cms_redirects TO service_role;\n\n-- The proxy runs as anon for a logged-out visitor, which is the overwhelming majority\n-- of traffic and the only traffic redirects really matter for. Without this policy the\n-- lookup returns zero rows and the feature is silently dead.\nDROP POLICY IF EXISTS \"Public read active redirects\" ON public.cms_redirects;\nCREATE POLICY \"Public read active redirects\" ON public.cms_redirects\n FOR SELECT TO authenticated, anon USING (is_active);\n\nDROP POLICY IF EXISTS \"Admins read all redirects\" ON public.cms_redirects;\nCREATE POLICY \"Admins read all redirects\" ON public.cms_redirects\n FOR SELECT TO authenticated\n USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));\n\nDROP POLICY IF EXISTS \"Admins insert redirects\" ON public.cms_redirects;\nCREATE POLICY \"Admins insert redirects\" ON public.cms_redirects\n FOR INSERT TO authenticated\n WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));\n\nDROP POLICY IF EXISTS \"Admins update redirects\" ON public.cms_redirects;\nCREATE POLICY \"Admins update redirects\" ON public.cms_redirects\n FOR UPDATE TO authenticated\n USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role))\n WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));\n\nDROP POLICY IF EXISTS \"Admins delete redirects\" ON public.cms_redirects;\nCREATE POLICY \"Admins delete redirects\" ON public.cms_redirects\n FOR DELETE TO authenticated\n USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));\n\n-- Robots directives live in site_settings rather than a table of their own: there is\n-- exactly one robots.txt per install, so a dedicated table would only ever hold one\n-- row, and site_settings already carries the public-read / staff-write policy this\n-- needs. The stored shape matches `RobotsSettings` in @nextblock-cms/utils, and\n-- `normalizeRobotsSettings` tolerates a missing or malformed value -- so this seed is\n-- a convenience for the settings screen, not a correctness requirement for rendering.\n--\n-- ON CONFLICT DO NOTHING because an install that has already configured robots must\n-- not have its rules reset by a replay of this migration.\nINSERT INTO public.site_settings (key, value)\nVALUES (\n 'seo_robots_settings',\n '{\"customRules\": \"\", \"isIndexingEnabled\": true, \"sitemapEnabled\": true, \"userAgentRules\": [{\"allow\": [\"/\"], \"disallow\": [], \"userAgent\": \"*\"}]}'::jsonb\n)\nON CONFLICT (key) DO NOTHING;\n"
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"version": "00000000000031",
|
|
174
|
+
"name": "00000000000031_seo_robots_settings_admin_only.sql",
|
|
175
|
+
"sql": "-- Restrict writes to the robots.txt settings row to ADMIN only.\n--\n-- `site_settings.seo_robots_settings` is the row that decides whether the whole site\n-- is crawlable: app/robots.ts reads it on every /robots.txt hit and serves a blanket\n-- `Disallow: /` when `isIndexingEnabled` is false. The CMS only offers that switch\n-- under /cms/settings/seo, and `saveRobotsSettings` re-checks for ADMIN before it\n-- writes — but RLS is the independent boundary, and it did not agree. The baseline\n-- write policies let ADMIN *or* WRITER write any key outside the sensitive array, so\n-- a WRITER holding a normal session could PATCH this row through PostgREST and take\n-- the entire site out of Google. That failure is quiet (nothing in the CMS shows it),\n-- slow to notice (search traffic decays over weeks) and hard to attribute after the\n-- fact, which is why the database has to refuse it rather than trusting the one\n-- server action that happens to guard it today.\n--\n-- The SELECT policy is deliberately NOT touched. This key must stay anon-READABLE:\n-- app/robots.ts reads it with the anon (SSG) client on every crawl, and adding the key\n-- to the read policy's sensitive array would make robots.txt fall back to its\n-- permissive defaults for every crawler. Only INSERT/UPDATE/DELETE move to ADMIN-only,\n-- matching the UI boundary — exactly the shape migration 00000000000011 used for\n-- language_detection_settings, which is anon-readable for the same reason.\n--\n-- Every key already present in each policy's array is preserved (dropping one would\n-- silently widen write access back to WRITER for that key); `seo_robots_settings` is\n-- appended to the three write policies only.\n\nDROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;\nCREATE POLICY site_settings_insert_policy ON public.site_settings FOR INSERT TO authenticated WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));\n\nDROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;\nCREATE POLICY site_settings_update_policy ON public.site_settings FOR UPDATE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role)))) WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));\n\nDROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;\nCREATE POLICY site_settings_delete_policy ON public.site_settings FOR DELETE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));\n\n-- Forward-only and idempotent.\n"
|
|
166
176
|
}
|
|
167
177
|
];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/// <reference types="next" />
|
|
2
2
|
/// <reference types="next/image-types/global" />
|
|
3
|
-
import "./.next/
|
|
4
|
-
import "./.next/
|
|
3
|
+
import "./.next/types/routes.d.ts";
|
|
4
|
+
import "./.next/types/root-params.d.ts";
|
|
5
5
|
|
|
6
6
|
// NOTE: This file should not be edited
|
|
7
7
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|