bsmnt 0.7.1 → 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 (121) hide show
  1. package/dist/application/add-hooks/index.d.ts.map +1 -1
  2. package/dist/application/add-hooks/index.js +1 -0
  3. package/dist/application/add-hooks/index.js.map +1 -1
  4. package/dist/application/add-integration/index.d.ts.map +1 -1
  5. package/dist/application/add-integration/index.js +20 -2
  6. package/dist/application/add-integration/index.js.map +1 -1
  7. package/dist/core/create/execute-plan.d.ts +2 -0
  8. package/dist/core/create/execute-plan.d.ts.map +1 -1
  9. package/dist/core/create/execute-plan.js +3 -0
  10. package/dist/core/create/execute-plan.js.map +1 -1
  11. package/dist/domain/version.d.ts +14 -0
  12. package/dist/domain/version.d.ts.map +1 -0
  13. package/dist/domain/version.js +81 -0
  14. package/dist/domain/version.js.map +1 -0
  15. package/dist/index.js +3 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/infrastructure/create/executor.d.ts.map +1 -1
  18. package/dist/infrastructure/create/executor.js +2 -0
  19. package/dist/infrastructure/create/executor.js.map +1 -1
  20. package/dist/infrastructure/create/render-generated-docs.d.ts +18 -0
  21. package/dist/infrastructure/create/render-generated-docs.d.ts.map +1 -0
  22. package/dist/infrastructure/create/render-generated-docs.js +72 -0
  23. package/dist/infrastructure/create/render-generated-docs.js.map +1 -0
  24. package/dist/infrastructure/create/setup-dotenvx.d.ts.map +1 -1
  25. package/dist/infrastructure/create/setup-dotenvx.js +2 -1
  26. package/dist/infrastructure/create/setup-dotenvx.js.map +1 -1
  27. package/dist/infrastructure/create/setup-remote.d.ts +6 -1
  28. package/dist/infrastructure/create/setup-remote.d.ts.map +1 -1
  29. package/dist/infrastructure/create/setup-remote.js +7 -2
  30. package/dist/infrastructure/create/setup-remote.js.map +1 -1
  31. package/dist/infrastructure/integrations/ensure-studio-headers.d.ts +6 -0
  32. package/dist/infrastructure/integrations/ensure-studio-headers.d.ts.map +1 -0
  33. package/dist/infrastructure/integrations/ensure-studio-headers.js +153 -0
  34. package/dist/infrastructure/integrations/ensure-studio-headers.js.map +1 -0
  35. package/dist/infrastructure/update/cache.d.ts +14 -0
  36. package/dist/infrastructure/update/cache.d.ts.map +1 -0
  37. package/dist/infrastructure/update/cache.js +41 -0
  38. package/dist/infrastructure/update/cache.js.map +1 -0
  39. package/dist/infrastructure/update/check-version.d.ts +2 -0
  40. package/dist/infrastructure/update/check-version.d.ts.map +1 -0
  41. package/dist/infrastructure/update/check-version.js +33 -0
  42. package/dist/infrastructure/update/check-version.js.map +1 -0
  43. package/dist/infrastructure/update/notifier.d.ts +8 -0
  44. package/dist/infrastructure/update/notifier.d.ts.map +1 -0
  45. package/dist/infrastructure/update/notifier.js +141 -0
  46. package/dist/infrastructure/update/notifier.js.map +1 -0
  47. package/dist/modules/features/cms/sanity/config.d.ts.map +1 -1
  48. package/dist/modules/features/cms/sanity/config.js +2 -1
  49. package/dist/modules/features/cms/sanity/config.js.map +1 -1
  50. package/dist/modules/features/cms/sanity-pagebuilder/config.d.ts.map +1 -1
  51. package/dist/modules/features/cms/sanity-pagebuilder/config.js +4 -1
  52. package/dist/modules/features/cms/sanity-pagebuilder/config.js.map +1 -1
  53. package/package.json +2 -2
  54. package/src/modules/features/cms/sanity/files/app/api/blog/[slug]/route.ts +4 -1
  55. package/src/modules/features/cms/sanity/files/app/sitemap.md/route.ts +2 -1
  56. package/src/modules/features/cms/sanity/files/components/ui/sanity-image/index.tsx +19 -5
  57. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/README.md +31 -0
  58. package/src/modules/features/cms/sanity/files/proxy.ts +9 -0
  59. package/src/modules/features/cms/sanity-pagebuilder/files/app/api/[[...slug]]/route.ts +8 -3
  60. package/src/modules/features/cms/sanity-pagebuilder/files/app/sitemap.md/route.ts +7 -1
  61. package/src/modules/features/cms/sanity-pagebuilder/files/components/ui/sanity-image/index.tsx +15 -5
  62. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/block-actions-item.tsx +81 -0
  63. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/slug-field.tsx +13 -7
  64. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/slug-notices.tsx +135 -0
  65. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/icons.ts +2 -0
  66. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/live/index.tsx +1 -1
  67. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/markdown-proxy.config.ts +1 -1
  68. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +7 -0
  69. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +72 -21
  70. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/index.ts +1 -0
  71. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/gated-content.ts +178 -0
  72. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts +9 -1
  73. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/redirect.ts +35 -4
  74. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/metadata.ts +54 -1
  75. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page-builder.ts +4 -0
  76. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page.ts +11 -0
  77. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +4 -0
  78. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/utils/page-builder-markdown.ts +20 -0
  79. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/metadata.ts +27 -5
  80. package/src/modules/features/cms/sanity-pagebuilder/files/proxy.ts +9 -0
  81. package/src/modules/features/env/dotenvx/files/setup-remote.ts +3 -1
  82. package/src/templates/next-default/.env.example +10 -0
  83. package/src/templates/next-default/.github/workflows/ci.yml +7 -0
  84. package/src/templates/next-default/README.md +14 -0
  85. package/src/templates/next-default/app/robots.ts +2 -3
  86. package/src/templates/next-default/knip.json +8 -6
  87. package/src/templates/next-default/lefthook.yml +0 -5
  88. package/src/templates/next-default/next.config.ts +36 -2
  89. package/src/templates/next-default/package.json +9 -9
  90. package/src/templates/next-experiments/.env.example +10 -0
  91. package/src/templates/next-experiments/.github/workflows/ci.yml +7 -0
  92. package/src/templates/next-experiments/README.md +14 -0
  93. package/src/templates/next-experiments/app/robots.ts +2 -3
  94. package/src/templates/next-experiments/knip.json +8 -6
  95. package/src/templates/next-experiments/lefthook.yml +0 -5
  96. package/src/templates/next-experiments/next.config.ts +36 -2
  97. package/src/templates/next-experiments/package.json +9 -9
  98. package/src/templates/next-pagebuilder/.env.example +10 -0
  99. package/src/templates/next-pagebuilder/.github/PULL_REQUEST_TEMPLATE.md +13 -0
  100. package/src/templates/next-pagebuilder/.github/workflows/ci.yml +15 -0
  101. package/src/templates/next-pagebuilder/README.md +45 -0
  102. package/src/templates/next-pagebuilder/app/(content)/[[...slug]]/page.tsx +26 -11
  103. package/src/templates/next-pagebuilder/app/actions/unlock-gated-content.ts +39 -0
  104. package/src/templates/next-pagebuilder/app/robots.ts +2 -3
  105. package/src/templates/next-pagebuilder/app/sitemap.xml/route.ts +6 -3
  106. package/src/templates/next-pagebuilder/components/page-builder/components/content-collection/skeleton.tsx +53 -0
  107. package/src/templates/next-pagebuilder/components/page-builder/components/gated-content/index.tsx +169 -0
  108. package/src/templates/next-pagebuilder/components/page-builder/renderer.tsx +10 -1
  109. package/src/templates/next-pagebuilder/knip.json +8 -6
  110. package/src/templates/next-pagebuilder/lefthook.yml +0 -5
  111. package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +81 -0
  112. package/src/templates/next-pagebuilder/next.config.ts +33 -2
  113. package/src/templates/next-pagebuilder/package.json +4 -4
  114. package/src/templates/next-webgl/.env.example +10 -0
  115. package/src/templates/next-webgl/.github/workflows/ci.yml +7 -0
  116. package/src/templates/next-webgl/README.md +14 -0
  117. package/src/templates/next-webgl/app/robots.ts +2 -3
  118. package/src/templates/next-webgl/knip.json +8 -6
  119. package/src/templates/next-webgl/lefthook.yml +0 -5
  120. package/src/templates/next-webgl/next.config.ts +36 -2
  121. package/src/templates/next-webgl/package.json +9 -9
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../../../src/modules/features/cms/sanity-pagebuilder/config.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,kCAAkC;IAClD,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,aAAa,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,eAAO,MAAM,uBAAuB,EAAE,kCA6CrC,CAAC"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../../../src/modules/features/cms/sanity-pagebuilder/config.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,kCAAkC;IAClD,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,aAAa,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,eAAO,MAAM,uBAAuB,EAAE,kCAgDrC,CAAC"}
@@ -9,15 +9,18 @@ export const sanityPagebuilderConfig = {
9
9
  "@phosphor-icons/react": "^2.1.10",
10
10
  "@portabletext/types": "^4.0.2",
11
11
  "@sanity/asset-utils": "^2.3.0",
12
+ "@sanity/icons": "^3.7.4",
12
13
  "@sanity/image-url": "^2.1.1",
13
14
  "@sanity/table": "^2.0.1",
15
+ "@sanity/ui": "^3.1.14",
14
16
  "@sanity/vision": "^5.30.0",
15
17
  "@sanity/visual-editing": "^5.4.3",
16
- "next-sanity": "^13.0.10",
18
+ "next-sanity": "^13.3.1",
17
19
  sanity: "^5.30.0",
18
20
  "sanity-plugin-media": "^4.1.1",
19
21
  },
