bsmnt 0.13.0 → 0.14.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 (90) hide show
  1. package/dist/modules/features/cms/sanity-pagebuilder/config.d.ts.map +1 -1
  2. package/dist/modules/features/cms/sanity-pagebuilder/config.js +1 -0
  3. package/dist/modules/features/cms/sanity-pagebuilder/config.js.map +1 -1
  4. package/dist/modules/features/cms/sanity-pagebuilder-cache/config.d.ts.map +1 -1
  5. package/dist/modules/features/cms/sanity-pagebuilder-cache/config.js +1 -0
  6. package/dist/modules/features/cms/sanity-pagebuilder-cache/config.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/agent-skills/ashby-pagebuilder-block/SKILL.md +29 -11
  9. package/src/agent-skills/hubspot-pagebuilder-block/SKILL.md +31 -12
  10. package/src/agent-skills/page-builder-block/SKILL.md +26 -13
  11. package/src/modules/features/cms/sanity-pagebuilder/files/app/api/revalidate/route.ts +2 -2
  12. package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/rich-text.tsx +1 -1
  13. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/block-actions-item.tsx +2 -2
  14. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/component-preview-frame.tsx +124 -0
  15. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/component-preview-pane.tsx +25 -0
  16. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-builder-input.tsx +3 -26
  17. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-builder-insert-menu.tsx +121 -93
  18. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/icons.ts +2 -2
  19. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +22 -50
  20. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/presentation.ts +1 -18
  21. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +56 -64
  22. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/sanity.config.ts +4 -8
  23. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/index.ts +0 -1
  24. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/blog-content.ts +2 -2
  25. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts +1 -4
  26. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/block.ts +4 -4
  27. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page-builder.ts +4 -18
  28. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page.ts +11 -37
  29. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/singletons.ts +0 -16
  30. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +35 -33
  31. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/studio-embed.ts +22 -0
  32. package/src/modules/features/cms/sanity-pagebuilder-cache/files/app/api/revalidate/route.ts +2 -2
  33. package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/sanity/rich-text.tsx +1 -1
  34. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/block-actions-item.tsx +2 -2
  35. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/component-preview-frame.tsx +124 -0
  36. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/component-preview-pane.tsx +25 -0
  37. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/page-builder-input.tsx +3 -26
  38. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/page-builder-insert-menu.tsx +121 -93
  39. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/icons.ts +2 -2
  40. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/page-builder-config.ts +22 -50
  41. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/presentation.ts +1 -18
  42. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/queries.ts +56 -64
  43. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/sanity.config.ts +4 -8
  44. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/index.ts +0 -1
  45. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/reusable/blog-content.ts +2 -2
  46. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/index.ts +1 -4
  47. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/block.ts +4 -4
  48. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/page-builder.ts +4 -18
  49. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/page.ts +11 -37
  50. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/singletons.ts +0 -16
  51. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/structure.ts +35 -33
  52. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/utils/studio-embed.ts +22 -0
  53. package/src/modules/features/forms/hubspot/files/_sanity-adapter/form-block.ts +4 -10
  54. package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/README.md +10 -5
  55. package/src/modules/features/jobs/ashby/files/_sanity-adapter/job-board-block.ts +0 -7
  56. package/src/modules/features/jobs/ashby/files/lib/integrations/ashby/README.md +11 -5
  57. package/src/templates/next-default/lib/utils/README.md +1 -0
  58. package/src/templates/next-default/lib/utils/image-sizes.ts +64 -25
  59. package/src/templates/next-experiments/lib/utils/README.md +1 -0
  60. package/src/templates/next-experiments/lib/utils/image-sizes.ts +64 -25
  61. package/src/templates/next-pagebuilder/AGENTS.md +19 -7
  62. package/src/templates/next-pagebuilder/README.md +15 -3
  63. package/src/templates/next-pagebuilder/app/actions/unlock-gated-content.ts +5 -4
  64. package/src/templates/next-pagebuilder/app/components/page.tsx +81 -0
  65. package/src/templates/next-pagebuilder/components/page-builder/components/gated-content/index.tsx +8 -3
  66. package/src/templates/next-pagebuilder/components/page-builder/library.ts +183 -0
  67. package/src/templates/next-pagebuilder/components/page-builder/registry.tsx +4 -1
  68. package/src/templates/next-pagebuilder/components/page-builder/renderer.tsx +3 -1
  69. package/src/templates/next-pagebuilder/components/page-builder/stage.tsx +10 -0
  70. package/src/templates/next-pagebuilder/components/page-document/index.tsx +2 -1
  71. package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +139 -271
  72. package/src/templates/next-pagebuilder/lib/utils/image-sizes.ts +64 -25
  73. package/src/templates/next-pagebuilder-cache/AGENTS.md +19 -7
  74. package/src/templates/next-pagebuilder-cache/README.md +15 -3
  75. package/src/templates/next-pagebuilder-cache/app/actions/unlock-gated-content.ts +5 -4
  76. package/src/templates/next-pagebuilder-cache/app/components/page.tsx +91 -0
  77. package/src/templates/next-pagebuilder-cache/components/page-builder/components/gated-content/index.tsx +8 -3
  78. package/src/templates/next-pagebuilder-cache/components/page-builder/library.ts +183 -0
  79. package/src/templates/next-pagebuilder-cache/components/page-builder/registry.tsx +4 -1
  80. package/src/templates/next-pagebuilder-cache/components/page-builder/renderer.tsx +3 -1
  81. package/src/templates/next-pagebuilder-cache/components/page-builder/stage.tsx +10 -0
  82. package/src/templates/next-pagebuilder-cache/components/page-document/index.tsx +2 -1
  83. package/src/templates/next-pagebuilder-cache/lib/integrations/sanity/sanity.types.ts +139 -271
  84. package/src/templates/next-pagebuilder-cache/lib/utils/image-sizes.ts +64 -25
  85. package/src/templates/next-webgl/lib/utils/README.md +1 -0
  86. package/src/templates/next-webgl/lib/utils/image-sizes.ts +64 -25
  87. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/use-studio-preview-urls.ts +0 -62
  88. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/studio-preview.ts +0 -37
  89. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/use-studio-preview-urls.ts +0 -62
  90. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/studio-preview.ts +0 -37
