bsmnt 0.11.0 → 0.12.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 (50) hide show
  1. package/package.json +1 -1
  2. package/src/modules/features/cms/sanity/files/components/ui/sanity-image/index.tsx +36 -4
  3. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/components/rich-text.tsx +7 -1
  4. package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/rich-text.tsx +7 -1
  5. package/src/modules/features/cms/sanity-pagebuilder/files/components/ui/sanity-image/index.tsx +36 -4
  6. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-builder-input.tsx +26 -7
  7. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-builder-insert-menu.tsx +242 -0
  8. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/use-studio-preview-urls.ts +62 -0
  9. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +72 -34
  10. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/index.ts +1 -0
  11. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/studio-preview.ts +37 -0
  12. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts +3 -0
  13. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/singletons.ts +2 -0
  14. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +5 -0
  15. package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/sanity/rich-text.tsx +7 -1
  16. package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/ui/sanity-image/index.tsx +36 -4
  17. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/page-builder-input.tsx +26 -7
  18. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/page-builder-insert-menu.tsx +242 -0
  19. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/use-studio-preview-urls.ts +62 -0
  20. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/page-builder-config.ts +72 -34
  21. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/index.ts +1 -0
  22. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/studio-preview.ts +37 -0
  23. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/index.ts +3 -0
  24. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/singletons.ts +2 -0
  25. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/structure.ts +5 -0
  26. package/src/templates/next-default/.biome/plugins/no-fill-without-sizes.grit +11 -0
  27. package/src/templates/next-default/biome.json +2 -1
  28. package/src/templates/next-default/components/ui/image/index.tsx +20 -12
  29. package/src/templates/next-default/next.config.ts +10 -4
  30. package/src/templates/next-experiments/.biome/plugins/no-fill-without-sizes.grit +11 -0
  31. package/src/templates/next-experiments/biome.json +2 -1
  32. package/src/templates/next-experiments/components/ui/image/index.tsx +20 -12
  33. package/src/templates/next-experiments/next.config.ts +10 -4
  34. package/src/templates/next-pagebuilder/.biome/plugins/no-fill-without-sizes.grit +11 -0
  35. package/src/templates/next-pagebuilder/biome.json +2 -1
  36. package/src/templates/next-pagebuilder/components/page-builder/components/article-content/index.tsx +5 -0
  37. package/src/templates/next-pagebuilder/components/page-builder/components/article-content/related-post-item.tsx +1 -0
  38. package/src/templates/next-pagebuilder/components/ui/image/index.tsx +20 -12
  39. package/src/templates/next-pagebuilder/next.config.ts +4 -3
  40. package/src/templates/next-pagebuilder-cache/.biome/plugins/no-fill-without-sizes.grit +11 -0
  41. package/src/templates/next-pagebuilder-cache/biome.json +2 -1
  42. package/src/templates/next-pagebuilder-cache/components/page-builder/components/article-content/index.tsx +5 -0
  43. package/src/templates/next-pagebuilder-cache/components/page-builder/components/article-content/related-post-item.tsx +1 -0
  44. package/src/templates/next-pagebuilder-cache/components/ui/image/index.tsx +21 -12
  45. package/src/templates/next-pagebuilder-cache/lib/utils/image-sizes.ts +72 -0
  46. package/src/templates/next-pagebuilder-cache/next.config.ts +4 -3
  47. package/src/templates/next-webgl/.biome/plugins/no-fill-without-sizes.grit +11 -0
  48. package/src/templates/next-webgl/biome.json +2 -1
  49. package/src/templates/next-webgl/components/ui/image/index.tsx +20 -12
  50. package/src/templates/next-webgl/next.config.ts +10 -4
