bsmnt 0.13.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. package/package.json +1 -1
  2. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +1 -1
  3. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/presentation.ts +47 -15
  4. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/blog-content.ts +2 -2
  5. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +4 -4
  6. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/page-builder-config.ts +1 -1
  7. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/presentation.ts +47 -15
  8. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/reusable/blog-content.ts +2 -2
  9. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/structure.ts +4 -4
  10. package/src/templates/next-default/lib/utils/README.md +1 -0
  11. package/src/templates/next-default/lib/utils/image-sizes.ts +64 -25
  12. package/src/templates/next-experiments/lib/utils/README.md +1 -0
  13. package/src/templates/next-experiments/lib/utils/image-sizes.ts +64 -25
  14. package/src/templates/next-pagebuilder/components/page-document/index.tsx +1 -1
  15. package/src/templates/next-pagebuilder/lib/utils/image-sizes.ts +64 -25
  16. package/src/templates/next-pagebuilder-cache/components/page-document/index.tsx +1 -1
  17. package/src/templates/next-pagebuilder-cache/lib/utils/image-sizes.ts +64 -25
  18. package/src/templates/next-webgl/lib/utils/README.md +1 -0
  19. package/src/templates/next-webgl/lib/utils/image-sizes.ts +64 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bsmnt",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "packageManager": "bun@1.3.14",
5
5
  "description": "CLI to scaffold basement projects and add integrations",
6
6
  "type": "module",