@@ -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
  }
@@ -11,7 +11,9 @@ page.
11
11
  Studio with a slug, not a directory. `app/(content)/[[...slug]]/page.tsx`
12
12
  resolves every one of them; a hand-written route shadows that resolver and
13
13
  becomes a URL no editor can see, move or delete. The only things that belong in
14
- `app/` are non-page endpoints — the Studio, API handlers, sitemap, feed.
14
+ `app/` are non-page endpoints — the Studio, API handlers, sitemap, feed, and
15
+ `app/components`, the Studio's live block preview: it renders fixtures rather
16
+ than content, is gated to Studio embeds by `isStudioEmbed()` and is `noindex`.
15
17
 
16
18
  It runs with [Cache Components](https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents)
17
19
  (`cacheComponents: true`), so every Sanity read sits behind a `'use cache'`
@@ -22,10 +24,18 @@ boundaries where they are unless you mean to move them.
22
24
 
23
25
  ## Page-builder blocks
24
26
 
27
+ **Blocks are inline objects, never documents.** A placement belongs to exactly
28
+ one page, so the page's `pageBuilder` array stores the block itself and nothing
29
+ is dereferenced at read time. Don't reintroduce a block-as-document with a
30
+ reference member — that model was removed deliberately: no block was ever
31
+ reused, and it cost a hop on every read plus a second id to address.
32
+
25
33
  **A block is `defineBlock` + a registry entry.** Define the schema with
26
34
  `defineBlock` (`lib/integrations/sanity/schemas/shared/block.ts`), never