@@ -0,0 +1,62 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useClient } from "sanity";
3
+ import { apiVersion } from "@/lib/integrations/sanity/env";
4
+
5
+ const PREVIEW_ASSETS_QUERY = `*[
6
+ _type == "studioPreview" &&
7
+ key in $keys &&
8
+ defined(preview.asset._ref)
9
+ ] | order(_updatedAt desc) {
10
+ key,
11
+ "url": preview.asset->url
12
+ }`;
13
+
14
+ type PreviewDocument = {
15
+ key: string | null;
16
+ url: string | null;
17
+ };
18
+
19
+ export type StudioPreviewUrls = Record<string, string>;
20
+
21
+ const SEPARATOR = " ";
22
+
23
+ /** `undefined` while in flight, so callers can tell loading from "no preview". */
24
+ export function useStudioPreviewUrls(keys: readonly string[]) {
25
+ const client = useClient({ apiVersion });
26
+ const [urls, setUrls] = useState<StudioPreviewUrls>();
27
+ // Keys off contents, not array identity — callers pass a freshly mapped array.
28
+ const keyList = keys.join(SEPARATOR);
29
+
30
+ useEffect(() => {
31
+ if (!keyList) {
32
+ setUrls({});
33
+ return;
34
+ }
35
+
36
+ let cancelled = false;
37
+
38
+ client
39
+ .fetch<PreviewDocument[]>(PREVIEW_ASSETS_QUERY, {
40
+ keys: keyList.split(SEPARATOR),
41
+ })
42
+ .then((documents) => {
43
+ if (cancelled) return;
44
+ const next: StudioPreviewUrls = {};
45
+ for (const document of documents) {
46
+ if (document.key && document.url && !next[document.key]) {
47
+ next[document.key] = document.url;
48
+ }
49
+ }
50
+ setUrls(next);
51
+ })
52
+ .catch(() => {
53
+ if (!cancelled) setUrls({});
54
+ });
55
+
56
+ return () => {
57
+ cancelled = true;
58
+ };
59
+ }, [client, keyList]);
60
+
61
+ return urls;
62
+ }
@@ -9,15 +9,35 @@ export type PageBuilderDocumentType =
9
9
  | "blogContent"
10
10
  | (typeof singletonComponents)[number]["schemaType"];
11
11
 
12
+ /**
13
+ * A block declares two orthogonal axes. `pageTypes` is which page documents may
14
+ * host it and drives validation; `category` is what the block structurally is
15
+ * and drives the insert menu. Never collapse them back into one field — that is
16
+ * what made the menu a single flat list.
17
+ */
18
+ export type PageBuilderGroupName = "generic" | "blogEntry";
19
+
20
+ export const componentCategories = [
21
+ { name: "hero", title: "Hero" },
22
+ { name: "text", title: "Text" },
23
+ { name: "list", title: "List" },
24
+ { name: "content", title: "Content" },
25
+ { name: "misc", title: "Miscellaneous" },
26
+ ] as const satisfies ReadonlyArray<{ name: string; title: string }>;
27
+
28
+ export type ComponentCategory = (typeof componentCategories)[number]["name"];
29
+
12
30
  type PageBuilderReferenceMember = {
13
31
  documentType: PageBuilderDocumentType;
14
32
  name: string;
15
33
  title: string;
16
34
  documentId?: string;
17
- groups: PageBuilderGroupName[];
35
+ pageTypes: readonly PageBuilderGroupName[];
36
+ category: ComponentCategory;
18
37
  };
19
38
 
20
- export type PageBuilderGroupName = "generic" | "blogEntry";
39
+ export const blockPreviewKey = (schemaTypeName: string) =>
40
+ `block.${schemaTypeName}`;
21
41
 
22
42
  export const pageBuilderPageTypes: Array<{
23
43
  title: string;
@@ -41,31 +61,36 @@ const reusablePageBuilderReferenceMembers: PageBuilderReferenceMember[] = [
41
61
  documentType: "hero",
42
62
  name: "heroReference",
43
63
  title: "Heading",
44
- groups: ["generic"],
64
+ pageTypes: ["generic"],
65
+ category: "hero",
45
66
  },
46
67
  {
47
68
  documentType: "description",
48
69
  name: "descriptionReference",
49
70
  title: "Description",
50
- groups: ["generic"],
71
+ pageTypes: ["generic"],
72
+ category: "text",
51
73
  },
52
74
  {
53
75
  documentType: "richTextBlock",
54
76
  name: "richTextBlockReference",
55
77
  title: "Rich Text",
56
- groups: ["generic"],
78
+ pageTypes: ["generic"],
79
+ category: "text",
57
80
  },
58
81
  {
59
82
  documentType: "gatedContent",
60
83
  name: "gatedContentReference",
61
84
  title: "Gated Content",
62
- groups: ["generic"],
85
+ pageTypes: ["generic"],
86
+ category: "misc",
63
87
  },
64
88
  {
65
89
  documentType: "blogContent",
66
90
  name: "blogContentReference",
67
91
  title: "Blog Content",
68
- groups: ["blogEntry"],
92
+ pageTypes: ["blogEntry"],
93
+ category: "content",
69
94
  },
70
95
  ];