20
22
  devDependencies: {
23
+ "@sanity/browserslist-config": "^1.0.5",
21
24
  "schema-dts": "^2.0.0",
22
25
  },
23
26
  scripts: {
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../../../../../src/modules/features/cms/sanity-pagebuilder/config.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAUH,MAAM,CAAC,MAAM,uBAAuB,GAAuC;IAC1E,IAAI,EAAE,oBAAoB;IAE1B,YAAY,EAAE;QACb,uBAAuB,EAAE,SAAS;QAClC,qBAAqB,EAAE,QAAQ;QAC/B,qBAAqB,EAAE,QAAQ;QAC/B,mBAAmB,EAAE,QAAQ;QAC7B,eAAe,EAAE,QAAQ;QACzB,gBAAgB,EAAE,SAAS;QAC3B,wBAAwB,EAAE,QAAQ;QAClC,aAAa,EAAE,UAAU;QACzB,MAAM,EAAE,SAAS;QACjB,qBAAqB,EAAE,QAAQ;KAC/B;IAED,eAAe,EAAE;QAChB,YAAY,EAAE,QAAQ;KACtB;IAED,OAAO,EAAE;QACR,gBAAgB,EACf,wFAAwF;QACzF,gBAAgB,EAAE,qCAAqC;QACvD,MAAM,EAAE,wBAAwB;QAChC,QAAQ,EAAE,wBAAwB;KAClC;IAED,aAAa,EAAE;QACd,yBAAyB;QACzB,uCAAuC;QACvC,6BAA6B;QAC7B,uBAAuB;QACvB,uBAAuB;QACvB,kBAAkB;QAClB,sBAAsB;QACtB,+BAA+B;QAC/B,YAAY;QACZ,SAAS;QACT,cAAc;QACd,gBAAgB;QAChB,UAAU;QACV,mBAAmB;QACnB,4BAA4B;KAC5B;CACD,CAAC"}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../../../../src/modules/features/cms/sanity-pagebuilder/config.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAUH,MAAM,CAAC,MAAM,uBAAuB,GAAuC;IAC1E,IAAI,EAAE,oBAAoB;IAE1B,YAAY,EAAE;QACb,uBAAuB,EAAE,SAAS;QAClC,qBAAqB,EAAE,QAAQ;QAC/B,qBAAqB,EAAE,QAAQ;QAC/B,eAAe,EAAE,QAAQ;QACzB,mBAAmB,EAAE,QAAQ;QAC7B,eAAe,EAAE,QAAQ;QACzB,YAAY,EAAE,SAAS;QACvB,gBAAgB,EAAE,SAAS;QAC3B,wBAAwB,EAAE,QAAQ;QAClC,aAAa,EAAE,SAAS;QACxB,MAAM,EAAE,SAAS;QACjB,qBAAqB,EAAE,QAAQ;KAC/B;IAED,eAAe,EAAE;QAChB,6BAA6B,EAAE,QAAQ;QACvC,YAAY,EAAE,QAAQ;KACtB;IAED,OAAO,EAAE;QACR,gBAAgB,EACf,wFAAwF;QACzF,gBAAgB,EAAE,qCAAqC;QACvD,MAAM,EAAE,wBAAwB;QAChC,QAAQ,EAAE,wBAAwB;KAClC;IAED,aAAa,EAAE;QACd,yBAAyB;QACzB,uCAAuC;QACvC,6BAA6B;QAC7B,uBAAuB;QACvB,uBAAuB;QACvB,kBAAkB;QAClB,sBAAsB;QACtB,+BAA+B;QAC/B,YAAY;QACZ,SAAS;QACT,cAAc;QACd,gBAAgB;QAChB,UAAU;QACV,mBAAmB;QACnB,4BAA4B;KAC5B;CACD,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bsmnt",
3
- "version": "0.7.1",
3
+ "version": "0.10.0",
4
4
  "packageManager": "bun@1.3.14",
5
5
  "description": "CLI to scaffold basement projects and add integrations",
6
6
  "type": "module",
@@ -81,7 +81,7 @@
81
81
  "commander": "^13.1.0",
82
82
  "dotenv": "^17.4.2",
83
83
  "fs-extra": "^11.3.3",
84
- "next-sanity": "^13.0.10",
84
+ "next-sanity": "^13.3.1",
85
85
  "ora": "^9.1.0",
86
86
  "picocolors": "^1.1.1",
87
87
  "prompts": "^2.4.2",
@@ -4,6 +4,7 @@ import { SanityFetch } from "@/lib/integrations/sanity/live";
4
4
  import { ARTICLE_QUERY } from "@/lib/integrations/sanity/queries";
5
5
  import type { Article } from "@/lib/integrations/sanity/sanity.types";
6
6
  import { portableTextToMarkdown } from "@/lib/utils/portable-text-to-markdown";
7
+ import { getBaseUrl } from "@/lib/utils/url";
7
8
 
8
9
  export async function GET(
9
10
  _request: Request,
@@ -16,7 +17,8 @@ export async function GET(
16
17
  }
17
18
 
18
19
  const slug = rawSlug.slice(0, -3);
19
- const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";
20
+ const baseUrl = getBaseUrl().replace(/\/$/, "");
21
+ const canonicalPath = `/blog/${slug}`;
20
22
 
21
23
  if (!isSanityConfigured()) {
22
24
  return new NextResponse("# 404 Not Found\n\nCMS not configured.", {
@@ -64,6 +66,7 @@ export async function GET(
64
66
  "Content-Type": "text/markdown; charset=utf-8",
65
67
  Vary: "Accept",
66
68
  "X-Content-Type-Options": "nosniff",
69
+ Link: `<${baseUrl}${canonicalPath}>; rel="canonical"`,
67
70
  },
68
71
  });
69
72
  } catch (error) {
@@ -1,8 +1,9 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { isSanityConfigured } from "@/lib/integrations/check-integration";
3
+ import { getBaseUrl } from "@/lib/utils/url";
3
4
 
4
5
  export async function GET() {
5
- const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";
6
+ const baseUrl = getBaseUrl();
6
7
 
7
8
  if (!isSanityConfigured()) {
8
9
  return new NextResponse(
@@ -4,13 +4,13 @@ import { urlForImage } from "@/lib/integrations/sanity/utils/image";
4
4
 
5
5
  interface SanityImageProps extends Omit<ImageProps, "src" | "aspectRatio"> {
6
6
  image: {
7
- asset: {
7
+ asset?: {
8
8
  _ref: string;
9
9
  _type: "reference";
10
10
  };
11
11
  alt?: string;
12
12
  hotspot?: object;
13
- crop?: object;
13
+ crop?: { left?: number; right?: number; top?: number; bottom?: number };
14
14
  };
15
15
  maxWidth?: number;
16
16
  }
@@ -19,18 +19,32 @@ export function SanityImage({
19
19
  image,
20
20
  maxWidth = 1920,
21
21
  alt,
22
+ fill,
22
23
  ...props
23
24
  }: SanityImageProps) {
24
25
  if (!image?.asset) return null;
25
26
 
27
+ // getImageDimensions is uncropped; the URL builder applies the crop.
28
+ // Rounding order must match @sanity/image-url: origin first, then subtract.
26
29
  const { width, height } = getImageDimensions(image.asset);
27
- const aspectRatio = width / height;
30
+ const { left = 0, right = 0, top = 0, bottom = 0 } = image.crop ?? {};
31
+ const cropLeft = Math.round(left * width);
32
+ const cropTop = Math.round(top * height);
33
+ const sourceWidth = Math.round(width - right * width - cropLeft);
34
+ const sourceHeight = Math.round(height - bottom * height - cropTop);
35
+
36
+ const imageProps = fill
37
+ ? { fill: true }
38
+ : { width: sourceWidth, height: sourceHeight };
28
39
 
29
40
  return (
30
41
  <Image
31
- src={urlForImage(image).width(maxWidth).url()}
42
+ src={urlForImage(image)
43
+ .width(Math.min(maxWidth, sourceWidth))
44
+ .url()}
32
45
  alt={alt || image.alt || ""}
33
- aspectRatio={aspectRatio}
46
+ aspectRatio={sourceWidth / sourceHeight}
47
+ {...imageProps}
34
48
  {...props}
35
49
  />
36
50
  );
@@ -128,6 +128,37 @@ export const landing = defineType({
128
128
 
129
129
  See [ARCHITECTURE.md](../../../ARCHITECTURE.md) for cache gotchas.
130
130
 
131
+ ## Sanity Dashboard (Studio in an iframe)
132
+
133
+ The Studio at `/studio` can be embedded in the Sanity Dashboard
134
+ (`https://www.sanity.io/@<org>/studio/<appId>/<workspace>`), which loads it in an
135
+ iframe from `www.sanity.io`. Three things have to be true for that to work:
136
+
137
+ 1. **Headers.** `next.config.ts` already exempts `/studio/:path*` from the
138
+ site-wide `X-Frame-Options` / `frame-ancestors 'self'` headers, sending
139
+ `frame-ancestors 'self' https://*.sanity.io` there instead and dropping
140
+ `X-Frame-Options` entirely for that path (it has no "allow this origin"
141
+ form — it must be absent, not overridden). Nothing to do here unless
142
+ you've hand-edited `headers()`.
143
+ 2. **Bridge script.** Already wired in: `next-sanity/studio`'s `NextStudio`
144
+ re-exports `NextStudioWithBridge`, which renders the
145
+ `core.sanity-cdn.com/bridge.js` tag the Dashboard needs. Don't hand-roll
146
+ that script tag, and don't switch the Studio route to
147
+ `next-sanity/studio/client-component` (the bridge-less variant).
148
+ 3. **Registered URL.** In [Sanity Manage](https://sanity.io/manage), the
149
+ Studio application's URL must be the **full path**
150
+ (`https://example.com/studio`), not just the domain. This can't be set
151
+ from code — either click through Manage, or run
152
+ `sanity deploy --external --url https://example.com/studio --yes` from
153
+ `lib/integrations/sanity`. Writing it via the API needs the
154
+ `sanity.project.deployStudio` grant, which read/write tokens don't have.
155
+
156
+ Verify against a production build (`bun build && bun start`), not dev. One trap:
157
+ the Dashboard reports `Failed to load iframe due to X-Frame-Options: SAMEORIGIN`
158
+ for *any* framing failure, including a wrong registered URL with already-correct
159
+ headers. Trust the browser console's `Framing '<url>' violates …` line instead —
160
+ it names the URL actually being framed.
161
+
131
162
  ## Troubleshooting
132
163
 
133
164
  **Visual editor not loading:**
@@ -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. /blog//foo.md silently resolving to /blog/foo.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
  // 1. Rewrite .md extension URLs to API routes
18
27
  // e.g. /blog/my-post.md → /api/blog/my-post.md
19
28
  for (const route of mdExtensionRoutes) {
@@ -8,6 +8,9 @@ import { getBaseUrl } from "@/lib/utils/base-url";
8
8
 
9
9
  type PageResult = NonNullable<PAGE_QUERY_RESULT>;
10
10
 
11
+ const isValidSegment = (segment: string) =>
12
+ segment.length > 0 && segment !== "." && segment !== "..";
13
+
11
14
  const getSlugPath = (slugParts?: string[]) => {
12
15
  if (!slugParts?.length) return null;
13
16
 
@@ -18,8 +21,9 @@ const getSlugPath = (slugParts?: string[]) => {
18
21
  const pathSegments = [...slugParts];
19
22
  pathSegments[pathSegments.length - 1] = lastSegment.slice(0, -3);
20
23
 
21
- const slug = pathSegments.filter(Boolean).join("/");
22
- if (!slug) return null;
24
+ if (!pathSegments.every(isValidSegment)) return null;
25
+
26
+ const slug = pathSegments.join("/");
23
27
 
24
28
  return slug === "index" ? null : slug;
25
29
  };
@@ -57,7 +61,7 @@ export async function GET(
57
61
 
58
62
  const page = data as PageResult | null;
59
63
 
60
- const baseUrl = getBaseUrl();
64
+ const baseUrl = getBaseUrl().replace(/\/$/, "");
61
65
 
62
66
  if (!page) {
63
67
  return new NextResponse("# 404 Not Found\n\nPage not found.", {
@@ -94,6 +98,7 @@ export async function GET(
94
98
  "Content-Type": "text/markdown; charset=utf-8",
95
99
  Vary: "Accept",
96
100
  "X-Content-Type-Options": "nosniff",
101
+ Link: `<${baseUrl}${canonicalPath}>; rel="canonical"`,
97
102
  },
98
103
  },
99
104
  );
@@ -113,7 +113,13 @@ export const GET = async () => {
113
113
 
114
114
  if (pages.length === 0) parts.push("No content published yet.", "");
115
115
 
116
- return new NextResponse(parts.join("\n"));
116
+ return new NextResponse(parts.join("\n"), {
117
+ headers: {
118
+ "Content-Type": "text/markdown; charset=utf-8",
119
+ "X-Content-Type-Options": "nosniff",
120
+ "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=600",
121
+ },
122
+ });
117
123
  } catch (error) {
118
124
  console.error("Error generating sitemap.md:", error);
119
125
  return new NextResponse("# Sitemap\n\nError fetching content.", {
@@ -10,7 +10,7 @@ interface SanityImageProps extends Omit<ImageProps, "src" | "aspectRatio"> {
10
10
  };
11
11
  alt?: string;
12
12
  hotspot?: object;
13
- crop?: object;
13
+ crop?: { left?: number; right?: number; top?: number; bottom?: number };
14
14
  };
15
15
  maxWidth?: number;
16
16
  }
@@ -24,16 +24,26 @@ export function SanityImage({
24
24
  }: SanityImageProps) {
25
25
  if (!image?.asset) return null;
26
26
 
27
+ // getImageDimensions is uncropped; the URL builder applies the crop.
28
+ // Rounding order must match @sanity/image-url: origin first, then subtract.
27
29
  const { width, height } = getImageDimensions(image.asset);
28
- const aspectRatio = width / height;
30
+ const { left = 0, right = 0, top = 0, bottom = 0 } = image.crop ?? {};
31
+ const cropLeft = Math.round(left * width);
32
+ const cropTop = Math.round(top * height);
33
+ const sourceWidth = Math.round(width - right * width - cropLeft);
34
+ const sourceHeight = Math.round(height - bottom * height - cropTop);
29
35
 
30
- const imageProps = fill ? { fill: true } : { width, height };
36
+ const imageProps = fill
37
+ ? { fill: true }
38
+ : { width: sourceWidth, height: sourceHeight };
31
39
 
32
40
  return (
33
41
  <Image
34
- src={urlForImage(image).width(maxWidth).url()}
42
+ src={urlForImage(image)
43
+ .width(Math.min(maxWidth, sourceWidth))
44
+ .url()}
35
45
  alt={alt || image.alt || ""}
36
- aspectRatio={aspectRatio}
46
+ aspectRatio={sourceWidth / sourceHeight}
37
47
  {...imageProps}
38
48
  {...props}
39
49
  />
@@ -0,0 +1,81 @@
1
+ import { EyeClosedIcon, EyeOpenIcon } from "@sanity/icons";
2
+ import { Badge, Box, Button, Flex, Text, Tooltip, useToast } from "@sanity/ui";
3
+ import { useState } from "react";
4
+ import { type ObjectItemProps, useClient, useFormValue } from "sanity";
5
+ import { apiVersion } from "@/lib/integrations/sanity/env";
6
+
7
+ const HIDDEN_KEYS_FIELD = "hiddenPageBuilderKeys";
8
+
9
+ type ReferenceItem = { _key: string; _ref: string; _type: string };
10
+
11
+ /**
12
+ * Eye-icon toggle for a page builder placement's per-page visibility. Adds the
13
+ * placement's `_key` to the owning document's `hiddenPageBuilderKeys`; GROQ
14
+ * filters those keys out at query time. Standalone control because Sanity's
15
+ * built-in item menu isn't extensible via public props.
16
+ */
17
+ export function BlockActionsItem(props: ObjectItemProps<ReferenceItem>) {
18
+ const { value, renderDefault } = props;
19
+ const client = useClient({ apiVersion });
20
+ const toast = useToast();
21
+ const [busy, setBusy] = useState(false);
22
+
23
+ // Open document id (draft while editing).
24
+ const documentId = useFormValue(["_id"]) as string | undefined;
25
+ const hiddenKeys =
26
+ (useFormValue([HIDDEN_KEYS_FIELD]) as string[] | undefined) ?? [];
27
+ const itemKey = value._key;
28
+ const hidden = hiddenKeys.includes(itemKey);
29
+ const label = hidden ? "Show on this page" : "Hide on this page";
30
+
31
+ async function handleToggleHidden() {
32
+ if (busy || !documentId) return;
33
+ setBusy(true);
34
+ try {
35
+ // Atomic array ops (not read-modify-write) so concurrent toggles
36
+ // of different blocks don't clobber each other.
37
+ const patch = client.patch(documentId);
38
+ if (hidden) {
39
+ patch.unset([`${HIDDEN_KEYS_FIELD}[@ == "${itemKey}"]`]);
40
+ } else {
41
+ patch
42
+ .setIfMissing({ [HIDDEN_KEYS_FIELD]: [] })
43
+ .insert("after", `${HIDDEN_KEYS_FIELD}[-1]`, [itemKey]);
44
+ }
45
+ await patch.commit();
46
+ toast.push({
47
+ status: "success",
48
+ title: hidden ? "Block shown" : "Block hidden",
49
+ });
50
+ } catch {
51
+ toast.push({ status: "error", title: "Failed to update visibility" });
52
+ } finally {
53
+ setBusy(false);
54
+ }
55
+ }
56
+
57
+ return (
58
+ <Flex align="center" gap={1} paddingLeft={1}>
59
+ <Tooltip content={<Text size={1}>{label}</Text>} placement="top" portal>
60
+ <Button
61
+ mode="bleed"
62
+ // Open eye = visible, closed eye = hidden.
63
+ icon={hidden ? EyeClosedIcon : EyeOpenIcon}
64
+ tone={hidden ? "caution" : "default"}
65
+ aria-label={label}
66
+ // Unsaved doc has no id to patch — disable, don't no-op.
67
+ disabled={busy || !documentId}
68
+ onClick={handleToggleHidden}
69
+ />
70
+ </Tooltip>
71
+ {hidden && (
72
+ <Badge fontSize={0} mode="outline" tone="caution">
73
+ Hidden
74
+ </Badge>
75
+ )}
76
+ <Box flex={1} style={hidden ? { opacity: 0.5 } : undefined}>
77
+ {renderDefault(props)}
78
+ </Box>
79
+ </Flex>
80
+ );
81
+ }
@@ -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
+ }
@@ -15,6 +15,7 @@ import {
15
15
  LayoutIcon,
16
16
  ListBulletsIcon,
17
17
  ListIcon,
18
+ LockKeyIcon,
18
19
  PackageIcon,
19
20
  PushPinIcon,
20
21
  QuestionIcon,
@@ -71,6 +72,7 @@ export const customJsonLdIcon = asDuotoneIcon(BracketsCurlyIcon);
71
72
  export const heroIcon = asDuotoneIcon(RocketIcon);
72
73
  export const descriptionIcon = asDuotoneIcon(TextAlignLeftIcon);
73
74
  export const contentIcon = asDuotoneIcon(ArticleNyTimesIcon);
75
+ export const gatedContentIcon = asDuotoneIcon(LockKeyIcon);
74
76
 
75
77
  export const pageIcon = asDuotoneIcon(FileTextIcon);
76
78
  export const pinIcon = asDuotoneIcon(PushPinIcon);
@@ -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
  },
@@ -4,6 +4,7 @@ import { singletonComponents } from "./singletons";
4
4
  export type PageBuilderDocumentType =
5
5
  | "hero"
6
6
  | "description"
7
+ | "gatedContent"
7
8
  | "blogContent"
8
9
  | (typeof singletonComponents)[number]["schemaType"];
9
10
 
@@ -47,6 +48,12 @@ const reusablePageBuilderReferenceMembers: PageBuilderReferenceMember[] = [
47
48
  title: "Description",
48
49
  groups: ["generic"],
49
50
  },
51
+ {
52
+ documentType: "gatedContent",
53
+ name: "gatedContentReference",
54
+ title: "Gated Content",
55
+ groups: ["generic"],
56
+ },
50
57
  {
51
58
  documentType: "blogContent",
52
59
  name: "blogContentReference",