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.
Files changed (49) hide show
  1. package/dist/application/add-integration/index.d.ts.map +1 -1
  2. package/dist/application/add-integration/index.js +19 -2
  3. package/dist/application/add-integration/index.js.map +1 -1
  4. package/dist/infrastructure/integrations/ensure-studio-headers.d.ts +6 -0
  5. package/dist/infrastructure/integrations/ensure-studio-headers.d.ts.map +1 -0
  6. package/dist/infrastructure/integrations/ensure-studio-headers.js +153 -0
  7. package/dist/infrastructure/integrations/ensure-studio-headers.js.map +1 -0
  8. package/dist/modules/features/cms/sanity/config.d.ts.map +1 -1
  9. package/dist/modules/features/cms/sanity/config.js +2 -1
  10. package/dist/modules/features/cms/sanity/config.js.map +1 -1
  11. package/dist/modules/features/cms/sanity-pagebuilder/config.d.ts.map +1 -1
  12. package/dist/modules/features/cms/sanity-pagebuilder/config.js +2 -1
  13. package/dist/modules/features/cms/sanity-pagebuilder/config.js.map +1 -1
  14. package/package.json +2 -2
  15. package/src/modules/features/cms/sanity/files/app/api/blog/[slug]/route.ts +4 -1
  16. package/src/modules/features/cms/sanity/files/app/sitemap.md/route.ts +2 -1
  17. package/src/modules/features/cms/sanity/files/components/ui/sanity-image/index.tsx +19 -5
  18. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/README.md +31 -0
  19. package/src/modules/features/cms/sanity/files/proxy.ts +9 -0
  20. package/src/modules/features/cms/sanity-pagebuilder/files/app/api/[[...slug]]/route.ts +8 -3
  21. package/src/modules/features/cms/sanity-pagebuilder/files/app/sitemap.md/route.ts +7 -1
  22. package/src/modules/features/cms/sanity-pagebuilder/files/components/ui/sanity-image/index.tsx +15 -5
  23. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/slug-field.tsx +13 -7
  24. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/slug-notices.tsx +135 -0
  25. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/live/index.tsx +1 -1
  26. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/markdown-proxy.config.ts +1 -1
  27. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/redirect.ts +35 -4
  28. package/src/modules/features/cms/sanity-pagebuilder/files/proxy.ts +9 -0
  29. package/src/templates/next-default/.env.example +2 -0
  30. package/src/templates/next-default/app/robots.ts +2 -3
  31. package/src/templates/next-default/next.config.ts +36 -2
  32. package/src/templates/next-default/package.json +9 -9
  33. package/src/templates/next-experiments/.env.example +2 -0
  34. package/src/templates/next-experiments/app/robots.ts +2 -3
  35. package/src/templates/next-experiments/next.config.ts +36 -2
  36. package/src/templates/next-experiments/package.json +9 -9
  37. package/src/templates/next-pagebuilder/.env.example +2 -0
  38. package/src/templates/next-pagebuilder/README.md +31 -0
  39. package/src/templates/next-pagebuilder/app/(content)/[[...slug]]/page.tsx +24 -9
  40. package/src/templates/next-pagebuilder/app/robots.ts +2 -3
  41. package/src/templates/next-pagebuilder/app/sitemap.xml/route.ts +6 -3
  42. package/src/templates/next-pagebuilder/components/page-builder/components/content-collection/skeleton.tsx +53 -0
  43. package/src/templates/next-pagebuilder/components/page-builder/renderer.tsx +7 -1
  44. package/src/templates/next-pagebuilder/next.config.ts +33 -2
  45. package/src/templates/next-pagebuilder/package.json +4 -4
  46. package/src/templates/next-webgl/.env.example +2 -0
  47. package/src/templates/next-webgl/app/robots.ts +2 -3
  48. package/src/templates/next-webgl/next.config.ts +36 -2
  49. 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 to a locked-state note on pinned pages.
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 isPinned = getPinnedPageById(documentId) !== null;
13
+ const pinned = getPinnedPageById(documentId);
13
14
 
14
- return props.renderDefault({
15
- ...props,
16
- description: isPinned ? PINNED_DESCRIPTION : DEFAULT_DESCRIPTION,
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
+ }
@@ -25,7 +25,7 @@ type SanityFetchOptions = {
25
25
  query: string;
26
26
  params?: QueryParams;
27
27
  tags?: string[];
28
- stega?: boolean;
28
+ stega?: boolean | undefined;
29
29
  perspective?: "published" | "drafts" | "previewDrafts";
30
30
  };
31
31
 
@@ -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: /^\/(.+)\.md$/,
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 source = (
83
- context.document as RedirectValidationDocument | undefined
84
- )?.source?.current;
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: `${APP_BASE_URL}/sitemap.xml`,
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
- source: "/(.*)",
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.9",
46
+ "@types/react": "^19.2.14",
47
47
  "@types/react-dom": "^19.2.3",
48
- "@typescript/native-preview": "^7.0.0-dev.20260122.3",
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: `${APP_BASE_URL}/sitemap.xml`,
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
- source: "/(.*)",
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.9",
48
+ "@types/react": "^19.2.14",
49
49
  "@types/react-dom": "^19.2.3",
50
- "@typescript/native-preview": "^7.0.0-dev.20260122.3",
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",
@@ -25,6 +25,8 @@
25
25
  # bsmnt:endif
26
26
 
27
27
  # Base URL
28
+ # Inlined at build time — scope this to the Production environment only in
29
+ # Vercel so Preview deployments fall through to VERCEL_PROJECT_PRODUCTION_URL.
28
30
  NEXT_PUBLIC_BASE_URL=
29
31
 
30
32
  # Draft Mode
@@ -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 toPath = (segments?: string[]) =>
26
- segments?.filter(Boolean).join("/") || null
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 [{ slug: [] }]
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
- if (!slugs?.length) return [{ slug: [] }]
45
-
46
- return (slugs ?? []).flatMap((page) => {
47
- if (!page.slug) return []
48
- return page.slug === "/" ? { slug: [] } : { slug: page.slug.split("/") }
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: `${APP_BASE_URL}/sitemap.xml`,
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
  }