71
96
 
@@ -75,7 +100,8 @@ const singletonPageBuilderReferenceMembers: PageBuilderReferenceMember[] =
75
100
  name: `${component.schemaType}Reference`,
76
101
  title: component.title,
77
102
  documentId: component.documentId,
78
- groups: ["generic"],
103
+ pageTypes: ["generic"],
104
+ category: component.category,
79
105
  }));
80
106
 
81
107
  export const pageBuilderReferenceMembers: PageBuilderReferenceMember[] = [
@@ -93,25 +119,37 @@ export const getPageBuilderReferenceMemberByDocumentType = (
93
119
  (member) => member.documentType === documentType,
94
120
  );
95
121
 
96
- const isPageBuilderGroupVisible = (
122
+ const isMemberVisibleForPageType = (
123
+ member: PageBuilderReferenceMember,
97
124
  pageType: PageBuilderGroupName | undefined,
98
- group: PageBuilderGroupName,
99
- ) => (pageType ?? "generic") === group;
125
+ ) => member.pageTypes.includes(pageType ?? "generic");
100
126
 
101
127
  export const getVisiblePageBuilderReferenceMembers = (
102
128
  pageType: PageBuilderGroupName | undefined,
103
129
  ) =>
104
130
  pageBuilderReferenceMembers.filter((member) =>
105
- member.groups.some((group) => isPageBuilderGroupVisible(pageType, group)),
131
+ isMemberVisibleForPageType(member, pageType),
106
132
  );
107
133
 
108
- export const getVisiblePageBuilderInsertMenuGroups = (
134
+ export const getVisiblePageBuilderMemberNames = (
109
135
  pageType: PageBuilderGroupName | undefined,
110
136
  ) =>
111
- pageBuilderInsertMenuGroups.filter(
112
- (group) => group.name === (pageType ?? "generic"),
137
+ new Set(
138
+ getVisiblePageBuilderReferenceMembers(pageType).map((member) => member.name),
113
139
  );
114
140
 
141
+ export const getVisiblePageBuilderInsertMenuGroups = (
142
+ pageType: PageBuilderGroupName | undefined,
143
+ ) => {
144
+ const visible = getVisiblePageBuilderMemberNames(pageType);
145
+ return pageBuilderInsertMenuGroups
146
+ .map((group) => ({
147
+ ...group,
148
+ of: group.of.filter((name) => visible.has(name)),
149
+ }))
150
+ .filter((group) => group.of.length > 0);
151
+ };
152
+
115
153
  export const getInvalidPageBuilderDocumentTypes = (
116
154
  pageType: PageBuilderGroupName | undefined,
117
155
  documentTypes: PageBuilderDocumentType[],
@@ -121,24 +159,24 @@ export const getInvalidPageBuilderDocumentTypes = (
121
159
 
122
160
  if (!member) return false;
123
161
 
124
- return !member.groups.some((group) =>
125
- isPageBuilderGroupVisible(pageType, group),
126
- );
162
+ return !isMemberVisibleForPageType(member, pageType);
127
163
  });
128
164
 
129
- export const pageBuilderInsertMenuGroups = [
130
- {
131
- name: "generic",
132
- title: "Generic",
133
- of: pageBuilderReferenceMembers
134
- .filter((member) => member.groups.includes("generic"))
135
- .map((member) => member.name),
136
- },
137
- {
138
- name: "blogEntry",
139
- title: "Blog Entry",
140
- of: pageBuilderReferenceMembers
141
- .filter((member) => member.groups.includes("blogEntry"))
142
- .map((member) => member.name),
143
- },
144
- ];
165
+ export const pageBuilderInsertMenuGroups: Array<{
166
+ name: ComponentCategory;
167
+ title: string;
168
+ of: string[];
169
+ }> = componentCategories.map(({ name, title }) => ({
170
+ name,
171
+ title,
172
+ of: pageBuilderReferenceMembers
173
+ .filter((member) => member.category === name)
174
+ .map((member) => member.name),
175
+ }));
176
+
177
+ export const pageBuilderBlockPreviewOptions = pageBuilderReferenceMembers.map(
178
+ (member) => ({
179
+ title: `Block — ${member.title} · ${member.name}`,
180
+ value: blockPreviewKey(member.name),
181
+ }),
182
+ );
@@ -4,3 +4,4 @@ export { gatedContent } from "./reusable/gated-content";
4
4
  export { hero } from "./reusable/hero";
5
5
  export { richTextBlock } from "./reusable/rich-text-block";
6
6
  export { blogCollection } from "./singleton/content-collection";
7
+ export { studioPreview } from "./studio-preview";
@@ -0,0 +1,37 @@
1
+ import { defineField, defineType } from "sanity";
2
+ import { pageBuilderCardIcon } from "@/lib/integrations/sanity/icons";
3
+ import { pageBuilderBlockPreviewOptions } from "@/lib/integrations/sanity/page-builder-config";
4
+
5
+ export const studioPreview = defineType({
6
+ name: "studioPreview",
7
+ title: "Component Preview",
8
+ type: "document",
9
+ icon: pageBuilderCardIcon,
10
+ fields: [
11
+ defineField({
12
+ name: "key",
13
+ title: "Component",
14
+ type: "string",
15
+ options: { list: pageBuilderBlockPreviewOptions },
16
+ validation: (Rule) => Rule.required(),
17
+ }),
18
+ defineField({
19
+ name: "preview",
20
+ title: "Preview image",
21
+ type: "image",
22
+ options: { hotspot: true },
23
+ validation: (Rule) => Rule.required(),
24
+ }),
25
+ ],
26
+ preview: {
27
+ select: { key: "key", media: "preview" },
28
+ prepare({ key, media }) {
29
+ return {
30
+ title:
31
+ pageBuilderBlockPreviewOptions.find((option) => option.value === key)
32
+ ?.title ?? "Component Preview",
33
+ media,
34
+ };
35
+ },
36
+ },
37
+ });
@@ -15,6 +15,7 @@ import {
15
15
  gatedContent,
16
16
  hero,
17
17
  richTextBlock,
18
+ studioPreview,
18
19
  } from "./components";
19
20
  import { author } from "./content/author";
20
21
  import { blogCategory } from "./content/blog-category";
@@ -77,6 +78,7 @@ export {
77
78
  richText,
78
79
  softwareApplicationJsonLd,
79
80
  structuredData,
81
+ studioPreview,
80
82
  videoJsonLd,
81
83
  };
82
84
 
@@ -113,6 +115,7 @@ export const schema: { types: SchemaTypeDefinition[] } = {
113
115
  description,
114
116
  richTextBlock,
115
117
  gatedContent,
118
+ studioPreview,
116
119
 
117
120
  // Layout types (singletons for site-wide layout)
118
121
  companyData,
@@ -6,12 +6,14 @@ import {
6
6
  structuredDataIcon,
7
7
  } from "./icons";
8
8
 
9
+ // `category` must be one of `componentCategories` in page-builder-config.ts.
9
10
  export const singletonComponents = [
10
11
  {
11
12
  schemaType: "blogCollection",
12
13
  documentId: "blogCollection",
13
14
  title: "Blog Collection",
14
15
  icon: pageListIcon,
16
+ category: "list",
15
17
  },
16
18
  ] as const;
17
19
 
@@ -17,6 +17,7 @@ import {
17
17
  gatedContentIcon,
18
18
  heroIcon,
19
19
  layoutFolderIcon,
20
+ pageBuilderCardIcon,
20
21
  pageFolderIcon,
21
22
  pageIcon,
22
23
  pagesFolderIcon,
@@ -301,6 +302,10 @@ export const structure: StructureResolver = (S, context) =>
301
302
  S.documentTypeListItem("blogContent")
302
303
  .title("Blog Content")
303
304
  .icon(contentIcon),
305
+ S.divider(),
306
+ S.documentTypeListItem("studioPreview")
307
+ .title("Component Previews")
308
+ .icon(pageBuilderCardIcon),
304
309
  ]),
305
310
  ),
306
311
 
@@ -0,0 +1,11 @@
1
+ language js
2
+
3
+ or {
4
+ `<Image $attrs />`,
5
+ `<SanityImage $attrs />`
6
+ } as $image where {
7
+ $attrs <: contains `fill`,
8
+ ! $attrs <: contains `fill={false}`,
9
+ ! $attrs <: contains or { `sizes`, `mobileSize`, `desktopSize` },
10
+ register_diagnostic(span=$image, message="A `fill` image has no intrinsic width, so without `sizes` the browser assumes 100vw and requests the largest candidate. Pass `sizes`, or `mobileSize`/`desktopSize`.", severity="error")
11
+ }
@@ -234,7 +234,8 @@
234
234
  "plugins": [
235
235
  "./.biome/plugins/no-anchor-element.grit",
236
236
  "./.biome/plugins/no-unnecessary-forwardref.grit",
237
- "./.biome/plugins/no-relative-parent-imports.grit"
237
+ "./.biome/plugins/no-relative-parent-imports.grit",
238
+ "./.biome/plugins/no-fill-without-sizes.grit"
238
239
  ],
239
240
  "vcs": {
240
241
  "clientKind": "git",
@@ -98,7 +98,6 @@ const getFinalPlaceholder = (
98
98
  * Enhanced Image component with responsive sizing and automatic optimizations.
99
99
  *
100
100
  * Always use this component instead of next/image directly. Provides:
101
- * - Automatic responsive sizes generation
102
101
  * - Smart blur placeholders with aspect ratio support
103
102
  * - Performance optimizations (lazy loading by default)
104
103
  * - Priority flag for LCP images
@@ -147,14 +146,14 @@ export function Image({
147
146
  style,
148
147
  className,
149
148
  objectFit = "cover",
150
- quality = 90,
149
+ quality = 75,
151
150
  alt = "",
152
151
  fill,
153
152
  block = !fill,
154
- width = block ? 1 : undefined,
155
- height = block ? 1 : undefined,
156
- mobileSize = "100vw",
157
- desktopSize = "100vw",
153
+ width,
154
+ height,
155
+ mobileSize,
156
+ desktopSize,
158
157
  sizes,
159
158
  src,
160
159
  unoptimized,
@@ -164,9 +163,18 @@ export function Image({
164
163
  priority = false,
165
164
  ...props
166
165
  }: ImageProps) {
167
- // Generate responsive sizes if not provided
168
- const finalSizes =
169
- sizes || buildImageSizes({ base: mobileSize, desktop: desktopSize })
166
+ const responsiveSizes =
167
+ mobileSize || desktopSize
168
+ ? buildImageSizes({
169
+ base: mobileSize ?? "100vw",
170
+ desktop: desktopSize ?? mobileSize ?? "100vw",
171
+ })
172
+ : undefined
173
+
174
+ const hasIntrinsicSize = width !== undefined && height !== undefined
175
+ const finalSizes = sizes ?? responsiveSizes ?? (hasIntrinsicSize ? undefined : "100vw")
176
+ const finalWidth = width ?? (block ? 1 : undefined)
177
+ const finalHeight = height ?? (block ? 1 : undefined)
170
178
 
171
179
  // Early return after hooks
172
180
  if (!src) return null
@@ -194,8 +202,8 @@ export function Image({
194
202
  <NextImage
195
203
  ref={ref}
196
204
  fill={!block}
197
- {...(width !== undefined && { width })}
198
- {...(height !== undefined && { height })}
205
+ {...(finalWidth !== undefined && { width: finalWidth })}
206
+ {...(finalHeight !== undefined && { height: finalHeight })}
199
207
  priority={priority}
200
208
  quality={quality}
201
209
  alt={alt}
@@ -204,7 +212,7 @@ export function Image({
204
212
  ...style,
205
213
  }}
206
214
  className={cn(className, block && "block w-full")}
207
- sizes={finalSizes}
215
+ {...(finalSizes && { sizes: finalSizes })}
208
216
  src={src}
209
217
  unoptimized={unoptimized || isSvg}
210
218
  draggable={false}
@@ -77,12 +77,18 @@ const nextConfig: NextConfig = {
77
77
  ],
78
78
  },
79
79
  images: {
80
- dangerouslyAllowSVG: true,
81
- remotePatterns: [],
80
+ remotePatterns: [
81
+ {
82
+ protocol: "https",
83
+ hostname: "cdn.sanity.io",
84
+ },
85
+ ],
82
86
  minimumCacheTTL: 60 * 60 * 24 * 30, // 30 days
83
- qualities: [90],
87
+ qualities: [75, 90, 100],
84
88
  formats: ["image/avif", "image/webp"],
85
- deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
89
+ deviceSizes: [
90
+ 640, 750, 828, 1080, 1200, 1320, 1620, 1920, 2048, 2560, 3024, 3456, 3840,
91
+ ],
86
92
  imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
87
93
  },
88
94
  headers: async () => [
@@ -0,0 +1,11 @@
1
+ language js
2
+
3
+ or {
4
+ `<Image $attrs />`,
5
+ `<SanityImage $attrs />`
6
+ } as $image where {
7
+ $attrs <: contains `fill`,
8
+ ! $attrs <: contains `fill={false}`,
9
+ ! $attrs <: contains or { `sizes`, `mobileSize`, `desktopSize` },
10
+ register_diagnostic(span=$image, message="A `fill` image has no intrinsic width, so without `sizes` the browser assumes 100vw and requests the largest candidate. Pass `sizes`, or `mobileSize`/`desktopSize`.", severity="error")
11
+ }
@@ -229,7 +229,8 @@
229
229
  "plugins": [
230
230
  "./.biome/plugins/no-anchor-element.grit",
231
231
  "./.biome/plugins/no-unnecessary-forwardref.grit",
232
- "./.biome/plugins/no-relative-parent-imports.grit"
232
+ "./.biome/plugins/no-relative-parent-imports.grit",
233
+ "./.biome/plugins/no-fill-without-sizes.grit"
233
234
  ],
234
235
  "vcs": {
235
236
  "clientKind": "git",
@@ -98,7 +98,6 @@ const getFinalPlaceholder = (
98
98
  * Enhanced Image component with responsive sizing and automatic optimizations.
99
99
  *
100
100
  * Always use this component instead of next/image directly. Provides:
101
- * - Automatic responsive sizes generation
102
101
  * - Smart blur placeholders with aspect ratio support
103
102
  * - Performance optimizations (lazy loading by default)
104
103
  * - Priority flag for LCP images
@@ -147,14 +146,14 @@ export function Image({
147
146
  style,
148
147
  className,
149
148
  objectFit = "cover",
150
- quality = 90,
149
+ quality = 75,
151
150
  alt = "",
152
151
  fill,
153
152
  block = !fill,
154
- width = block ? 1 : undefined,
155
- height = block ? 1 : undefined,
156
- mobileSize = "100vw",
157
- desktopSize = "100vw",
153
+ width,
154
+ height,
155
+ mobileSize,
156
+ desktopSize,
158
157
  sizes,
159
158
  src,
160
159
  unoptimized,
@@ -164,9 +163,18 @@ export function Image({
164
163
  priority = false,
165
164
  ...props
166
165
  }: ImageProps) {
167
- // Generate responsive sizes if not provided
168
- const finalSizes =
169
- sizes || buildImageSizes({ base: mobileSize, desktop: desktopSize })
166
+ const responsiveSizes =
167
+ mobileSize || desktopSize
168
+ ? buildImageSizes({
169
+ base: mobileSize ?? "100vw",
170
+ desktop: desktopSize ?? mobileSize ?? "100vw",
171
+ })
172
+ : undefined
173
+
174
+ const hasIntrinsicSize = width !== undefined && height !== undefined
175
+ const finalSizes = sizes ?? responsiveSizes ?? (hasIntrinsicSize ? undefined : "100vw")
176
+ const finalWidth = width ?? (block ? 1 : undefined)
177
+ const finalHeight = height ?? (block ? 1 : undefined)
170
178
 
171
179
  // Early return after hooks
172
180
  if (!src) return null
@@ -194,8 +202,8 @@ export function Image({
194
202
  <NextImage
195
203
  ref={ref}
196
204
  fill={!block}
197
- {...(width !== undefined && { width })}
198
- {...(height !== undefined && { height })}
205
+ {...(finalWidth !== undefined && { width: finalWidth })}
206
+ {...(finalHeight !== undefined && { height: finalHeight })}
199
207
  priority={priority}
200
208
  quality={quality}
201
209
  alt={alt}
@@ -204,7 +212,7 @@ export function Image({
204
212
  ...style,
205
213
  }}
206
214
  className={cn(className, block && "block w-full")}
207
- sizes={finalSizes}
215
+ {...(finalSizes && { sizes: finalSizes })}
208
216
  src={src}
209
217
  unoptimized={unoptimized || isSvg}
210
218
  draggable={false}
@@ -77,12 +77,18 @@ const nextConfig: NextConfig = {
77
77
  ],
78
78
  },
79
79
  images: {
80
- dangerouslyAllowSVG: true,
81
- remotePatterns: [],
80
+ remotePatterns: [
81
+ {
82
+ protocol: "https",
83
+ hostname: "cdn.sanity.io",
84
+ },
85
+ ],
82
86
  minimumCacheTTL: 60 * 60 * 24 * 30, // 30 days
83
- qualities: [90],
87
+ qualities: [75, 90, 100],
84
88
  formats: ["image/avif", "image/webp"],
85
- deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
89
+ deviceSizes: [
90
+ 640, 750, 828, 1080, 1200, 1320, 1620, 1920, 2048, 2560, 3024, 3456, 3840,
91
+ ],
86
92
  imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
87
93
  },
88
94
  headers: async () => [
@@ -0,0 +1,11 @@
1
+ language js
2
+
3
+ or {
4
+ `<Image $attrs />`,
5
+ `<SanityImage $attrs />`
6
+ } as $image where {
7
+ $attrs <: contains `fill`,
8
+ ! $attrs <: contains `fill={false}`,
9
+ ! $attrs <: contains or { `sizes`, `mobileSize`, `desktopSize` },
10
+ register_diagnostic(span=$image, message="A `fill` image has no intrinsic width, so without `sizes` the browser assumes 100vw and requests the largest candidate. Pass `sizes`, or `mobileSize`/`desktopSize`.", severity="error")
11
+ }
@@ -247,7 +247,8 @@
247
247
  "plugins": [
248
248
  "./.biome/plugins/no-anchor-element.grit",
249
249
  "./.biome/plugins/no-unnecessary-forwardref.grit",
250
- "./.biome/plugins/no-relative-parent-imports.grit"
250
+ "./.biome/plugins/no-relative-parent-imports.grit",
251
+ "./.biome/plugins/no-fill-without-sizes.grit"
251
252
  ],
252
253
  "vcs": {
253
254
  "clientKind": "git",
@@ -28,6 +28,9 @@ export function ArticleContent({ block }: ArticleContentProps) {
28
28
  <SanityImage
29
29
  image={author.avatar}
30
30
  alt={author.name || ""}
31
+ width={40}
32
+ height={40}
33
+ maxWidth={80}
31
34
  className="size-10 rounded-full object-cover"
32
35
  />
33
36
  ) : null}
@@ -50,6 +53,8 @@ export function ArticleContent({ block }: ArticleContentProps) {
50
53
  image={thumbnail}
51
54
  alt={thumbnail.alt || ""}
52
55
  priority
56
+ quality={90}
57
+ sizes="(min-width: 896px) 896px, 100vw"
53
58
  className="aspect-video w-full object-cover"
54
59
  />
55
60
  </div>
@@ -14,6 +14,7 @@ export const RelatedPostItem = ({ post }: { post: ArticleRelatedPost }) => {
14
14
  <SanityImage
15
15
  image={post.metadata.image}
16
16
  alt={post.title || ""}
17
+ sizes="(min-width: 768px) 768px, 100vw"
17
18
  className="aspect-[16/9] w-full object-cover transition-transform duration-300 group-hover:scale-105"
18
19
  />
19
20
  </div>