27
- `defineType` — it implies `type: "document"` and appends **Anchor ID** and
28
- **Presentation Mode** for you. Register the component in
35
+ `defineType` — it implies `type: "object"` and appends **Anchor ID** and
36
+ **Presentation Mode** for you. The array member's type name *is* the block's
37
+ `_type`, so the registry, the insert menu and the fixture library all key off
38
+ the same string. Register the component in
29
39
  `components/page-builder/registry.tsx`; the renderer is driven by that map, so
30
40
  there is no switch to extend.
31
41
 
@@ -40,10 +50,12 @@ that was never registered — is a typecheck error.
40
50
  drives the insert menu; `misc` only when nothing else fits). Never collapse them
41
51
  into one field: that is what made the menu a single flat list.
42
52
 
43
- **And a preview image.** The insert menu renders one tile per block from a
44
- `studioPreview` document (Components → Component Previews) keyed
45
- `block.<memberName>`. A block without one falls back to its schema icon, which
46
- is what made the old menu unreadable.
53
+ **And a fixture.** A block isn't done until `components/page-builder/library.ts`
54
+ has an entry for it: a stand-in value typed as the block's query result, plus a
55
+ one-line note. That entry is the block's preview everywhere — the live pane in
56
+ the insert menu, its row in Studio → Components, and the `/components` route,
57
+ all rendered by the production renderer. Without one the picker just says the
58
+ component has no preview.
47
59
 
48
60
  **Never hardcode vertical padding in a block component.** The renderer wraps
49
61
  each block in `<Section>` (`components/page-builder/section.tsx`), which owns
@@ -25,14 +25,18 @@ bun dev
25
25
 
26
26
  ## Page-builder blocks
27
27
 
28
- A block is two halves — a schema document and a React component — and each half
29
- has one place that makes it a block rather than an ordinary document or a loose
28
+ A block is two halves — a schema type and a React component — and each half has
29
+ one place that makes it a block rather than an ordinary object type or a loose
30
30
  component. Use them and Anchor ID, Presentation Mode and the spacing scale come
31
31
  for free.
32
32
 
33
+ Blocks are **inline objects** in the page's `pageBuilder` array, never documents
34
+ and never referenced: a placement belongs to exactly one page, so the block
35
+ lives on that page and nothing is dereferenced at read time.
36
+
33
37
  **Define the schema with `defineBlock`, never `defineType`**
34
38
  (`lib/integrations/sanity/schemas/shared/block.ts`). It implies
35
- `type: "document"` and appends two fields to every block:
39
+ `type: "object"` and appends two fields to every block:
36
40
 
37
41
  - **Anchor ID** — optional. Left empty, the anchor is generated from the block's