@@ -88,7 +88,7 @@ const reusablePageBuilderReferenceMembers: PageBuilderReferenceMember[] = [
88
88
  {
89
89
  documentType: "blogContent",
90
90
  name: "blogContentReference",
91
- title: "Blog Content",
91
+ title: "Blog Posts",
92
92
  pageTypes: ["blogEntry"],
93
93
  category: "content",
94
94
  },
@@ -7,6 +7,7 @@ import {
7
7
  presentationTool,
8
8
  } from "sanity/presentation";
9
9
  import { previewURL } from "./env";
10
+ import { pageBuilderReferenceMembers } from "./page-builder-config";
10
11
  import { getPinnedPageById, HOMEPAGE_PINNED_PAGE } from "./pinned-pages";
11
12
 
12
13
  const MAX_FOLDER_DEPTH = 6;
@@ -43,10 +44,52 @@ const pageResolvedSlugExpression = buildNestedSlugExpression(
43
44
  "slug.current",
44
45
  );
45
46
 
46
- const CONTENT_TYPE_PREFIXES: Record<string, string> = {
47
- blogContent: "blog",
47
+ const PAGE_BUILDER_DOCUMENT_TYPES = new Set<string>(
48
+ pageBuilderReferenceMembers.map((member) => member.documentType),
49
+ );
50
+
51
+ const HOST_PAGES_QUERY = `*[_type == "page" && references($id)]{
52
+ _id,
53
+ title,
54
+ "resolvedSlug": ${pageResolvedSlugExpression}
55
+ }`;
56
+
57
+ type HostPage = {
58
+ _id: string;
59
+ title?: string;
60
+ resolvedSlug?: string;
48
61
  };
49
62
 
63
+ const isDraftId = (id: string) => id.startsWith("drafts.");
64
+
65
+ const resolveHostPageLocations = (
66
+ documentStore: DocumentStore,
67
+ id: string,
68
+ ): Observable<DocumentLocationsState> =>
69
+ documentStore.listenQuery(HOST_PAGES_QUERY, { id }, {}).pipe(
70
+ map((pages: HostPage[] | null) => {
71
+ const deduped = new Map<string, HostPage>();
72
+ for (const page of pages ?? []) {
73
+ const publishedId = page._id.replace(/^drafts\./, "");
74
+ const existing = deduped.get(publishedId);
75
+ if (!existing || isDraftId(existing._id)) {
76
+ deduped.set(publishedId, page);
77
+ }
78
+ }
79
+
80
+ return {
81
+ locations: [...deduped.values()].flatMap((page) => {
82
+ const pinned = getPinnedPageById(page._id);
83
+ const href =
84
+ pinned?.path ??
85
+ (page.resolvedSlug ? `/${page.resolvedSlug}` : null);
86
+ if (!href) return [];
87
+ return [{ title: page.title || pinned?.title || "Page", href }];
88
+ }),
89
+ };
90
+ }),
91
+ );
92
+
50
93
  const resolveSlugLocation = <T extends { title?: string }>(
51
94
  documentStore: DocumentStore,
52
95
  id: string,
@@ -73,10 +116,6 @@ export const presentation = presentationTool({
73
116
  route: "/",
74
117
  filter: `_type == "page" && _id == "${HOMEPAGE_PINNED_PAGE.id}"`,
75
118
  },
76
- ...Object.entries(CONTENT_TYPE_PREFIXES).map(([type, prefix]) => ({
77
- route: `/${prefix}/:slug`,
78
- filter: `_type == "${type}" && slug.current == $slug`,
79
- })),
80
119
  {
81
120
  route: "/:slug+",
82
121
  filter: `_type == "page" && ${pageResolvedSlugExpression} == $slug`,
@@ -97,16 +136,9 @@ export const presentation = presentationTool({
97
136
  );
98
137
  }
99
138
 
100
- const prefix = CONTENT_TYPE_PREFIXES[params.type];
101
- if (!prefix) return null;
139
+ if (!PAGE_BUILDER_DOCUMENT_TYPES.has(params.type)) return null;
102
140
 
103
- return resolveSlugLocation<{ title?: string; slug?: string }>(
104
- documentStore,
105
- params.id,
106
- `title, "slug": slug.current`,
107
- (doc) => (doc.slug ? `/${prefix}/${doc.slug}` : null),
108
- "Untitled",
109
- );
141
+ return resolveHostPageLocations(documentStore, params.id);
110
142
  },
111
143
  },
112
144
  previewUrl: {
@@ -4,7 +4,7 @@ import { defineBlock } from "@/lib/integrations/sanity/schemas/shared/block";
4
4
 
5
5
  export const blogContent = defineBlock({
6
6
  name: "blogContent",
7
- title: "Blog Content",
7
+ title: "Blog Posts",
8
8
  icon: contentIcon,
9
9
  fields: [
10
10
  defineField({
@@ -80,7 +80,7 @@ export const blogContent = defineBlock({
80
80
  },
81
81
  prepare({ title, date, media }) {
82
82
  return {
83
- title: title || "Blog Content",
83
+ title: title || "Blog Posts",
84
84
  subtitle: date || "No date set",
85
85
  media,
86
86
  };
@@ -252,7 +252,7 @@ export const structure: StructureResolver = (S, context) =>
252
252
 
253
253
  S.divider(),
254
254
 
255
- // GROUP 2: Content -- authors, blog categories
255
+ // GROUP 2: Content -- blog posts, authors, blog categories
256
256
  S.listItem()
257
257
  .title("Content")
258
258
  .icon(contentFolderIcon)
@@ -260,6 +260,9 @@ export const structure: StructureResolver = (S, context) =>
260
260
  S.list()
261
261
  .title("Content")
262
262
  .items([
263
+ S.documentTypeListItem("blogContent")
264
+ .title("Blog Posts")
265
+ .icon(contentIcon),
263
266
  S.documentTypeListItem("author")
264
267
  .title("Authors")
265
268
  .icon(authorIcon),
@@ -299,9 +302,6 @@ export const structure: StructureResolver = (S, context) =>
299
302
  S.documentTypeListItem("gatedContent")
300
303
  .title("Gated Content")
301
304
  .icon(gatedContentIcon),
302
- S.documentTypeListItem("blogContent")
303
- .title("Blog Content")
304
- .icon(contentIcon),
305
305
  S.divider(),
306
306
  S.documentTypeListItem("studioPreview")
307
307
  .title("Component Previews")
@@ -88,7 +88,7 @@ const reusablePageBuilderReferenceMembers: PageBuilderReferenceMember[] = [
88
88
  {
89
89
  documentType: "blogContent",
90
90
  name: "blogContentReference",
91
- title: "Blog Content",
91
+ title: "Blog Posts",
92
92
  pageTypes: ["blogEntry"],
93
93
  category: "content",
94
94
  },
@@ -7,6 +7,7 @@ import {
7
7
  presentationTool,
8
8
  } from "sanity/presentation";
9
9
  import { previewURL } from "./env";
10
+ import { pageBuilderReferenceMembers } from "./page-builder-config";
10
11
  import { getPinnedPageById, HOMEPAGE_PINNED_PAGE } from "./pinned-pages";
11
12
 
12
13
  const MAX_FOLDER_DEPTH = 6;
@@ -43,10 +44,52 @@ const pageResolvedSlugExpression = buildNestedSlugExpression(
43
44
  "slug.current",
44
45
  );
45
46
 
46
- const CONTENT_TYPE_PREFIXES: Record<string, string> = {
47
- blogContent: "blog",
47
+ const PAGE_BUILDER_DOCUMENT_TYPES = new Set<string>(
48
+ pageBuilderReferenceMembers.map((member) => member.documentType),
49
+ );
50
+
51
+ const HOST_PAGES_QUERY = `*[_type == "page" && references($id)]{
52
+ _id,
53
+ title,
54
+ "resolvedSlug": ${pageResolvedSlugExpression}
55
+ }`;
56
+
57
+ type HostPage = {
58
+ _id: string;
59
+ title?: string;
60
+ resolvedSlug?: string;
48
61
  };
49
62
 
63
+ const isDraftId = (id: string) => id.startsWith("drafts.");
64
+
65
+ const resolveHostPageLocations = (
66
+ documentStore: DocumentStore,
67
+ id: string,
68
+ ): Observable<DocumentLocationsState> =>
69
+ documentStore.listenQuery(HOST_PAGES_QUERY, { id }, {}).pipe(
70
+ map((pages: HostPage[] | null) => {
71
+ const deduped = new Map<string, HostPage>();
72
+ for (const page of pages ?? []) {
73
+ const publishedId = page._id.replace(/^drafts\./, "");
74
+ const existing = deduped.get(publishedId);
75
+ if (!existing || isDraftId(existing._id)) {
76
+ deduped.set(publishedId, page);
77
+ }
78
+ }
79
+
80
+ return {
81
+ locations: [...deduped.values()].flatMap((page) => {
82
+ const pinned = getPinnedPageById(page._id);
83
+ const href =
84
+ pinned?.path ??
85
+ (page.resolvedSlug ? `/${page.resolvedSlug}` : null);
86
+ if (!href) return [];
87
+ return [{ title: page.title || pinned?.title || "Page", href }];
88
+ }),
89
+ };
90
+ }),
91
+ );
92
+
50
93
  const resolveSlugLocation = <T extends { title?: string }>(
51
94
  documentStore: DocumentStore,
52
95
  id: string,
@@ -73,10 +116,6 @@ export const presentation = presentationTool({
73
116
  route: "/",
74
117
  filter: `_type == "page" && _id == "${HOMEPAGE_PINNED_PAGE.id}"`,
75
118
  },
76
- ...Object.entries(CONTENT_TYPE_PREFIXES).map(([type, prefix]) => ({
77
- route: `/${prefix}/:slug`,
78
- filter: `_type == "${type}" && slug.current == $slug`,
79
- })),
80
119
  {
81
120
  route: "/:slug+",
82
121
  filter: `_type == "page" && ${pageResolvedSlugExpression} == $slug`,
@@ -97,16 +136,9 @@ export const presentation = presentationTool({
97
136
  );
98
137
  }
99
138
 
100
- const prefix = CONTENT_TYPE_PREFIXES[params.type];
101
- if (!prefix) return null;
139
+ if (!PAGE_BUILDER_DOCUMENT_TYPES.has(params.type)) return null;
102
140
 
103
- return resolveSlugLocation<{ title?: string; slug?: string }>(
104
- documentStore,
105
- params.id,
106
- `title, "slug": slug.current`,
107
- (doc) => (doc.slug ? `/${prefix}/${doc.slug}` : null),
108
- "Untitled",
109
- );
141
+ return resolveHostPageLocations(documentStore, params.id);
110
142
  },
111
143
  },
112
144
  previewUrl: {
@@ -4,7 +4,7 @@ import { defineBlock } from "@/lib/integrations/sanity/schemas/shared/block";
4
4
 
5
5
  export const blogContent = defineBlock({
6
6
  name: "blogContent",
7
- title: "Blog Content",
7
+ title: "Blog Posts",
8
8
  icon: contentIcon,
9
9
  fields: [
10
10
  defineField({
@@ -80,7 +80,7 @@ export const blogContent = defineBlock({
80
80
  },
81
81
  prepare({ title, date, media }) {
82
82
  return {
83
- title: title || "Blog Content",
83
+ title: title || "Blog Posts",
84
84
  subtitle: date || "No date set",
85
85
  media,
86
86
  };
@@ -252,7 +252,7 @@ export const structure: StructureResolver = (S, context) =>
252
252
 
253
253
  S.divider(),
254
254
 
255
- // GROUP 2: Content -- authors, blog categories
255
+ // GROUP 2: Content -- blog posts, authors, blog categories
256
256
  S.listItem()
257
257
  .title("Content")
258
258
  .icon(contentFolderIcon)
@@ -260,6 +260,9 @@ export const structure: StructureResolver = (S, context) =>
260
260
  S.list()
261
261
  .title("Content")
262
262
  .items([
263
+ S.documentTypeListItem("blogContent")
264
+ .title("Blog Posts")
265
+ .icon(contentIcon),
263
266
  S.documentTypeListItem("author")
264
267
  .title("Authors")
265
268
  .icon(authorIcon),
@@ -299,9 +302,6 @@ export const structure: StructureResolver = (S, context) =>
299
302
  S.documentTypeListItem("gatedContent")
300
303
  .title("Gated Content")
301
304
  .icon(gatedContentIcon),
302
- S.documentTypeListItem("blogContent")
303
- .title("Blog Content")
304
- .icon(contentIcon),
305
305
  S.divider(),
306
306
  S.documentTypeListItem("studioPreview")
307
307
  .title("Component Previews")
@@ -45,6 +45,7 @@ export const metadata = generatePageMetadata({ title: 'About' })
45
45
  // next/image sizes — keys mirror the element's Tailwind variants
46
46
  buildImageSizes({ base: '100vw', desktop: '796px' })
47
47
  buildGridImageSizes({ base: 1, tablet: 2, desktop: 3 })
48
+ buildGridImageSizes({ base: 1, tablet: 2, desktop: 3 }, { maxWidth: 1440 })
48
49
 
49
50
  // Refs — in a component, use the hook: an inline mergeRefs([...]) is a new
50
51
  // callback each render, so React detaches and reattaches every ref.
@@ -15,40 +15,55 @@ export type ByBreakpoint<
15
15
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
16
16
  > = { base: T } & Partial<Record<Extract<keyof B, string>, T>>
17
17
 
18
+ export type ImageSizesOptions<
19
+ B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
20
+ > = { breakpoints?: B }
21
+
22
+ export type GridImageSizesOptions<
23
+ B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
24
+ > = ImageSizesOptions<B> & {
25
+ /** The grid container's `max-width` in px, past which columns stop growing. */
26
+ maxWidth?: number
27
+ }
28
+
29
+ type Condition<T> = { min: number; value: T }
30
+
18
31
  function columnWidth(columns: number): string {
19
32
  return `${Math.round(100 / columns)}vw`
20
33
  }
21
34
 
35
+ function toConditions<T>(
36
+ overrides: Readonly<Record<string, T | undefined>>,
37
+ scale: BreakpointMap
38
+ ): Condition<T>[] {
39
+ return Object.entries(overrides).flatMap(([name, value]) => {
40
+ const min = scale[name]
41
+ return value !== undefined && min !== undefined ? [{ min, value }] : []
42
+ })
43
+ }
44
+
22
45
  /**
23
46
  * Widest first, since `sizes` takes the first matching condition and the last
24
47
  * entry must be bare. `min-width` matches the Tailwind variant's boundary; a
25
48
  * `max-width: <next> - 1` one would leave a fractional-pixel gap.
26
49
  */
27
- function toSizes(
28
- base: string,
29
- overrides: Readonly<Record<string, string | undefined>>,
30
- scale: BreakpointMap
31
- ): string {
32
- const conditions: string[] = []
33
-
34
- for (const [name, min] of Object.entries(scale).sort(
35
- ([, a], [, b]) => b - a
36
- )) {
37
- const value = overrides[name]
38
- if (value !== undefined) {
39
- conditions.push(`(min-width: ${min}px) ${value}`)
40
- }
41
- }
50
+ function joinSizes(base: string, conditions: Condition<string>[]): string {
51
+ const sorted = [...conditions].sort((a, b) => b.min - a.min)
42
52
 
43
- return [...conditions, base].join(", ")
53
+ return [
54
+ ...sorted.map(({ min, value }) => `(min-width: ${min}px) ${value}`),
55
+ base,
56
+ ].join(", ")
44
57
  }
45
58
 
46
59
  /** Builds `sizes` from the CSS length the image renders at per breakpoint. */
47
60
  export function buildImageSizes<
48
61
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
49
- >(widths: ByBreakpoint<string, B>, breakpoints?: B): string {
62
+ >(widths: ByBreakpoint<string, B>, options: ImageSizesOptions<B> = {}): string {
50
63
  const { base, ...overrides } = widths
51
- return toSizes(base, overrides, breakpoints ?? BASEMENT_BREAKPOINTS)
64
+ const scale = options.breakpoints ?? BASEMENT_BREAKPOINTS
65
+
66
+ return joinSizes(base, toConditions<string>(overrides, scale))
52
67
  }
53
68
 
54
69
  /**
@@ -58,15 +73,39 @@ export function buildImageSizes<
58
73
  */
59
74
  export function buildGridImageSizes<
60
75
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
61
- >(columns: ByBreakpoint<number, B>, breakpoints?: B): string {
76
+ >(
77
+ columns: ByBreakpoint<number, B>,
78
+ options: GridImageSizesOptions<B> = {}
79
+ ): string {
80
+ const { maxWidth, breakpoints } = options
62
81
  const { base, ...overrides } = columns
63
- const widths: Record<string, string> = {}
82
+ const steps = toConditions<number>(
83
+ overrides,
84
+ breakpoints ?? BASEMENT_BREAKPOINTS
85
+ )
86
+ const cap =
87
+ maxWidth !== undefined && Number.isFinite(maxWidth) && maxWidth > 0
88
+ ? maxWidth
89
+ : undefined
90
+
91
+ // The cap rarely lands on a breakpoint, so it needs its own condition.
92
+ if (cap !== undefined && !steps.some(({ min }) => min === cap)) {
93
+ const below = steps
94
+ .filter(({ min }) => min < cap)
95
+ .sort((a, b) => a.min - b.min)
96
+ .at(-1)
64
97
 
65
- for (const [name, count] of Object.entries(overrides)) {
66
- if (count !== undefined) {
67
- widths[name] = columnWidth(count)
68
- }
98
+ steps.push({ min: cap, value: below?.value ?? base })
69
99
  }
70
100
 
71
- return toSizes(columnWidth(base), widths, breakpoints ?? BASEMENT_BREAKPOINTS)
101
+ return joinSizes(
102
+ columnWidth(base),
103
+ steps.map(({ min, value }) => ({
104
+ min,
105
+ value:
106
+ cap !== undefined && min >= cap
107
+ ? `${Math.round(cap / value)}px`
108
+ : columnWidth(value),
109
+ }))
110
+ )
72
111
  }
@@ -45,6 +45,7 @@ export const metadata = generatePageMetadata({ title: 'About' })
45
45
  // next/image sizes — keys mirror the element's Tailwind variants
46
46
  buildImageSizes({ base: '100vw', desktop: '796px' })
47
47
  buildGridImageSizes({ base: 1, tablet: 2, desktop: 3 })
48
+ buildGridImageSizes({ base: 1, tablet: 2, desktop: 3 }, { maxWidth: 1440 })
48
49
 
49
50
  // Refs — in a component, use the hook: an inline mergeRefs([...]) is a new
50
51
  // callback each render, so React detaches and reattaches every ref.
@@ -15,40 +15,55 @@ export type ByBreakpoint<
15
15
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
16
16
  > = { base: T } & Partial<Record<Extract<keyof B, string>, T>>
17
17
 
18
+ export type ImageSizesOptions<
19
+ B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
20
+ > = { breakpoints?: B }
21
+
22
+ export type GridImageSizesOptions<
23
+ B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
24
+ > = ImageSizesOptions<B> & {
25
+ /** The grid container's `max-width` in px, past which columns stop growing. */
26
+ maxWidth?: number
27
+ }
28
+
29
+ type Condition<T> = { min: number; value: T }
30
+
18
31
  function columnWidth(columns: number): string {
19
32
  return `${Math.round(100 / columns)}vw`
20
33
  }
21
34
 
35
+ function toConditions<T>(
36
+ overrides: Readonly<Record<string, T | undefined>>,
37
+ scale: BreakpointMap
38
+ ): Condition<T>[] {
39
+ return Object.entries(overrides).flatMap(([name, value]) => {
40
+ const min = scale[name]
41
+ return value !== undefined && min !== undefined ? [{ min, value }] : []
42
+ })
43
+ }
44
+
22
45
  /**
23
46
  * Widest first, since `sizes` takes the first matching condition and the last
24
47
  * entry must be bare. `min-width` matches the Tailwind variant's boundary; a
25
48
  * `max-width: <next> - 1` one would leave a fractional-pixel gap.
26
49
  */
27
- function toSizes(
28
- base: string,
29
- overrides: Readonly<Record<string, string | undefined>>,
30
- scale: BreakpointMap
31
- ): string {
32
- const conditions: string[] = []
33
-
34
- for (const [name, min] of Object.entries(scale).sort(
35
- ([, a], [, b]) => b - a
36
- )) {
37
- const value = overrides[name]
38
- if (value !== undefined) {
39
- conditions.push(`(min-width: ${min}px) ${value}`)
40
- }
41
- }
50
+ function joinSizes(base: string, conditions: Condition<string>[]): string {
51
+ const sorted = [...conditions].sort((a, b) => b.min - a.min)
42
52
 
43
- return [...conditions, base].join(", ")
53
+ return [
54
+ ...sorted.map(({ min, value }) => `(min-width: ${min}px) ${value}`),
55
+ base,
56
+ ].join(", ")
44
57
  }
45
58
 
46
59
  /** Builds `sizes` from the CSS length the image renders at per breakpoint. */
47
60
  export function buildImageSizes<
48
61
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
49
- >(widths: ByBreakpoint<string, B>, breakpoints?: B): string {
62
+ >(widths: ByBreakpoint<string, B>, options: ImageSizesOptions<B> = {}): string {
50
63
  const { base, ...overrides } = widths
51
- return toSizes(base, overrides, breakpoints ?? BASEMENT_BREAKPOINTS)
64
+ const scale = options.breakpoints ?? BASEMENT_BREAKPOINTS
65
+
66
+ return joinSizes(base, toConditions<string>(overrides, scale))
52
67
  }
53
68
 
54
69
  /**
@@ -58,15 +73,39 @@ export function buildImageSizes<
58
73
  */
59
74
  export function buildGridImageSizes<
60
75
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
61
- >(columns: ByBreakpoint<number, B>, breakpoints?: B): string {
76
+ >(
77
+ columns: ByBreakpoint<number, B>,
78
+ options: GridImageSizesOptions<B> = {}
79
+ ): string {
80
+ const { maxWidth, breakpoints } = options
62
81
  const { base, ...overrides } = columns
63
- const widths: Record<string, string> = {}
82
+ const steps = toConditions<number>(
83
+ overrides,
84
+ breakpoints ?? BASEMENT_BREAKPOINTS
85
+ )
86
+ const cap =
87
+ maxWidth !== undefined && Number.isFinite(maxWidth) && maxWidth > 0
88
+ ? maxWidth
89
+ : undefined
90
+
91
+ // The cap rarely lands on a breakpoint, so it needs its own condition.
92
+ if (cap !== undefined && !steps.some(({ min }) => min === cap)) {
93
+ const below = steps
94
+ .filter(({ min }) => min < cap)
95
+ .sort((a, b) => a.min - b.min)
96
+ .at(-1)
64
97
 
65
- for (const [name, count] of Object.entries(overrides)) {
66
- if (count !== undefined) {
67
- widths[name] = columnWidth(count)
68
- }
98
+ steps.push({ min: cap, value: below?.value ?? base })
69
99
  }
70
100
 
71
- return toSizes(columnWidth(base), widths, breakpoints ?? BASEMENT_BREAKPOINTS)
101
+ return joinSizes(
102
+ columnWidth(base),
103
+ steps.map(({ min, value }) => ({
104
+ min,
105
+ value:
106
+ cap !== undefined && min >= cap
107
+ ? `${Math.round(cap / value)}px`
108
+ : columnWidth(value),
109
+ }))
110
+ )
72
111
  }
@@ -90,7 +90,7 @@ export const PageDocument = ({
90
90
  // Editor-selected page-level structured data (FAQ, Event, custom, etc.).
91
91
  const pageJsonLd = buildPageJsonLd(page.metadata?.jsonLd)
92
92
 
93
- // Blog posts (pages with a Blog Content block) emit Article JSON-LD with
93
+ // Blog posts (pages with a Blog Posts block) emit Article JSON-LD with
94
94
  // fields derived from the post — no Custom block needed. Skipped when the
95
95
  // editor already supplied an Article (via a Custom block) to avoid duplicates.
96
96
  const blogBlock = page.pageBuilder?.find(
@@ -15,40 +15,55 @@ export type ByBreakpoint<
15
15
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
16
16
  > = { base: T } & Partial<Record<Extract<keyof B, string>, T>>
17
17
 
18
+ export type ImageSizesOptions<
19
+ B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
20
+ > = { breakpoints?: B }
21
+
22
+ export type GridImageSizesOptions<
23
+ B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
24
+ > = ImageSizesOptions<B> & {
25
+ /** The grid container's `max-width` in px, past which columns stop growing. */
26
+ maxWidth?: number
27
+ }
28
+
29
+ type Condition<T> = { min: number; value: T }
30
+
18
31
  function columnWidth(columns: number): string {
19
32
  return `${Math.round(100 / columns)}vw`
20
33
  }
21
34
 
35
+ function toConditions<T>(
36
+ overrides: Readonly<Record<string, T | undefined>>,
37
+ scale: BreakpointMap
38
+ ): Condition<T>[] {
39
+ return Object.entries(overrides).flatMap(([name, value]) => {
40
+ const min = scale[name]
41
+ return value !== undefined && min !== undefined ? [{ min, value }] : []
42
+ })
43
+ }
44
+
22
45
  /**
23
46
  * Widest first, since `sizes` takes the first matching condition and the last
24
47
  * entry must be bare. `min-width` matches the Tailwind variant's boundary; a
25
48
  * `max-width: <next> - 1` one would leave a fractional-pixel gap.
26
49
  */
27
- function toSizes(
28
- base: string,
29
- overrides: Readonly<Record<string, string | undefined>>,
30
- scale: BreakpointMap
31
- ): string {
32
- const conditions: string[] = []
33
-
34
- for (const [name, min] of Object.entries(scale).sort(
35
- ([, a], [, b]) => b - a
36
- )) {
37
- const value = overrides[name]
38
- if (value !== undefined) {
39
- conditions.push(`(min-width: ${min}px) ${value}`)
40
- }
41
- }
50
+ function joinSizes(base: string, conditions: Condition<string>[]): string {
51
+ const sorted = [...conditions].sort((a, b) => b.min - a.min)
42
52
 
43
- return [...conditions, base].join(", ")
53
+ return [
54
+ ...sorted.map(({ min, value }) => `(min-width: ${min}px) ${value}`),
55
+ base,
56
+ ].join(", ")
44
57
  }
45
58
 
46
59
  /** Builds `sizes` from the CSS length the image renders at per breakpoint. */
47
60
  export function buildImageSizes<
48
61
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
49
- >(widths: ByBreakpoint<string, B>, breakpoints?: B): string {
62
+ >(widths: ByBreakpoint<string, B>, options: ImageSizesOptions<B> = {}): string {
50
63
  const { base, ...overrides } = widths
51
- return toSizes(base, overrides, breakpoints ?? BASEMENT_BREAKPOINTS)
64
+ const scale = options.breakpoints ?? BASEMENT_BREAKPOINTS
65
+
66
+ return joinSizes(base, toConditions<string>(overrides, scale))
52
67
  }
53
68
 
54
69
  /**
@@ -58,15 +73,39 @@ export function buildImageSizes<
58
73
  */
59
74
  export function buildGridImageSizes<
60
75
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
61
- >(columns: ByBreakpoint<number, B>, breakpoints?: B): string {
76
+ >(
77
+ columns: ByBreakpoint<number, B>,
78
+ options: GridImageSizesOptions<B> = {}
79
+ ): string {
80
+ const { maxWidth, breakpoints } = options
62
81
  const { base, ...overrides } = columns
63
- const widths: Record<string, string> = {}
82
+ const steps = toConditions<number>(
83
+ overrides,
84
+ breakpoints ?? BASEMENT_BREAKPOINTS
85
+ )
86
+ const cap =
87
+ maxWidth !== undefined && Number.isFinite(maxWidth) && maxWidth > 0
88
+ ? maxWidth
89
+ : undefined
90
+
91
+ // The cap rarely lands on a breakpoint, so it needs its own condition.
92
+ if (cap !== undefined && !steps.some(({ min }) => min === cap)) {
93
+ const below = steps
94
+ .filter(({ min }) => min < cap)
95
+ .sort((a, b) => a.min - b.min)
96
+ .at(-1)
64
97
 
65
- for (const [name, count] of Object.entries(overrides)) {
66
- if (count !== undefined) {
67
- widths[name] = columnWidth(count)
68
- }
98
+ steps.push({ min: cap, value: below?.value ?? base })
69
99
  }
70
100
 
71
- return toSizes(columnWidth(base), widths, breakpoints ?? BASEMENT_BREAKPOINTS)
101
+ return joinSizes(
102
+ columnWidth(base),
103
+ steps.map(({ min, value }) => ({
104
+ min,
105
+ value:
106
+ cap !== undefined && min >= cap
107
+ ? `${Math.round(cap / value)}px`
108
+ : columnWidth(value),
109
+ }))
110
+ )
72
111
  }
@@ -92,7 +92,7 @@ export const PageDocument = ({
92
92
  // Editor-selected page-level structured data (FAQ, Event, custom, etc.).
93
93
  const pageJsonLd = buildPageJsonLd(page.metadata?.jsonLd)
94
94
 
95
- // Blog posts (pages with a Blog Content block) emit Article JSON-LD with
95
+ // Blog posts (pages with a Blog Posts block) emit Article JSON-LD with
96
96
  // fields derived from the post — no Custom block needed. Skipped when the
97
97
  // editor already supplied an Article (via a Custom block) to avoid duplicates.
98
98
  const blogBlock = page.pageBuilder?.find(
@@ -15,40 +15,55 @@ export type ByBreakpoint<
15
15
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
16
16
  > = { base: T } & Partial<Record<Extract<keyof B, string>, T>>
17
17
 
18
+ export type ImageSizesOptions<
19
+ B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
20
+ > = { breakpoints?: B }
21
+
22
+ export type GridImageSizesOptions<
23
+ B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
24
+ > = ImageSizesOptions<B> & {
25
+ /** The grid container's `max-width` in px, past which columns stop growing. */
26
+ maxWidth?: number
27
+ }
28
+
29
+ type Condition<T> = { min: number; value: T }
30
+
18
31
  function columnWidth(columns: number): string {
19
32
  return `${Math.round(100 / columns)}vw`
20
33
  }
21
34
 
35
+ function toConditions<T>(
36
+ overrides: Readonly<Record<string, T | undefined>>,
37
+ scale: BreakpointMap
38
+ ): Condition<T>[] {
39
+ return Object.entries(overrides).flatMap(([name, value]) => {
40
+ const min = scale[name]
41
+ return value !== undefined && min !== undefined ? [{ min, value }] : []
42
+ })
43
+ }
44
+
22
45
  /**
23
46
  * Widest first, since `sizes` takes the first matching condition and the last
24
47
  * entry must be bare. `min-width` matches the Tailwind variant's boundary; a
25
48
  * `max-width: <next> - 1` one would leave a fractional-pixel gap.
26
49
  */
27
- function toSizes(
28
- base: string,
29
- overrides: Readonly<Record<string, string | undefined>>,
30
- scale: BreakpointMap
31
- ): string {
32
- const conditions: string[] = []
33
-
34
- for (const [name, min] of Object.entries(scale).sort(
35
- ([, a], [, b]) => b - a
36
- )) {
37
- const value = overrides[name]
38
- if (value !== undefined) {
39
- conditions.push(`(min-width: ${min}px) ${value}`)
40
- }
41
- }
50
+ function joinSizes(base: string, conditions: Condition<string>[]): string {
51
+ const sorted = [...conditions].sort((a, b) => b.min - a.min)
42
52
 
43
- return [...conditions, base].join(", ")
53
+ return [
54
+ ...sorted.map(({ min, value }) => `(min-width: ${min}px) ${value}`),
55
+ base,
56
+ ].join(", ")
44
57
  }
45
58
 
46
59
  /** Builds `sizes` from the CSS length the image renders at per breakpoint. */
47
60
  export function buildImageSizes<
48
61
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
49
- >(widths: ByBreakpoint<string, B>, breakpoints?: B): string {
62
+ >(widths: ByBreakpoint<string, B>, options: ImageSizesOptions<B> = {}): string {
50
63
  const { base, ...overrides } = widths
51
- return toSizes(base, overrides, breakpoints ?? BASEMENT_BREAKPOINTS)
64
+ const scale = options.breakpoints ?? BASEMENT_BREAKPOINTS
65
+
66
+ return joinSizes(base, toConditions<string>(overrides, scale))
52
67
  }
53
68
 
54
69
  /**
@@ -58,15 +73,39 @@ export function buildImageSizes<
58
73
  */
59
74
  export function buildGridImageSizes<
60
75
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
61
- >(columns: ByBreakpoint<number, B>, breakpoints?: B): string {
76
+ >(
77
+ columns: ByBreakpoint<number, B>,
78
+ options: GridImageSizesOptions<B> = {}
79
+ ): string {
80
+ const { maxWidth, breakpoints } = options
62
81
  const { base, ...overrides } = columns
63
- const widths: Record<string, string> = {}
82
+ const steps = toConditions<number>(
83
+ overrides,
84
+ breakpoints ?? BASEMENT_BREAKPOINTS
85
+ )
86
+ const cap =
87
+ maxWidth !== undefined && Number.isFinite(maxWidth) && maxWidth > 0
88
+ ? maxWidth
89
+ : undefined
90
+
91
+ // The cap rarely lands on a breakpoint, so it needs its own condition.
92
+ if (cap !== undefined && !steps.some(({ min }) => min === cap)) {
93
+ const below = steps
94
+ .filter(({ min }) => min < cap)
95
+ .sort((a, b) => a.min - b.min)
96
+ .at(-1)
64
97
 
65
- for (const [name, count] of Object.entries(overrides)) {
66
- if (count !== undefined) {
67
- widths[name] = columnWidth(count)
68
- }
98
+ steps.push({ min: cap, value: below?.value ?? base })
69
99
  }
70
100
 
71
- return toSizes(columnWidth(base), widths, breakpoints ?? BASEMENT_BREAKPOINTS)
101
+ return joinSizes(
102
+ columnWidth(base),
103
+ steps.map(({ min, value }) => ({
104
+ min,
105
+ value:
106
+ cap !== undefined && min >= cap
107
+ ? `${Math.round(cap / value)}px`
108
+ : columnWidth(value),
109
+ }))
110
+ )
72
111
  }
@@ -45,6 +45,7 @@ export const metadata = generatePageMetadata({ title: 'About' })
45
45
  // next/image sizes — keys mirror the element's Tailwind variants
46
46
  buildImageSizes({ base: '100vw', desktop: '796px' })
47
47
  buildGridImageSizes({ base: 1, tablet: 2, desktop: 3 })
48
+ buildGridImageSizes({ base: 1, tablet: 2, desktop: 3 }, { maxWidth: 1440 })
48
49
 
49
50
  // Refs — in a component, use the hook: an inline mergeRefs([...]) is a new
50
51
  // callback each render, so React detaches and reattaches every ref.
@@ -15,40 +15,55 @@ export type ByBreakpoint<
15
15
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
16
16
  > = { base: T } & Partial<Record<Extract<keyof B, string>, T>>
17
17
 
18
+ export type ImageSizesOptions<
19
+ B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
20
+ > = { breakpoints?: B }
21
+
22
+ export type GridImageSizesOptions<
23
+ B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
24
+ > = ImageSizesOptions<B> & {
25
+ /** The grid container's `max-width` in px, past which columns stop growing. */
26
+ maxWidth?: number
27
+ }
28
+
29
+ type Condition<T> = { min: number; value: T }
30
+
18
31
  function columnWidth(columns: number): string {
19
32
  return `${Math.round(100 / columns)}vw`
20
33
  }
21
34
 
35
+ function toConditions<T>(
36
+ overrides: Readonly<Record<string, T | undefined>>,
37
+ scale: BreakpointMap
38
+ ): Condition<T>[] {
39
+ return Object.entries(overrides).flatMap(([name, value]) => {
40
+ const min = scale[name]
41
+ return value !== undefined && min !== undefined ? [{ min, value }] : []
42
+ })
43
+ }
44
+
22
45
  /**
23
46
  * Widest first, since `sizes` takes the first matching condition and the last
24
47
  * entry must be bare. `min-width` matches the Tailwind variant's boundary; a
25
48
  * `max-width: <next> - 1` one would leave a fractional-pixel gap.
26
49
  */
27
- function toSizes(
28
- base: string,
29
- overrides: Readonly<Record<string, string | undefined>>,
30
- scale: BreakpointMap
31
- ): string {
32
- const conditions: string[] = []
33
-
34
- for (const [name, min] of Object.entries(scale).sort(
35
- ([, a], [, b]) => b - a
36
- )) {
37
- const value = overrides[name]
38
- if (value !== undefined) {
39
- conditions.push(`(min-width: ${min}px) ${value}`)
40
- }
41
- }
50
+ function joinSizes(base: string, conditions: Condition<string>[]): string {
51
+ const sorted = [...conditions].sort((a, b) => b.min - a.min)
42
52
 
43
- return [...conditions, base].join(", ")
53
+ return [
54
+ ...sorted.map(({ min, value }) => `(min-width: ${min}px) ${value}`),
55
+ base,
56
+ ].join(", ")
44
57
  }
45
58
 
46
59
  /** Builds `sizes` from the CSS length the image renders at per breakpoint. */
47
60
  export function buildImageSizes<
48
61
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
49
- >(widths: ByBreakpoint<string, B>, breakpoints?: B): string {
62
+ >(widths: ByBreakpoint<string, B>, options: ImageSizesOptions<B> = {}): string {
50
63
  const { base, ...overrides } = widths
51
- return toSizes(base, overrides, breakpoints ?? BASEMENT_BREAKPOINTS)
64
+ const scale = options.breakpoints ?? BASEMENT_BREAKPOINTS
65
+
66
+ return joinSizes(base, toConditions<string>(overrides, scale))
52
67
  }
53
68
 
54
69
  /**
@@ -58,15 +73,39 @@ export function buildImageSizes<
58
73
  */
59
74
  export function buildGridImageSizes<
60
75
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
61
- >(columns: ByBreakpoint<number, B>, breakpoints?: B): string {
76
+ >(
77
+ columns: ByBreakpoint<number, B>,
78
+ options: GridImageSizesOptions<B> = {}
79
+ ): string {
80
+ const { maxWidth, breakpoints } = options
62
81
  const { base, ...overrides } = columns
63
- const widths: Record<string, string> = {}
82
+ const steps = toConditions<number>(
83
+ overrides,
84
+ breakpoints ?? BASEMENT_BREAKPOINTS
85
+ )
86
+ const cap =
87
+ maxWidth !== undefined && Number.isFinite(maxWidth) && maxWidth > 0
88
+ ? maxWidth
89
+ : undefined
90
+
91
+ // The cap rarely lands on a breakpoint, so it needs its own condition.
92
+ if (cap !== undefined && !steps.some(({ min }) => min === cap)) {
93
+ const below = steps
94
+ .filter(({ min }) => min < cap)
95
+ .sort((a, b) => a.min - b.min)
96
+ .at(-1)
64
97
 
65
- for (const [name, count] of Object.entries(overrides)) {
66
- if (count !== undefined) {
67
- widths[name] = columnWidth(count)
68
- }
98
+ steps.push({ min: cap, value: below?.value ?? base })
69
99
  }
70
100
 
71
- return toSizes(columnWidth(base), widths, breakpoints ?? BASEMENT_BREAKPOINTS)
101
+ return joinSizes(
102
+ columnWidth(base),
103
+ steps.map(({ min, value }) => ({
104
+ min,
105
+ value:
106
+ cap !== undefined && min >= cap
107
+ ? `${Math.round(cap / value)}px`
108
+ : columnWidth(value),
109
+ }))
110
+ )
72
111
  }