bsmnt 0.9.0 → 0.10.0
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/dist/application/add-integration/index.d.ts.map +1 -1
- package/dist/application/add-integration/index.js +19 -2
- package/dist/application/add-integration/index.js.map +1 -1
- package/dist/infrastructure/integrations/ensure-studio-headers.d.ts +6 -0
- package/dist/infrastructure/integrations/ensure-studio-headers.d.ts.map +1 -0
- package/dist/infrastructure/integrations/ensure-studio-headers.js +153 -0
- package/dist/infrastructure/integrations/ensure-studio-headers.js.map +1 -0
- package/dist/modules/features/cms/sanity/config.d.ts.map +1 -1
- package/dist/modules/features/cms/sanity/config.js +2 -1
- package/dist/modules/features/cms/sanity/config.js.map +1 -1
- package/dist/modules/features/cms/sanity-pagebuilder/config.d.ts.map +1 -1
- package/dist/modules/features/cms/sanity-pagebuilder/config.js +2 -1
- package/dist/modules/features/cms/sanity-pagebuilder/config.js.map +1 -1
- package/package.json +2 -2
- package/src/modules/features/cms/sanity/files/app/api/blog/[slug]/route.ts +4 -1
- package/src/modules/features/cms/sanity/files/app/sitemap.md/route.ts +2 -1
- package/src/modules/features/cms/sanity/files/components/ui/sanity-image/index.tsx +19 -5
- package/src/modules/features/cms/sanity/files/lib/integrations/sanity/README.md +31 -0
- package/src/modules/features/cms/sanity/files/proxy.ts +9 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/app/api/[[...slug]]/route.ts +8 -3
- package/src/modules/features/cms/sanity-pagebuilder/files/app/sitemap.md/route.ts +7 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/components/ui/sanity-image/index.tsx +15 -5
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/slug-field.tsx +13 -7
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/slug-notices.tsx +135 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/live/index.tsx +1 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/markdown-proxy.config.ts +1 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/redirect.ts +35 -4
- package/src/modules/features/cms/sanity-pagebuilder/files/proxy.ts +9 -0
- package/src/templates/next-default/.env.example +2 -0
- package/src/templates/next-default/app/robots.ts +2 -3
- package/src/templates/next-default/next.config.ts +36 -2
- package/src/templates/next-default/package.json +9 -9
- package/src/templates/next-experiments/.env.example +2 -0
- package/src/templates/next-experiments/app/robots.ts +2 -3
- package/src/templates/next-experiments/next.config.ts +36 -2
- package/src/templates/next-experiments/package.json +9 -9
- package/src/templates/next-pagebuilder/.env.example +2 -0
- package/src/templates/next-pagebuilder/README.md +31 -0
- package/src/templates/next-pagebuilder/app/(content)/[[...slug]]/page.tsx +24 -9
- package/src/templates/next-pagebuilder/app/robots.ts +2 -3
- package/src/templates/next-pagebuilder/app/sitemap.xml/route.ts +6 -3
- package/src/templates/next-pagebuilder/components/page-builder/components/content-collection/skeleton.tsx +53 -0
- package/src/templates/next-pagebuilder/components/page-builder/renderer.tsx +7 -1
- package/src/templates/next-pagebuilder/next.config.ts +33 -2
- package/src/templates/next-pagebuilder/package.json +4 -4
- package/src/templates/next-webgl/.env.example +2 -0
- package/src/templates/next-webgl/app/robots.ts +2 -3
- package/src/templates/next-webgl/next.config.ts +36 -2
- package/src/templates/next-webgl/package.json +9 -9
|
@@ -1,18 +1,24 @@
|
|
|
1
|
+
import { Stack } from "@sanity/ui";
|
|
1
2
|
import { type FieldProps, useFormValue } from "sanity";
|
|
3
|
+
import { SlugRedirectNotice } from "@/lib/integrations/sanity/components/slug-notices";
|
|
2
4
|
import { getPinnedPageById } from "@/lib/integrations/sanity/pinned-pages";
|
|
3
5
|
|
|
4
6
|
const DEFAULT_DESCRIPTION = "URL path without leading slash.";
|
|
5
7
|
const PINNED_DESCRIPTION =
|
|
6
8
|
"URL path without leading slash. Disabled on 📌 pinned pages.";
|
|
7
9
|
|
|
8
|
-
// Swaps the slug description
|
|
9
|
-
// (Sanity descriptions are plain strings, hence the 📌 character.)
|
|
10
|
+
// Swaps the slug description on pinned pages (plain-string field, hence 📌).
|
|
10
11
|
export function SlugField(props: FieldProps) {
|
|
11
12
|
const documentId = useFormValue(["_id"]) as string | undefined;
|
|
12
|
-
const
|
|
13
|
+
const pinned = getPinnedPageById(documentId);
|
|
13
14
|
|
|
14
|
-
return
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
return (
|
|
16
|
+
<Stack space={3}>
|
|
17
|
+
{props.renderDefault({
|
|
18
|
+
...props,
|
|
19
|
+
description: pinned ? PINNED_DESCRIPTION : DEFAULT_DESCRIPTION,
|
|
20
|
+
})}
|
|
21
|
+
<SlugRedirectNotice pinnedPath={pinned?.path} />
|
|
22
|
+
</Stack>
|
|
23
|
+
);
|
|
18
24
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Box, Button, Card, Stack, Text } from "@sanity/ui";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { useClient, useFormValue } from "sanity";
|
|
4
|
+
import { useRouter } from "sanity/router";
|
|
5
|
+
import { apiVersion } from "@/lib/integrations/sanity/env";
|
|
6
|
+
import { isEmptyPageSlug } from "@/lib/integrations/sanity/page-category";
|
|
7
|
+
|
|
8
|
+
// Mirrors `pageResolvedSlugExpression`'s chain; leaf joins in JS so this
|
|
9
|
+
// tracks the live slug, not the last saved draft.
|
|
10
|
+
const FOLDER_PATH_QUERY = `*[_id == $folderId][0]{
|
|
11
|
+
"path": array::join(array::compact([
|
|
12
|
+
parentFolder->parentFolder->parentFolder->parentFolder->parentFolder->slug.current,
|
|
13
|
+
parentFolder->parentFolder->parentFolder->parentFolder->slug.current,
|
|
14
|
+
parentFolder->parentFolder->parentFolder->slug.current,
|
|
15
|
+
parentFolder->parentFolder->slug.current,
|
|
16
|
+
parentFolder->slug.current,
|
|
17
|
+
slug.current
|
|
18
|
+
]), "/")
|
|
19
|
+
}.path`;
|
|
20
|
+
|
|
21
|
+
const REDIRECT_QUERY = `*[_type == "redirect" && source.current == $source][0]._id`;
|
|
22
|
+
|
|
23
|
+
// The slug field writes on every keystroke.
|
|
24
|
+
const LOOKUP_DEBOUNCE_MS = 400;
|
|
25
|
+
|
|
26
|
+
interface SlugRedirectNoticeProps {
|
|
27
|
+
/** Public path of a pinned page, whose URL is fixed rather than slug-derived. */
|
|
28
|
+
pinnedPath?: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Warns when a published redirect claims this page's URL — the catch-all
|
|
33
|
+
* route only runs `checkRedirect` after a page lookup misses, so the page
|
|
34
|
+
* wins and the redirect silently never fires.
|
|
35
|
+
*
|
|
36
|
+
* Reads the `page` form shape; don't reuse for other slug types.
|
|
37
|
+
*/
|
|
38
|
+
export function SlugRedirectNotice({ pinnedPath }: SlugRedirectNoticeProps) {
|
|
39
|
+
const client = useClient({ apiVersion });
|
|
40
|
+
const router = useRouter();
|
|
41
|
+
const slug = useFormValue(["slug", "current"]) as string | undefined;
|
|
42
|
+
const folderId = useFormValue(["pageFolder", "_ref"]) as string | undefined;
|
|
43
|
+
const [folderPath, setFolderPath] = useState("");
|
|
44
|
+
const [redirectId, setRedirectId] = useState<string | null>(null);
|
|
45
|
+
|
|
46
|
+
// Separate effect so the folder chain isn't re-resolved on every keystroke.
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!folderId) {
|
|
49
|
+
setFolderPath("");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let active = true;
|
|
54
|
+
const published = client.withConfig({ perspective: "published" });
|
|
55
|
+
|
|
56
|
+
published
|
|
57
|
+
.fetch<string | null>(FOLDER_PATH_QUERY, { folderId })
|
|
58
|
+
.then((path) => {
|
|
59
|
+
if (active) setFolderPath(path ?? "");
|
|
60
|
+
})
|
|
61
|
+
.catch(() => {
|
|
62
|
+
if (active) setFolderPath("");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
active = false;
|
|
67
|
+
};
|
|
68
|
+
}, [client, folderId]);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const leaf = isEmptyPageSlug(slug) ? "" : slug;
|
|
72
|
+
const source = pinnedPath
|
|
73
|
+
? pinnedPath.replace(/^\/+/, "")
|
|
74
|
+
: [folderPath, leaf].filter(Boolean).join("/");
|
|
75
|
+
|
|
76
|
+
if (!source) {
|
|
77
|
+
setRedirectId(null);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let active = true;
|
|
82
|
+
// Drafts never reach visitors, so an unpublished redirect isn't a conflict.
|
|
83
|
+
const published = client.withConfig({ perspective: "published" });
|
|
84
|
+
|
|
85
|
+
const timer = setTimeout(async () => {
|
|
86
|
+
try {
|
|
87
|
+
const id = await published.fetch<string | null>(REDIRECT_QUERY, {
|
|
88
|
+
source,
|
|
89
|
+
});
|
|
90
|
+
if (active) setRedirectId(id);
|
|
91
|
+
} catch {
|
|
92
|
+
if (active) setRedirectId(null);
|
|
93
|
+
}
|
|
94
|
+
}, LOOKUP_DEBOUNCE_MS);
|
|
95
|
+
|
|
96
|
+
return () => {
|
|
97
|
+
active = false;
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
};
|
|
100
|
+
}, [client, folderPath, pinnedPath, slug]);
|
|
101
|
+
|
|
102
|
+
if (!redirectId) return null;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Card border padding={3} radius={2} tone="caution">
|
|
106
|
+
<Stack space={3}>
|
|
107
|
+
<Stack space={2}>
|
|
108
|
+
<Text size={1} weight="semibold">
|
|
109
|
+
A redirect already claims this URL
|
|
110
|
+
</Text>
|
|
111
|
+
<Text muted size={1}>
|
|
112
|
+
Visitors reach this page instead — a redirect only runs when no page
|
|
113
|
+
matches its path. Remove the redirect, or move this page to another
|
|
114
|
+
slug.
|
|
115
|
+
</Text>
|
|
116
|
+
</Stack>
|
|
117
|
+
<Box>
|
|
118
|
+
<Button
|
|
119
|
+
fontSize={1}
|
|
120
|
+
mode="ghost"
|
|
121
|
+
onClick={() =>
|
|
122
|
+
router.navigateIntent("edit", {
|
|
123
|
+
id: redirectId,
|
|
124
|
+
type: "redirect",
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
padding={3}
|
|
128
|
+
text="Go to redirect"
|
|
129
|
+
tone="caution"
|
|
130
|
+
/>
|
|
131
|
+
</Box>
|
|
132
|
+
</Stack>
|
|
133
|
+
</Card>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -22,7 +22,7 @@ export interface MarkdownRoute {
|
|
|
22
22
|
/** .md extension routes -- rewrite to API endpoints */
|
|
23
23
|
export const mdExtensionRoutes: MarkdownRoute[] = [
|
|
24
24
|
{
|
|
25
|
-
regex: /^\/(
|
|
25
|
+
regex: /^\/((?:[^/]+\/)*[^/]+)\.md$/,
|
|
26
26
|
apiPath: "/api/[slug].md",
|
|
27
27
|
publicPath: "/[slug].md",
|
|
28
28
|
},
|
|
@@ -65,7 +65,7 @@ export const redirect = defineType({
|
|
|
65
65
|
description:
|
|
66
66
|
"Where to send visitors. An internal route path without a leading slash (e.g. new-page or blog/new-post), or an absolute URL (e.g. https://example.com).",
|
|
67
67
|
validation: (Rule) =>
|
|
68
|
-
Rule.required().custom((value, context) => {
|
|
68
|
+
Rule.required().custom(async (value, context) => {
|
|
69
69
|
if (typeof value !== "string") return true;
|
|
70
70
|
|
|
71
71
|
const isAbsolute = /^https?:\/\//.test(value);
|
|
@@ -79,13 +79,44 @@ export const redirect = defineType({
|
|
|
79
79
|
return "Internal destination must not start or end with a forward slash";
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
const document = context.document as
|
|
83
|
+
| RedirectValidationDocument
|
|
84
|
+
| undefined;
|
|
85
|
+
const source = document?.source?.current;
|
|
86
|
+
|
|
85
87
|
if (source && source === value) {
|
|
86
88
|
return "Destination must differ from source";
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
// Rejects a direct loop (a -> b, b -> a); longer chains aren't checked.
|
|
92
|
+
// Normalizes slashes since docs written outside Studio can skip that validation.
|
|
93
|
+
if (source) {
|
|
94
|
+
const id = document?._id?.replace(/^drafts\./, "") ?? "";
|
|
95
|
+
const client = context.getClient({ apiVersion });
|
|
96
|
+
const normalize = (path: string) => path.replace(/^\/+|\/+$/g, "");
|
|
97
|
+
const normalizedSource = normalize(source);
|
|
98
|
+
const normalizedDestination = normalize(value);
|
|
99
|
+
const others = await client.fetch<
|
|
100
|
+
Array<{ source?: string; destination?: string }>
|
|
101
|
+
>(
|
|
102
|
+
`*[
|
|
103
|
+
_type == "redirect" &&
|
|
104
|
+
!(_id in [$id, "drafts." + $id])
|
|
105
|
+
]{ "source": source.current, destination }`,
|
|
106
|
+
{ id },
|
|
107
|
+
);
|
|
108
|
+
const hasInverse = others.some(
|
|
109
|
+
(redirect) =>
|
|
110
|
+
redirect.source &&
|
|
111
|
+
normalize(redirect.source) === normalizedDestination &&
|
|
112
|
+
redirect.destination &&
|
|
113
|
+
normalize(redirect.destination) === normalizedSource,
|
|
114
|
+
);
|
|
115
|
+
if (hasInverse) {
|
|
116
|
+
return "This creates a redirect loop — another redirect sends the destination back to this source";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
89
120
|
return true;
|
|
90
121
|
}),
|
|
91
122
|
}),
|
|
@@ -14,6 +14,15 @@ export function proxy(request: NextRequest) {
|
|
|
14
14
|
return NextResponse.next();
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// Collapse repeated slashes before any rewrite, or the rewrite below would
|
|
18
|
+
// mask the alias (e.g. /foo//bar.md silently resolving to /foo/bar.md).
|
|
19
|
+
const collapsedPathname = pathname.replace(/\/{2,}/g, "/");
|
|
20
|
+
if (collapsedPathname !== pathname) {
|
|
21
|
+
const url = request.nextUrl.clone();
|
|
22
|
+
url.pathname = collapsedPathname;
|
|
23
|
+
return NextResponse.redirect(url, 308);
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
if (pathname === "/" && acceptHeader.includes("text/markdown")) {
|
|
18
27
|
const url = request.nextUrl.clone();
|
|
19
28
|
url.pathname = "/api/index.md";
|
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
# Base URL for your site (used for SEO, canonical URLs, etc.)
|
|
33
33
|
# Development: http://localhost:3000
|
|
34
34
|
# Production: https://your-domain.com
|
|
35
|
+
# Inlined at build time — scope this to the Production environment only in
|
|
36
|
+
# Vercel so Preview deployments fall through to VERCEL_PROJECT_PRODUCTION_URL.
|
|
35
37
|
NEXT_PUBLIC_BASE_URL=""
|
|
36
38
|
|
|
37
39
|
# Draft mode secret for preview functionality
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { MetadataRoute } from "next"
|
|
2
|
-
|
|
3
|
-
const APP_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"
|
|
2
|
+
import { getBaseUrl } from "@/lib/utils/url"
|
|
4
3
|
|
|
5
4
|
export default function robots(): MetadataRoute.Robots {
|
|
6
5
|
return {
|
|
@@ -9,6 +8,6 @@ export default function robots(): MetadataRoute.Robots {
|
|
|
9
8
|
allow: "/",
|
|
10
9
|
disallow: [],
|
|
11
10
|
},
|
|
12
|
-
sitemap: `${
|
|
11
|
+
sitemap: `${getBaseUrl()}/sitemap.xml`,
|
|
13
12
|
}
|
|
14
13
|
}
|
|
@@ -64,8 +64,10 @@ const nextConfig: NextConfig = {
|
|
|
64
64
|
},
|
|
65
65
|
},
|
|
66
66
|
},
|
|
67
|
+
logging: {
|
|
68
|
+
browserToTerminal: true,
|
|
69
|
+
},
|
|
67
70
|
experimental: {
|
|
68
|
-
browserDebugInfoInTerminal: true,
|
|
69
71
|
optimizePackageImports: [
|
|
70
72
|
"@react-three/drei",
|
|
71
73
|
"@react-three/fiber",
|
|
@@ -86,7 +88,8 @@ const nextConfig: NextConfig = {
|
|
|
86
88
|
},
|
|
87
89
|
headers: async () => [
|
|
88
90
|
{
|
|
89
|
-
|
|
91
|
+
// Excludes /studio* — must stay mutually exclusive with the rule below.
|
|
92
|
+
source: "/((?!studio(?:/|$)).*)",
|
|
90
93
|
headers: [
|
|
91
94
|
{
|
|
92
95
|
key: "X-Content-Type-Options",
|
|
@@ -118,6 +121,37 @@ const nextConfig: NextConfig = {
|
|
|
118
121
|
},
|
|
119
122
|
],
|
|
120
123
|
},
|
|
124
|
+
{
|
|
125
|
+
// X-Frame-Options can't allow a third-party origin, and Next can't unset
|
|
126
|
+
// it from another rule — the iframe needs it absent, not overridden.
|
|
127
|
+
source: "/studio/:path*",
|
|
128
|
+
headers: [
|
|
129
|
+
{
|
|
130
|
+
key: "X-Content-Type-Options",
|
|
131
|
+
value: "nosniff",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
key: "Content-Security-Policy",
|
|
135
|
+
value: "frame-ancestors 'self' https://*.sanity.io;",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
key: "X-XSS-Protection",
|
|
139
|
+
value: "1; mode=block",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
key: "X-DNS-Prefetch-Control",
|
|
143
|
+
value: "on",
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
key: "Strict-Transport-Security",
|
|
147
|
+
value: "max-age=63072000; includeSubDomains; preload",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
key: "Permissions-Policy",
|
|
151
|
+
value: "camera=(), microphone=(), geolocation=()",
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
},
|
|
121
155
|
],
|
|
122
156
|
redirects: async () => [
|
|
123
157
|
{
|
|
@@ -30,30 +30,30 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"class-variance-authority": "^0.7.1",
|
|
32
32
|
"clsx": "^2.1.1",
|
|
33
|
-
"next": "^16",
|
|
34
|
-
"react": "^19",
|
|
35
|
-
"react-dom": "^19",
|
|
33
|
+
"next": "^16.3.0",
|
|
34
|
+
"react": "^19.2.8",
|
|
35
|
+
"react-dom": "^19.2.8",
|
|
36
36
|
"tailwind-merge": "^3.4.0",
|
|
37
37
|
"zod": "^4.3.6"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@biomejs/biome": "^2.4.14",
|
|
41
|
-
"@next/bundle-analyzer": "^16",
|
|
41
|
+
"@next/bundle-analyzer": "^16.3.0",
|
|
42
42
|
"@svgr/webpack": "^8.1.0",
|
|
43
|
-
"@tailwindcss/postcss": "^4",
|
|
43
|
+
"@tailwindcss/postcss": "^4.2.2",
|
|
44
44
|
"@types/bun": "^1.3.6",
|
|
45
45
|
"@types/node": "^24.13.1",
|
|
46
|
-
"@types/react": "^19.2.
|
|
46
|
+
"@types/react": "^19.2.14",
|
|
47
47
|
"@types/react-dom": "^19.2.3",
|
|
48
|
-
"@typescript/native-preview": "^7.0.0-dev.
|
|
48
|
+
"@typescript/native-preview": "^7.0.0-dev.20260323.1",
|
|
49
49
|
"babel-plugin-react-compiler": "^1.0.0",
|
|
50
50
|
"cross-env": "^10.1.0",
|
|
51
51
|
"knip": "^6.16.1",
|
|
52
52
|
"lefthook": "^1.13.0",
|
|
53
53
|
"postcss-preset-env": "^10.6.1",
|
|
54
54
|
"schema-dts": "^2.0.0",
|
|
55
|
-
"tailwindcss": "^4",
|
|
56
|
-
"typescript": "^5"
|
|
55
|
+
"tailwindcss": "^4.2.2",
|
|
56
|
+
"typescript": "^5.9.3"
|
|
57
57
|
},
|
|
58
58
|
"trustedDependencies": [
|
|
59
59
|
"@biomejs/biome",
|
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
# Base URL for your site (used for SEO, canonical URLs, etc.)
|
|
33
33
|
# Development: http://localhost:3000
|
|
34
34
|
# Production: https://your-domain.com
|
|
35
|
+
# Inlined at build time — scope this to the Production environment only in
|
|
36
|
+
# Vercel so Preview deployments fall through to VERCEL_PROJECT_PRODUCTION_URL.
|
|
35
37
|
NEXT_PUBLIC_BASE_URL=""
|
|
36
38
|
|
|
37
39
|
# Draft mode secret for preview functionality
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { MetadataRoute } from "next"
|
|
2
|
-
|
|
3
|
-
const APP_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"
|
|
2
|
+
import { getBaseUrl } from "@/lib/utils/url"
|
|
4
3
|
|
|
5
4
|
export default function robots(): MetadataRoute.Robots {
|
|
6
5
|
return {
|
|
@@ -9,6 +8,6 @@ export default function robots(): MetadataRoute.Robots {
|
|
|
9
8
|
allow: "/",
|
|
10
9
|
disallow: [],
|
|
11
10
|
},
|
|
12
|
-
sitemap: `${
|
|
11
|
+
sitemap: `${getBaseUrl()}/sitemap.xml`,
|
|
13
12
|
}
|
|
14
13
|
}
|
|
@@ -64,8 +64,10 @@ const nextConfig: NextConfig = {
|
|
|
64
64
|
},
|
|
65
65
|
},
|
|
66
66
|
},
|
|
67
|
+
logging: {
|
|
68
|
+
browserToTerminal: true,
|
|
69
|
+
},
|
|
67
70
|
experimental: {
|
|
68
|
-
browserDebugInfoInTerminal: true,
|
|
69
71
|
optimizePackageImports: [
|
|
70
72
|
"@react-three/drei",
|
|
71
73
|
"@react-three/fiber",
|
|
@@ -86,7 +88,8 @@ const nextConfig: NextConfig = {
|
|
|
86
88
|
},
|
|
87
89
|
headers: async () => [
|
|
88
90
|
{
|
|
89
|
-
|
|
91
|
+
// Excludes /studio* — must stay mutually exclusive with the rule below.
|
|
92
|
+
source: "/((?!studio(?:/|$)).*)",
|
|
90
93
|
headers: [
|
|
91
94
|
{
|
|
92
95
|
key: "X-Content-Type-Options",
|
|
@@ -118,6 +121,37 @@ const nextConfig: NextConfig = {
|
|
|
118
121
|
},
|
|
119
122
|
],
|
|
120
123
|
},
|
|
124
|
+
{
|
|
125
|
+
// X-Frame-Options can't allow a third-party origin, and Next can't unset
|
|
126
|
+
// it from another rule — the iframe needs it absent, not overridden.
|
|
127
|
+
source: "/studio/:path*",
|
|
128
|
+
headers: [
|
|
129
|
+
{
|
|
130
|
+
key: "X-Content-Type-Options",
|
|
131
|
+
value: "nosniff",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
key: "Content-Security-Policy",
|
|
135
|
+
value: "frame-ancestors 'self' https://*.sanity.io;",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
key: "X-XSS-Protection",
|
|
139
|
+
value: "1; mode=block",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
key: "X-DNS-Prefetch-Control",
|
|
143
|
+
value: "on",
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
key: "Strict-Transport-Security",
|
|
147
|
+
value: "max-age=63072000; includeSubDomains; preload",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
key: "Permissions-Policy",
|
|
151
|
+
value: "camera=(), microphone=(), geolocation=()",
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
},
|
|
121
155
|
],
|
|
122
156
|
redirects: async () => [
|
|
123
157
|
{
|
|
@@ -31,31 +31,31 @@
|
|
|
31
31
|
"class-variance-authority": "^0.7.1",
|
|
32
32
|
"clsx": "^2.1.1",
|
|
33
33
|
"lucide-react": "^0.474.0",
|
|
34
|
-
"next": "^16",
|
|
35
|
-
"react": "^19",
|
|
36
|
-
"react-dom": "^19",
|
|
34
|
+
"next": "^16.3.0",
|
|
35
|
+
"react": "^19.2.8",
|
|
36
|
+
"react-dom": "^19.2.8",
|
|
37
37
|
"tailwind-merge": "^3.4.0",
|
|
38
38
|
"three": "^0.182.0",
|
|
39
39
|
"zod": "^4.3.6"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@biomejs/biome": "^2.4.14",
|
|
43
|
-
"@next/bundle-analyzer": "^16",
|
|
43
|
+
"@next/bundle-analyzer": "^16.3.0",
|
|
44
44
|
"@svgr/webpack": "^8.1.0",
|
|
45
|
-
"@tailwindcss/postcss": "^4",
|
|
45
|
+
"@tailwindcss/postcss": "^4.2.2",
|
|
46
46
|
"@types/bun": "^1.3.6",
|
|
47
47
|
"@types/node": "^24.13.1",
|
|
48
|
-
"@types/react": "^19.2.
|
|
48
|
+
"@types/react": "^19.2.14",
|
|
49
49
|
"@types/react-dom": "^19.2.3",
|
|
50
|
-
"@typescript/native-preview": "^7.0.0-dev.
|
|
50
|
+
"@typescript/native-preview": "^7.0.0-dev.20260323.1",
|
|
51
51
|
"babel-plugin-react-compiler": "^1.0.0",
|
|
52
52
|
"cross-env": "^10.1.0",
|
|
53
53
|
"knip": "^6.16.1",
|
|
54
54
|
"lefthook": "^1.13.0",
|
|
55
55
|
"postcss-preset-env": "^10.6.1",
|
|
56
56
|
"schema-dts": "^2.0.0",
|
|
57
|
-
"tailwindcss": "^4",
|
|
58
|
-
"typescript": "^5"
|
|
57
|
+
"tailwindcss": "^4.2.2",
|
|
58
|
+
"typescript": "^5.9.3"
|
|
59
59
|
},
|
|
60
60
|
"trustedDependencies": [
|
|
61
61
|
"@biomejs/biome",
|
|
@@ -64,6 +64,37 @@ Copy `.env.example` to `.env.local` and fill in the values you need. `.env.local
|
|
|
64
64
|
For deploys, set the same variables in your host's dashboard (e.g. Vercel → Settings → Environment Variables).
|
|
65
65
|
|
|
66
66
|
<!-- bsmnt:endif -->
|
|
67
|
+
## Sanity Dashboard (Studio in an iframe)
|
|
68
|
+
|
|
69
|
+
The Studio at `/studio` can be embedded in the Sanity Dashboard
|
|
70
|
+
(`https://www.sanity.io/@<org>/studio/<appId>/<workspace>`), which loads it in an
|
|
71
|
+
iframe from `www.sanity.io`. Three things have to be true for that to work:
|
|
72
|
+
|
|
73
|
+
1. **Headers.** `next.config.ts` already exempts `/studio/:path*` from the
|
|
74
|
+
site-wide `X-Frame-Options` / `frame-ancestors 'self'` headers, sending
|
|
75
|
+
`frame-ancestors 'self' https://*.sanity.io` there instead and dropping
|
|
76
|
+
`X-Frame-Options` entirely for that path (it has no "allow this origin"
|
|
77
|
+
form — it must be absent, not overridden). Nothing to do here unless
|
|
78
|
+
you've hand-edited `headers()`.
|
|
79
|
+
2. **Bridge script.** Already wired in: `next-sanity/studio`'s `NextStudio`
|
|
80
|
+
re-exports `NextStudioWithBridge`, which renders the
|
|
81
|
+
`core.sanity-cdn.com/bridge.js` tag the Dashboard needs. Don't hand-roll
|
|
82
|
+
that script tag, and don't switch the Studio route to
|
|
83
|
+
`next-sanity/studio/client-component` (the bridge-less variant).
|
|
84
|
+
3. **Registered URL.** In [Sanity Manage](https://sanity.io/manage), the
|
|
85
|
+
Studio application's URL must be the **full path**
|
|
86
|
+
(`https://example.com/studio`), not just the domain. This can't be set
|
|
87
|
+
from code — either click through Manage, or run
|
|
88
|
+
`sanity deploy --external --url https://example.com/studio --yes` from
|
|
89
|
+
`lib/integrations/sanity`. Writing it via the API needs the
|
|
90
|
+
`sanity.project.deployStudio` grant, which read/write tokens don't have.
|
|
91
|
+
|
|
92
|
+
Verify against a production build (`bun build && bun start`), not dev. One trap:
|
|
93
|
+
the Dashboard reports `Failed to load iframe due to X-Frame-Options: SAMEORIGIN`
|
|
94
|
+
for *any* framing failure, including a wrong registered URL with already-correct
|
|
95
|
+
headers. Trust the browser console's `Framing '<url>' violates …` line instead —
|
|
96
|
+
it names the URL actually being framed.
|
|
97
|
+
|
|
67
98
|
## Continuous Integration
|
|
68
99
|
|
|
69
100
|
A GitHub Actions workflow (`.github/workflows/ci.yml`) runs on every pull request to `main` and on pushes to `main`. It runs type checking, linting, the production build, tests, and `knip`.
|
|
@@ -22,8 +22,14 @@ const getPageDocument = async (slug: string | null, stega?: boolean) =>
|
|
|
22
22
|
stega,
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
const
|
|
26
|
-
|
|
25
|
+
const isValidSegment = (segment: string) =>
|
|
26
|
+
segment.length > 0 && segment !== "." && segment !== ".."
|
|
27
|
+
|
|
28
|
+
const toPath = (segments?: string[]) => {
|
|
29
|
+
if (!segments?.length) return null
|
|
30
|
+
if (!segments.every(isValidSegment)) return null
|
|
31
|
+
return segments.join("/")
|
|
32
|
+
}
|
|
27
33
|
|
|
28
34
|
const getResolvedPage = async (
|
|
29
35
|
path: string | null,
|
|
@@ -35,18 +41,21 @@ const getResolvedPage = async (
|
|
|
35
41
|
|
|
36
42
|
type SearchParams = Promise<{ [key: string]: string | string[] | undefined }>
|
|
37
43
|
|
|
44
|
+
const HOMEPAGE_PARAM: { slug: string[] } = { slug: [] }
|
|
45
|
+
|
|
38
46
|
export const generateStaticParams = async () => {
|
|
39
|
-
if (!client) return [
|
|
47
|
+
if (!client) return [HOMEPAGE_PARAM]
|
|
40
48
|
|
|
41
49
|
const slugs =
|
|
42
50
|
await client.fetch<ALL_PAGE_SLUGS_QUERY_RESULT>(ALL_PAGE_SLUGS_QUERY)
|
|
43
51
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
return [
|
|
53
|
+
HOMEPAGE_PARAM,
|
|
54
|
+
...(slugs ?? []).flatMap((page) => {
|
|
55
|
+
if (!page.slug || page.slug === "/") return []
|
|
56
|
+
return { slug: page.slug.split("/") }
|
|
57
|
+
}),
|
|
58
|
+
]
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
type Props = { params: Promise<{ slug?: string[] }> }
|
|
@@ -54,6 +63,9 @@ type Props = { params: Promise<{ slug?: string[] }> }
|
|
|
54
63
|
export const generateMetadata = async ({ params }: Props) => {
|
|
55
64
|
const { slug } = await params
|
|
56
65
|
const path = toPath(slug)
|
|
66
|
+
|
|
67
|
+
if (slug?.length && path === null) return {}
|
|
68
|
+
|
|
57
69
|
const page = await getResolvedPage(path, false)
|
|
58
70
|
|
|
59
71
|
return page
|
|
@@ -70,6 +82,9 @@ export default async function CatchAllPage({
|
|
|
70
82
|
const { slug } = await params
|
|
71
83
|
|
|
72
84
|
const path = toPath(slug)
|
|
85
|
+
|
|
86
|
+
if (slug?.length && path === null) notFound()
|
|
87
|
+
|
|
73
88
|
const page = await getResolvedPage(path)
|
|
74
89
|
|
|
75
90
|
if (!page) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { MetadataRoute } from "next"
|
|
2
|
-
|
|
3
|
-
const APP_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"
|
|
2
|
+
import { getBaseUrl } from "@/lib/utils/base-url"
|
|
4
3
|
|
|
5
4
|
export default function robots(): MetadataRoute.Robots {
|
|
6
5
|
return {
|
|
@@ -9,6 +8,6 @@ export default function robots(): MetadataRoute.Robots {
|
|
|
9
8
|
allow: "/",
|
|
10
9
|
disallow: [],
|
|
11
10
|
},
|
|
12
|
-
sitemap: `${
|
|
11
|
+
sitemap: `${getBaseUrl()}/sitemap.xml`,
|
|
13
12
|
}
|
|
14
13
|
}
|
|
@@ -51,8 +51,11 @@ const renderSitemap = (entries: SitemapEntry[]) =>
|
|
|
51
51
|
"</urlset>",
|
|
52
52
|
].join("\n")
|
|
53
53
|
|
|
54
|
-
const getSitemapResponse = (entries: SitemapEntry[]) =>
|
|
55
|
-
new NextResponse(renderSitemap(entries)
|
|
54
|
+
const getSitemapResponse = (entries: SitemapEntry[], status = 200) =>
|
|
55
|
+
new NextResponse(renderSitemap(entries), {
|
|
56
|
+
status,
|
|
57
|
+
headers: { "Content-Type": "application/xml; charset=utf-8" },
|
|
58
|
+
})
|
|
56
59
|
|
|
57
60
|
export const GET = async () => {
|
|
58
61
|
const baseUrl = getSitemapBaseUrl()
|
|
@@ -75,6 +78,6 @@ export const GET = async () => {
|
|
|
75
78
|
)
|
|
76
79
|
} catch (error) {
|
|
77
80
|
console.error("Error generating sitemap.xml:", error)
|
|
78
|
-
return getSitemapResponse([homepageEntry])
|
|
81
|
+
return getSitemapResponse([homepageEntry], 503)
|
|
79
82
|
}
|
|
80
83
|
}
|