38
42
  own title at render time (`headline` / `heading` / `teaserHeading` / `title` /
@@ -61,6 +65,14 @@ block in `<Section>` (`components/page-builder/section.tsx`), which owns the
61
65
  component returns its content only — no outer `<section>`, no `py-*` / `pt-*` /
62
66
  `pb-*` on its root. Internal gaps between a block's own parts are fine.
63
67
 
68
+ **Every block ships a fixture in `components/page-builder/library.ts`**: a
69
+ stand-in value typed as the block's query result, plus a one-line note. It is
70
+ the block's only preview, and it is rendered by the production renderer at
71
+ `/components` — a route the Studio embeds in an iframe, so the insert menu and
72
+ **Components** in the Studio show the real block rather than a screenshot. The
73
+ route is gated to Studio embeds and `noindex`; a block with no entry simply has
74
+ no preview.
75
+
64
76
  ### The spacing scale is content, not code
65
77
 
66
78
  Editors own it at **Layout → Spacing Scale**: a list of steps, each with a name
@@ -14,13 +14,14 @@ export type UnlockResult =
14
14
  | { ok: true; payload: GatedContentPayload }
15
15
  | { ok: false; error: string }
16
16
 
17
- type UnlockInput = { documentId: string; email: string }
17
+ type UnlockInput = { pageId: string; blockKey: string; email: string }
18
18
 
19
19
  export async function unlockGatedContent({
20
- documentId,
20
+ pageId,
21
+ blockKey,
21
22
  email,
22
23
  }: UnlockInput): Promise<UnlockResult> {
23
- if (!documentId) return { ok: false, error: "Missing content id." }
24
+ if (!(pageId && blockKey)) return { ok: false, error: "Missing content id." }
24
25
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
25
26
  return { ok: false, error: "Enter a valid email address." }
26
27
  }
@@ -38,7 +39,7 @@ export async function unlockGatedContent({
38
39
  // is how `variant` goes missing.
39
40
  const payload = await client.fetch<GATED_CONTENT_QUERY_RESULT>(
40
41
  GATED_CONTENT_QUERY,
41
- { id: documentId },
42
+ { pageId, blockKey },
42
43
  toSanityFetchOptions({
43
44
  ...fetchOptions,
44
45
  // stega must stay off: it injects invisible characters into strings, which
@@ -0,0 +1,91 @@
1
+ import type { Metadata } from "next"
2
+ import { draftMode } from "next/headers"
3
+ import { notFound } from "next/navigation"
4
+ import { Suspense } from "react"
5
+ import { SpacingScale } from "@/components/layout/spacing-scale"
6
+ import {
7
+ COMPONENT_LIBRARY,
8
+ COMPONENT_PREVIEW_PARAM,
9
+ getLibraryEntry,
10
+ type LibraryEntry,
11
+ } from "@/components/page-builder/library"
12
+ import { Renderer } from "@/components/page-builder/renderer"
13
+ import { Stage } from "@/components/page-builder/stage"
14
+ import { isStudioEmbed } from "@/lib/utils/studio-embed"
15
+
16
+ export const metadata: Metadata = {
17
+ title: "Components",
18
+ robots: { index: false, follow: false, nocache: true },
19
+ }
20
+
21
+ type Props = {
22
+ searchParams: Promise<Record<string, string | string[] | undefined>>
23
+ }
24
+
25
+ const firstValue = (value: string | string[] | undefined) =>
26
+ Array.isArray(value) ? value[0] : value
27
+
28
+ const PREVIEW_PAGE_ID = "component-preview"
29
+
30
+ const EntryHeader = ({ entry }: { entry: LibraryEntry }) => (
31
+ <header className="sticky top-0 z-10 flex items-baseline gap-3 border-neutral-200 border-b bg-white/85 px-6 py-3 backdrop-blur">
32
+ <h2 className="font-mono text-neutral-900 text-xs uppercase">
33
+ {entry.title}
34
+ </h2>
35
+ <p className="text-neutral-500 text-xs">{entry.notes}</p>
36
+ </header>
37
+ )
38
+
39
+ const PreviewStage = ({ entry }: { entry: LibraryEntry }) => (
40
+ <Stage background={entry.background}>
41
+ <Renderer
42
+ blocks={[entry.block]}
43
+ pageId={PREVIEW_PAGE_ID}
44
+ pinnedCategory={null}
45
+ searchParams={undefined}
46
+ />
47
+ </Stage>
48
+ )
49
+
50
+ // Both `isStudioEmbed()` (headers) and `searchParams` are request-time data, so
51
+ // under Cache Components this has to sit inside the page's <Suspense> boundary —
52
+ // at the top of the page it fails the prerender with blocking-prerender-dynamic.
53
+ const Library = async ({ searchParams }: Props) => {
54
+ const [params, embedded] = await Promise.all([searchParams, isStudioEmbed()])
55
+
56
+ if (!embedded) notFound()
57
+
58
+ const requested = firstValue(params[COMPONENT_PREVIEW_PARAM])
59
+ const entry = getLibraryEntry(requested)
60
+
61
+ if (requested && !entry) notFound()
62
+
63
+ if (entry) return <PreviewStage entry={entry} />
64
+
65
+ return (
66
+ <>
67
+ {COMPONENT_LIBRARY.map((item) => (
68
+ <section key={item.documentType}>
69
+ <EntryHeader entry={item} />
70
+ <PreviewStage entry={item} />
71
+ </section>
72
+ ))}
73
+ </>
74
+ )
75
+ }
76
+
77
+ // Outside the `(content)` group, so `<SpacingScale />` is on this route.
78
+ const Page = async ({ searchParams }: Props) => {
79
+ const { isEnabled: isDraftMode } = await draftMode()
80
+
81
+ return (
82
+ <main className="flex flex-col">
83
+ <SpacingScale isDraftMode={isDraftMode} />
84
+ <Suspense fallback={null}>
85
+ <Library searchParams={searchParams} />
86
+ </Suspense>
87
+ </main>
88
+ )
89
+ }
90
+
91
+ export default Page
@@ -13,14 +13,15 @@ type GatedContentBlock = Extract<PageBuilderBlock, { _type: "gatedContent" }>
13
13
 
14
14
  type GatedContentProps = {
15
15
  block: GatedContentBlock
16
+ pageId: string
16
17
  }
17
18
 
18
19
  type GateState = "locked" | "loading" | "unlocked"
19
20
 
20
21
  const STORAGE_PREFIX = "gated-content:"
21
22
 
22
- export function GatedContent({ block }: GatedContentProps) {
23
- const storageKey = `${STORAGE_PREFIX}${block._id}`
23
+ export function GatedContent({ block, pageId }: GatedContentProps) {
24
+ const storageKey = `${STORAGE_PREFIX}${pageId}:${block._key}`
24
25
  const [state, setState] = useState<GateState>("locked")
25
26
  const [payload, setPayload] = useState<GatedContentPayload | null>(null)
26
27
  const [error, setError] = useState<string | null>(null)
@@ -33,7 +34,11 @@ export function GatedContent({ block }: GatedContentProps) {
33
34
  setState("loading")
34
35
  setError(null)
35
36
 
36
- const result = await unlockGatedContent({ documentId: block._id, email })
37
+ const result = await unlockGatedContent({
38
+ pageId,
39
+ blockKey: block._key,
40
+ email,
41
+ })
37
42
 
38
43
  if (!result.ok) {
39
44
  setError(result.error)
@@ -0,0 +1,183 @@
1
+ import type { PageBuilderDocumentType } from "@/lib/integrations/sanity/page-builder-config"
2
+ import type { PageBuilderBlock } from "./types"
3
+
4
+ /** One block's stand-in value, rendered by `/components` through `Renderer`. */
5
+ export type LibraryEntry = {
6
+ documentType: PageBuilderDocumentType
7
+ title: string
8
+ notes: string
9
+ background: string
10
+ block: PageBuilderBlock
11
+ }
12
+
13
+ type BlockOf<T extends PageBuilderDocumentType> = Extract<
14
+ PageBuilderBlock,
15
+ { _type: T }
16
+ >
17
+
18
+ const span = (key: string, text: string) => ({
19
+ _type: "span" as const,
20
+ _key: key,
21
+ text,
22
+ marks: [],
23
+ })
24
+
25
+ const paragraph = (key: string, text: string) => ({
26
+ _type: "block" as const,
27
+ _key: key,
28
+ style: "normal" as const,
29
+ markDefs: [],
30
+ children: [span(`${key}-span`, text)],
31
+ })
32
+
33
+ const heading = (key: string, text: string) => ({
34
+ ...paragraph(key, text),
35
+ style: "h2" as const,
36
+ })
37
+
38
+ const PLACEHOLDER_KEY = "placeholder"
39
+
40
+ const hero: BlockOf<"hero"> = {
41
+ _key: PLACEHOLDER_KEY,
42
+ _type: "hero",
43
+ headline: "A heading that says what this page is for.",
44
+ }
45
+
46
+ const description: BlockOf<"description"> = {
47
+ _key: PLACEHOLDER_KEY,
48
+ _type: "description",
49
+ description:
50
+ "One paragraph of standfirst copy under a heading — long enough to set up the page, short enough that nobody skips it.",
51
+ }
52
+
53
+ const richTextBlock: BlockOf<"richTextBlock"> = {
54
+ _key: PLACEHOLDER_KEY,
55
+ _type: "richTextBlock",
56
+ content: [
57
+ heading("rt-head", "What long-form copy looks like here"),
58
+ paragraph(
59
+ "rt-one",
60
+ "Rich Text is the block for body copy: headings, paragraphs, lists, links and quotes, all authored in the Studio and rendered with the site's own typography."
61
+ ),
62
+ paragraph(
63
+ "rt-two",
64
+ "Because it is Portable Text rather than HTML, the same content renders in the page, in the markdown route and in the feed without a second template."
65
+ ),
66
+ ],
67
+ }
68
+
69
+ const gatedContent: BlockOf<"gatedContent"> = {
70
+ _key: PLACEHOLDER_KEY,
71
+ _type: "gatedContent",
72
+ internalTitle: "Gated guide",
73
+ teaserHeading: "A guide worth an email address",
74
+ teaserBody: [
75
+ paragraph(
76
+ "gc-teaser",
77
+ "The teaser is public. The payload — rich text, a file or a video — is withheld from the page query until a visitor submits the form."
78
+ ),
79
+ ],
80
+ gateType: "form",
81
+ gateHeadline: "Read the full guide",
82
+ gateDescription: "We'll email you a copy as well.",
83
+ submitLabel: "Get the guide",
84
+ successMessage: "Sent — it's on its way.",
85
+ kind: "richText",
86
+ anchorId: null,
87
+ presentation: null,
88
+ }
89
+
90
+ const blogCollection: BlockOf<"blogCollection"> = {
91
+ _key: PLACEHOLDER_KEY,
92
+ _type: "blogCollection",
93
+ subtitle: "Everything else we've written",
94
+ }
95
+
96
+ const blogContent: BlockOf<"blogContent"> = {
97
+ _key: PLACEHOLDER_KEY,
98
+ _type: "blogContent",
99
+ title: "How this starter renders a blog post",
100
+ categories: [],
101
+ date: "2026-02-11",
102
+ author: {
103
+ _id: "placeholder-author",
104
+ name: "Jane Doe",
105
+ avatar: null,
106
+ role: "Engineer",
107
+ },
108
+ body: [
109
+ paragraph(
110
+ "bc-one",
111
+ "A blog post is an ordinary page whose page builder holds a Blog Posts block: byline, date, categories and body all live on the block."
112
+ ),
113
+ heading("bc-head", "One block, one article"),
114
+ paragraph(
115
+ "bc-two",
116
+ "The collection block reads the same fields, so an article added in the Studio shows up in the index without touching code."
117
+ ),
118
+ ],
119
+ relatedPosts: [],
120
+ }
121
+
122
+ export const COMPONENT_LIBRARY: LibraryEntry[] = [
123
+ {
124
+ documentType: "hero",
125
+ title: "Heading",
126
+ notes: "The page's opening statement. Headline only, no media.",
127
+ background: "bg-white",
128
+ block: hero,
129
+ },
130
+ {
131
+ documentType: "description",
132
+ title: "Description",
133
+ notes: "A single paragraph of standfirst copy under a heading.",
134
+ background: "bg-white",
135
+ block: description,
136
+ },
137
+ {
138
+ documentType: "richTextBlock",
139
+ title: "Rich Text",
140
+ notes: "Long-form body copy — headings, lists, links and quotes.",
141
+ background: "bg-white",
142
+ block: richTextBlock,
143
+ },
144
+ {
145
+ documentType: "gatedContent",
146
+ title: "Gated Content",
147
+ notes:
148
+ "A teaser with an email gate. The payload is withheld until a visitor submits, so the preview shows the locked state.",
149
+ background: "bg-white",
150
+ block: gatedContent,
151
+ },
152
+ {
153
+ documentType: "blogCollection",
154
+ title: "Blog Collection",
155
+ notes:
156
+ "The full article index with search and category filters. Reads real posts.",
157
+ background: "bg-white",
158
+ block: blogCollection,
159
+ },
160
+ {
161
+ documentType: "blogContent",
162
+ title: "Blog Posts",
163
+ notes:
164
+ "The body of an article — byline, date, categories and rich text. Only valid on a blog entry page.",
165
+ background: "bg-white",
166
+ block: blogContent,
167
+ },
168
+ ]
169
+
170
+ export const getLibraryEntry = (documentType: string | undefined) =>
171
+ documentType
172
+ ? (COMPONENT_LIBRARY.find((entry) => entry.documentType === documentType) ??
173
+ null)
174
+ : null
175
+
176
+ export const COMPONENT_PREVIEW_PATH = "/components"
177
+
178
+ export const COMPONENT_PREVIEW_PARAM = "component"
179
+
180
+ export const getComponentPreviewPath = (documentType?: string) =>
181
+ documentType
182
+ ? `${COMPONENT_PREVIEW_PATH}?${COMPONENT_PREVIEW_PARAM}=${encodeURIComponent(documentType)}`
183
+ : COMPONENT_PREVIEW_PATH
@@ -13,6 +13,7 @@ import { RichTextBlock } from "./components/rich-text-block"
13
13
  import type { PageBuilderBlock } from "./types"
14
14
 
15
15
  export type BlockContext = {
16
+ pageId: string
16
17
  searchParams: ContentCollectionSearchParams
17
18
  pinnedCategory: PinnedCollectionCategory
18
19
  }
@@ -48,7 +49,9 @@ const blockRegistry: {
48
49
  isEmpty: (block) => !block.description,
49
50
  },
50
51
  gatedContent: {
51
- render: (block) => <GatedContent block={block} />,
52
+ render: (block, { pageId }) => (
53
+ <GatedContent block={block} pageId={pageId} />
54
+ ),
52
55
  },
53
56
  hero: {
54
57
  render: (block) => <Hero block={block} />,
@@ -12,18 +12,20 @@ import type { PageBuilderBlock, PageBuilderBlocks } from "./types"
12
12
 
13
13
  type RendererProps = {
14
14
  blocks: PageBuilderBlocks
15
+ pageId: string
15
16
  searchParams: ContentCollectionSearchParams
16
17
  pinnedCategory: PinnedCollectionCategory
17
18
  }
18
19
 
19
20
  export function Renderer({
20
21
  blocks,
22
+ pageId,
21
23
  searchParams,
22
24
  pinnedCategory,
23
25
  }: RendererProps) {
24
26
  if (!blocks?.length) return null
25
27
 
26
- const context: BlockContext = { searchParams, pinnedCategory }
28
+ const context: BlockContext = { pageId, searchParams, pinnedCategory }
27
29
 
28
30
  const reservedAnchorIds = reserveAnchorIds(blocks)
29
31
  const takenAnchorIds = new Set<string>()
@@ -0,0 +1,10 @@
1
+ import type { PropsWithChildren } from "react"
2
+ import { cn } from "@/lib/styles/cn"
3
+
4
+ type StageProps = PropsWithChildren<{ background?: string }>
5
+
6
+ export const Stage = ({ background, children }: StageProps) => (
7
+ <div className={cn("grid min-h-svh content-center", background)}>
8
+ {children}
9
+ </div>
10
+ )
@@ -31,6 +31,7 @@ export const getPageSections = (
31
31
  <Renderer
32
32
  key="page-builder"
33
33
  blocks={page.pageBuilder}
34
+ pageId={page._id}
34
35
  searchParams={searchParams}
35
36
  // Cleaned because the slug becomes a GROQ parameter: in draft mode the
36
37
  // projection carries stega characters that would break the match.
@@ -92,7 +93,7 @@ export const PageDocument = ({
92
93
  // Editor-selected page-level structured data (FAQ, Event, custom, etc.).
93
94
  const pageJsonLd = buildPageJsonLd(page.metadata?.jsonLd)
94
95
 
95
- // Blog posts (pages with a Blog Content block) emit Article JSON-LD with
96
+ // Blog posts (pages with a Blog Posts block) emit Article JSON-LD with
96
97
  // fields derived from the post — no Custom block needed. Skipped when the
97
98
  // editor already supplied an Article (via a Custom block) to avoid duplicates.
98
99
  const blogBlock = page.pageBuilder?.find(