bsmnt 0.12.0 → 0.13.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 (174) hide show
  1. package/dist/application/add-integration/index.d.ts.map +1 -1
  2. package/dist/application/add-integration/index.js +23 -0
  3. package/dist/application/add-integration/index.js.map +1 -1
  4. package/dist/domain/consent.d.ts +17 -0
  5. package/dist/domain/consent.d.ts.map +1 -0
  6. package/dist/domain/consent.js +13 -0
  7. package/dist/domain/consent.js.map +1 -0
  8. package/dist/domain/integration.d.ts +19 -2
  9. package/dist/domain/integration.d.ts.map +1 -1
  10. package/dist/domain/integration.js +10 -1
  11. package/dist/domain/integration.js.map +1 -1
  12. package/dist/domain/jobs.d.ts +17 -0
  13. package/dist/domain/jobs.d.ts.map +1 -0
  14. package/dist/domain/jobs.js +13 -0
  15. package/dist/domain/jobs.js.map +1 -0
  16. package/dist/domain/skills.d.ts +23 -2
  17. package/dist/domain/skills.d.ts.map +1 -1
  18. package/dist/domain/skills.js +23 -2
  19. package/dist/domain/skills.js.map +1 -1
  20. package/dist/domain/templates.d.ts +7 -1
  21. package/dist/domain/templates.d.ts.map +1 -1
  22. package/dist/domain/templates.js +2 -0
  23. package/dist/domain/templates.js.map +1 -1
  24. package/dist/index.js +34 -9
  25. package/dist/index.js.map +1 -1
  26. package/dist/infrastructure/create/setup-agent.d.ts.map +1 -1
  27. package/dist/infrastructure/create/setup-agent.js +11 -4
  28. package/dist/infrastructure/create/setup-agent.js.map +1 -1
  29. package/dist/infrastructure/integrations/inject-integration.d.ts.map +1 -1
  30. package/dist/infrastructure/integrations/inject-integration.js +36 -2
  31. package/dist/infrastructure/integrations/inject-integration.js.map +1 -1
  32. package/dist/modules/features/consent/ketch/config.d.ts +18 -0
  33. package/dist/modules/features/consent/ketch/config.d.ts.map +1 -0
  34. package/dist/modules/features/consent/ketch/config.js +24 -0
  35. package/dist/modules/features/consent/ketch/config.js.map +1 -0
  36. package/dist/modules/features/consent/ketch/mergers/biome.d.ts +16 -0
  37. package/dist/modules/features/consent/ketch/mergers/biome.d.ts.map +1 -0
  38. package/dist/modules/features/consent/ketch/mergers/biome.js +31 -0
  39. package/dist/modules/features/consent/ketch/mergers/biome.js.map +1 -0
  40. package/dist/modules/features/consent/ketch/mergers/check-integration.d.ts +13 -0
  41. package/dist/modules/features/consent/ketch/mergers/check-integration.d.ts.map +1 -0
  42. package/dist/modules/features/consent/ketch/mergers/check-integration.js +64 -0
  43. package/dist/modules/features/consent/ketch/mergers/check-integration.js.map +1 -0
  44. package/dist/modules/features/consent/ketch/mergers/env-example.d.ts +12 -0
  45. package/dist/modules/features/consent/ketch/mergers/env-example.d.ts.map +1 -0
  46. package/dist/modules/features/consent/ketch/mergers/env-example.js +28 -0
  47. package/dist/modules/features/consent/ketch/mergers/env-example.js.map +1 -0
  48. package/dist/modules/features/consent/ketch/mergers/footer.d.ts +17 -0
  49. package/dist/modules/features/consent/ketch/mergers/footer.d.ts.map +1 -0
  50. package/dist/modules/features/consent/ketch/mergers/footer.js +40 -0
  51. package/dist/modules/features/consent/ketch/mergers/footer.js.map +1 -0
  52. package/dist/modules/features/consent/ketch/mergers/knip.d.ts +18 -0
  53. package/dist/modules/features/consent/ketch/mergers/knip.d.ts.map +1 -0
  54. package/dist/modules/features/consent/ketch/mergers/knip.js +37 -0
  55. package/dist/modules/features/consent/ketch/mergers/knip.js.map +1 -0
  56. package/dist/modules/features/consent/ketch/mergers/layout.d.ts +25 -0
  57. package/dist/modules/features/consent/ketch/mergers/layout.d.ts.map +1 -0
  58. package/dist/modules/features/consent/ketch/mergers/layout.js +55 -0
  59. package/dist/modules/features/consent/ketch/mergers/layout.js.map +1 -0
  60. package/dist/modules/features/jobs/ashby/config.d.ts +19 -0
  61. package/dist/modules/features/jobs/ashby/config.d.ts.map +1 -0
  62. package/dist/modules/features/jobs/ashby/config.js +29 -0
  63. package/dist/modules/features/jobs/ashby/config.js.map +1 -0
  64. package/dist/modules/features/jobs/ashby/mergers/check-integration.d.ts +14 -0
  65. package/dist/modules/features/jobs/ashby/mergers/check-integration.d.ts.map +1 -0
  66. package/dist/modules/features/jobs/ashby/mergers/check-integration.js +78 -0
  67. package/dist/modules/features/jobs/ashby/mergers/check-integration.js.map +1 -0
  68. package/dist/modules/features/jobs/ashby/mergers/env-example.d.ts +12 -0
  69. package/dist/modules/features/jobs/ashby/mergers/env-example.d.ts.map +1 -0
  70. package/dist/modules/features/jobs/ashby/mergers/env-example.js +27 -0
  71. package/dist/modules/features/jobs/ashby/mergers/env-example.js.map +1 -0
  72. package/dist/modules/features/jobs/ashby/mergers/knip.d.ts +18 -0
  73. package/dist/modules/features/jobs/ashby/mergers/knip.d.ts.map +1 -0
  74. package/dist/modules/features/jobs/ashby/mergers/knip.js +41 -0
  75. package/dist/modules/features/jobs/ashby/mergers/knip.js.map +1 -0
  76. package/dist/paths.d.ts +12 -0
  77. package/dist/paths.d.ts.map +1 -1
  78. package/dist/paths.js +12 -0
  79. package/dist/paths.js.map +1 -1
  80. package/package.json +3 -1
  81. package/src/agent-skills/ashby-pagebuilder-block/SKILL.md +277 -0
  82. package/src/agent-skills/hubspot-pagebuilder-block/SKILL.md +58 -25
  83. package/src/agent-skills/ketch-consent-check/SKILL.md +197 -0
  84. package/src/agent-skills/ketch-consent-setup/SKILL.md +326 -0
  85. package/src/agent-skills/ketch-consent-setup/references/ui-customization.md +329 -0
  86. package/src/agent-skills/page-builder-block/SKILL.md +150 -0
  87. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/anchor-id-input.tsx +17 -0
  88. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/presentation-input.tsx +146 -0
  89. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/spacing-steps-input.tsx +84 -0
  90. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/fetchers/layout.ts +6 -3
  91. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/icons.ts +8 -0
  92. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +12 -0
  93. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/blog-content.ts +3 -3
  94. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/description.ts +3 -3
  95. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/gated-content.ts +3 -3
  96. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/hero.ts +3 -3
  97. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/rich-text-block.ts +3 -3
  98. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/singleton/content-collection.ts +3 -3
  99. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts +8 -0
  100. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/spacing-settings.ts +120 -0
  101. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/anchor.ts +75 -0
  102. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/block.ts +46 -0
  103. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/presentation.ts +44 -0
  104. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/spacing.ts +164 -0
  105. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/singletons.ts +7 -0
  106. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/anchor-id-input.tsx +17 -0
  107. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/presentation-input.tsx +146 -0
  108. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/spacing-steps-input.tsx +84 -0
  109. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/fetchers/layout.ts +4 -2
  110. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/icons.ts +8 -0
  111. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/queries.ts +12 -0
  112. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/reusable/blog-content.ts +3 -3
  113. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/reusable/description.ts +3 -3
  114. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/reusable/gated-content.ts +3 -3
  115. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/reusable/hero.ts +3 -3
  116. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/reusable/rich-text-block.ts +3 -3
  117. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/singleton/content-collection.ts +3 -3
  118. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/index.ts +8 -0
  119. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/layout/spacing-settings.ts +120 -0
  120. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/anchor.ts +75 -0
  121. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/block.ts +46 -0
  122. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/presentation.ts +44 -0
  123. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/spacing.ts +164 -0
  124. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/singletons.ts +7 -0
  125. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/tags.ts +2 -0
  126. package/src/modules/features/consent/ketch/files/lib/integrations/check-integration.ts +25 -0
  127. package/src/modules/features/consent/ketch/files/lib/integrations/ketch/README.md +70 -0
  128. package/src/modules/features/consent/ketch/files/lib/integrations/ketch/categories.ts +20 -0
  129. package/src/modules/features/consent/ketch/files/lib/integrations/ketch/consent-store.ts +107 -0
  130. package/src/modules/features/consent/ketch/files/lib/integrations/ketch/env.ts +20 -0
  131. package/src/modules/features/consent/ketch/files/lib/integrations/ketch/ketch-experience.css +136 -0
  132. package/src/modules/features/consent/ketch/files/lib/integrations/ketch/preferences-button.tsx +51 -0
  133. package/src/modules/features/consent/ketch/files/lib/integrations/ketch/script.tsx +8 -0
  134. package/src/modules/features/consent/ketch/files/lib/integrations/ketch/types.ts +15 -0
  135. package/src/modules/features/consent/ketch/files/lib/integrations/ketch/use-consent.ts +26 -0
  136. package/src/modules/features/jobs/ashby/files/_sanity-adapter/job-board-block.ts +53 -0
  137. package/src/modules/features/jobs/ashby/files/lib/integrations/ashby/README.md +134 -0
  138. package/src/modules/features/jobs/ashby/files/lib/integrations/ashby/env.ts +3 -0
  139. package/src/modules/features/jobs/ashby/files/lib/integrations/ashby/index.ts +40 -0
  140. package/src/modules/features/jobs/ashby/files/lib/integrations/ashby/normalize.ts +124 -0
  141. package/src/modules/features/jobs/ashby/files/lib/integrations/ashby/types.ts +20 -0
  142. package/src/modules/features/jobs/ashby/files/lib/integrations/check-integration.ts +25 -0
  143. package/src/templates/next-pagebuilder/AGENTS.md +129 -0
  144. package/src/templates/next-pagebuilder/CLAUDE.md +3 -0
  145. package/src/templates/next-pagebuilder/README.md +70 -0
  146. package/src/templates/next-pagebuilder/app/(content)/layout.tsx +2 -0
  147. package/src/templates/next-pagebuilder/components/layout/spacing-scale/index.tsx +18 -0
  148. package/src/templates/next-pagebuilder/components/page-builder/components/article-content/index.tsx +1 -1
  149. package/src/templates/next-pagebuilder/components/page-builder/components/content-collection/index.tsx +2 -2
  150. package/src/templates/next-pagebuilder/components/page-builder/components/description.tsx +3 -7
  151. package/src/templates/next-pagebuilder/components/page-builder/components/gated-content/index.tsx +2 -2
  152. package/src/templates/next-pagebuilder/components/page-builder/components/hero.tsx +3 -7
  153. package/src/templates/next-pagebuilder/components/page-builder/components/rich-text-block.tsx +3 -5
  154. package/src/templates/next-pagebuilder/components/page-builder/registry.tsx +70 -0
  155. package/src/templates/next-pagebuilder/components/page-builder/renderer.tsx +33 -49
  156. package/src/templates/next-pagebuilder/components/page-builder/section.tsx +35 -0
  157. package/src/templates/next-pagebuilder/components/page-document/index.tsx +1 -1
  158. package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +92 -4
  159. package/src/templates/next-pagebuilder-cache/AGENTS.md +136 -0
  160. package/src/templates/next-pagebuilder-cache/CLAUDE.md +3 -0
  161. package/src/templates/next-pagebuilder-cache/README.md +75 -0
  162. package/src/templates/next-pagebuilder-cache/app/(content)/layout.tsx +2 -0
  163. package/src/templates/next-pagebuilder-cache/components/layout/spacing-scale/index.tsx +46 -0
  164. package/src/templates/next-pagebuilder-cache/components/page-builder/components/article-content/index.tsx +1 -1
  165. package/src/templates/next-pagebuilder-cache/components/page-builder/components/content-collection/index.tsx +2 -2
  166. package/src/templates/next-pagebuilder-cache/components/page-builder/components/description.tsx +3 -7
  167. package/src/templates/next-pagebuilder-cache/components/page-builder/components/gated-content/index.tsx +2 -2
  168. package/src/templates/next-pagebuilder-cache/components/page-builder/components/hero.tsx +3 -7
  169. package/src/templates/next-pagebuilder-cache/components/page-builder/components/rich-text-block.tsx +3 -5
  170. package/src/templates/next-pagebuilder-cache/components/page-builder/registry.tsx +70 -0
  171. package/src/templates/next-pagebuilder-cache/components/page-builder/renderer.tsx +33 -49
  172. package/src/templates/next-pagebuilder-cache/components/page-builder/section.tsx +35 -0
  173. package/src/templates/next-pagebuilder-cache/components/page-document/index.tsx +1 -1
  174. package/src/templates/next-pagebuilder-cache/lib/integrations/sanity/sanity.types.ts +92 -4
@@ -0,0 +1,53 @@
1
+ import { CaseIcon } from "@sanity/icons"
2
+ import { defineField, defineType } from "sanity"
3
+
4
+ export const ashbyJobBoardBlock = defineType({
5
+ name: "ashbyJobBoardBlock",
6
+ title: "Ashby Job Board",
7
+ type: "document",
8
+ icon: CaseIcon,
9
+ description:
10
+ "Lists open roles pulled live from Ashby. There is no content to manage here beyond an optional heading.",
11
+ fields: [
12
+ defineField({
13
+ name: "internalTitle",
14
+ title: "Internal Title",
15
+ type: "string",
16
+ description: "Only used to identify this block in the Studio.",
17
+ validation: (Rule) => Rule.required(),
18
+ }),
19
+ defineField({
20
+ name: "heading",
21
+ title: "Heading",
22
+ type: "string",
23
+ description: "Optional heading shown above the list of positions.",
24
+ }),
25
+ defineField({
26
+ name: "boardName",
27
+ title: "Job Board Name",
28
+ type: "string",
29
+ description:
30
+ "Optional. Overrides the ASHBY_JOB_BOARD_NAME environment variable — the board's slug in its jobs.ashbyhq.com URL.",
31
+ }),
32
+ ],
33
+ preview: {
34
+ select: {
35
+ title: "internalTitle",
36
+ heading: "heading",
37
+ boardName: "boardName",
38
+ },
39
+ prepare({ title, heading, boardName }) {
40
+ return {
41
+ title: title || "Ashby Job Board",
42
+ subtitle: heading || boardName || "Open positions",
43
+ }
44
+ },
45
+ },
46
+ })
47
+
48
+ export const ashbyJobBoardBlockReference = defineType({
49
+ name: "ashbyJobBoardBlockReference",
50
+ title: "Ashby Job Board",
51
+ type: "reference",
52
+ to: [{ type: "ashbyJobBoardBlock" }],
53
+ })
@@ -0,0 +1,134 @@
1
+ # Ashby job board integration
2
+
3
+ Agnostic, reusable [Ashby](https://www.ashbyhq.com/) job-board reader. The core
4
+ (`index.ts`, `normalize.ts`, `types.ts`) has **no CMS dependency** and adds **no
5
+ runtime packages** — it reads Ashby's public Job Posting API and normalizes it
6
+ into a stable `CareerJob[]`.
7
+
8
+ Fetching is **server-only**: `getCareerJobs` uses `react`'s `cache` and Next.js
9
+ `fetch` revalidation, so call it from a Server Component, Route Handler, or
10
+ `generateMetadata` — never from a `"use client"` component.
11
+
12
+ ## 1. Environment
13
+
14
+ Ashby's job-board endpoint is public — no token or private app required. The
15
+ only configuration is the board name, which is the slug in your board's URL
16
+ (`https://jobs.ashbyhq.com/<board-name>`):
17
+
18
+ ```bash
19
+ ASHBY_JOB_BOARD_NAME=your-board
20
+ ```
21
+
22
+ The value is not a secret, so it can stay plaintext in the committed env file.
23
+ It is only read server-side (in `env.ts`); it is never exposed to the client.
24
+ You can also skip the variable entirely and pass the board name explicitly.
25
+
26
+ `env.ts` resolves it once at module scope as `ashbyBoardName`, and
27
+ `isAshbyConfigured()` in `lib/integrations/check-integration.ts` derives from
28
+ that same value — so a "configured" check can never disagree with what
29
+ `getCareerJobs` actually resolves.
30
+
31
+ ## 2. Read the jobs
32
+
33
+ ```tsx
34
+ import { getCareerJobs } from "@/lib/integrations/ashby"
35
+
36
+ export default async function Careers() {
37
+ const jobs = await getCareerJobs()
38
+
39
+ return (
40
+ <ul>
41
+ {jobs.map((job) => (
42
+ <li key={job.id}>
43
+ <a href={job.href}>{job.title}</a>
44
+ </li>
45
+ ))}
46
+ </ul>
47
+ )
48
+ }
49
+ ```
50
+
51
+ Pass a board name to override `ASHBY_JOB_BOARD_NAME` (useful for multi-brand
52
+ sites, or to render a second board on one page):
53
+
54
+ ```tsx
55
+ const jobs = await getCareerJobs("your-other-board")
56
+ ```
57
+
58
+ `getCareerJobs` throws when the board name is missing or when Ashby responds
59
+ with a non-2xx status. Wrap the call so a job-board outage degrades gracefully
60
+ instead of failing the whole page:
61
+
62
+ ```tsx
63
+ let jobs: CareerJob[] = []
64
+ try {
65
+ jobs = await getCareerJobs()
66
+ } catch (error) {
67
+ console.error("Failed to load Ashby jobs", error)
68
+ }
69
+ ```
70
+
71
+ ## 3. What you get
72
+
73
+ `normalize.ts` tolerates both the current (`jobs`) and legacy (`jobPostings`)
74
+ payload shapes, drops unlisted postings, and resolves each field from the
75
+ several key spellings Ashby has used over time. Every optional field is
76
+ `string | null` — never `undefined` — so rendering stays branch-free:
77
+
78
+ | Field | Notes |
79
+ | --- | --- |
80
+ | `id` / `title` / `href` | Required; a posting missing any of them is dropped |
81
+ | `applyUrl` | Direct application link when Ashby exposes one |
82
+ | `location` / `department` / `team` | Resolved from the flat or nested variants |
83
+ | `employmentType` | e.g. `Full-time` |
84
+ | `workType` | `workplaceType`, or derived from `isRemote` |
85
+ | `isRemote` | `true` only when Ashby says so explicitly |
86
+ | `publishedAt` | ISO date string |
87
+ | `descriptionHtml` | Raw HTML — sanitize before injecting |
88
+ | `compensation` | `{ summary, display }`; respect `display` before rendering |
89
+
90
+ ## 4. Caching
91
+
92
+ The core caches the board for 5 minutes via `next: { revalidate: 300 }`, and
93
+ deduplicates calls within a single request via React `cache`.
94
+
95
+ In a **Cache Components** project (`cacheComponents: true`), the fetch-level
96
+ revalidate is ignored — wrap the call in your own cached function instead:
97
+
98
+ ```tsx
99
+ import { cacheLife } from "next/cache"
100
+ import { getCareerJobs } from "@/lib/integrations/ashby"
101
+
102
+ async function getCachedJobs() {
103
+ "use cache"
104
+ cacheLife("minutes")
105
+ return getCareerJobs()
106
+ }
107
+ ```
108
+
109
+ ## 5. Optional: Sanity Studio block
110
+
111
+ When this integration is added to a project that already has Sanity, a
112
+ `sanity/` folder ships alongside the core:
113
+
114
+ - `sanity/job-board-block.ts` — an `ashbyJobBoardBlock` document type with an
115
+ optional `heading` and an optional per-block `boardName` override
116
+
117
+ Register it by adding `ashbyJobBoardBlock` to your schema's document list, then
118
+ render it from your own component.
119
+
120
+ ## 6. Use as a page-builder block
121
+
122
+ In a basement **Sanity page-builder** project, `ashbyJobBoardBlock` can be a
123
+ selectable block. Adding this integration installs the
124
+ **`ashby-pagebuilder-block`** agent skill — ask your coding agent to "wire the
125
+ Ashby job board into the page builder" and it makes the edits and verifies the
126
+ typecheck. To do it by hand:
127
+
128
+ 1. `schemas/index.ts` — import `ashbyJobBoardBlock`, add it to `schema.types`
129
+ 2. `page-builder-config.ts` — add `"ashbyJobBoardBlock"` to
130
+ `PageBuilderDocumentType` and an `ashbyJobBoardBlockReference` member
131
+ 3. `structure.ts` — add `S.documentTypeListItem("ashbyJobBoardBlock")`
132
+ 4. `components/page-builder/renderer.tsx` — add a `case "ashbyJobBoardBlock"`
133
+ that awaits `getCareerJobs(block.boardName)` and renders the list
134
+ 5. `bun run sanity:typegen && bun typecheck`
@@ -0,0 +1,3 @@
1
+ const readBoardName = (value: string | undefined) => value?.trim() ?? ""
2
+
3
+ export const ashbyBoardName = readBoardName(process.env.ASHBY_JOB_BOARD_NAME)
@@ -0,0 +1,40 @@
1
+ import { cache } from "react"
2
+ import { ashbyBoardName } from "./env"
3
+ import { normalizeBoard } from "./normalize"
4
+ import type { CareerJob } from "./types"
5
+
6
+ const ASHBY_JOB_BOARD_API_URL = "https://api.ashbyhq.com/posting-api/job-board"
7
+ const BOARD_REVALIDATE_SECONDS = 300
8
+
9
+ export function resolveAshbyBoardName(boardName?: string | null): string {
10
+ const resolved = boardName?.trim() || ashbyBoardName
11
+
12
+ if (!resolved) {
13
+ throw new Error(
14
+ "Missing Ashby job board name. Pass it to getCareerJobs() or set ASHBY_JOB_BOARD_NAME."
15
+ )
16
+ }
17
+
18
+ return resolved
19
+ }
20
+
21
+ export const getCareerJobs = cache(
22
+ async (boardName?: string | null): Promise<CareerJob[]> => {
23
+ const board = resolveAshbyBoardName(boardName)
24
+
25
+ const response = await fetch(
26
+ `${ASHBY_JOB_BOARD_API_URL}/${encodeURIComponent(board)}?includeCompensation=true`,
27
+ { next: { revalidate: BOARD_REVALIDATE_SECONDS } }
28
+ )
29
+
30
+ if (!response.ok) {
31
+ throw new Error(`Ashby request failed with ${response.status}`)
32
+ }
33
+
34
+ return normalizeBoard(await response.json(), board)
35
+ }
36
+ )
37
+
38
+ export { ashbyBoardName } from "./env"
39
+ export { normalizeBoard } from "./normalize"
40
+ export type { CareerCompensation, CareerJob } from "./types"
@@ -0,0 +1,124 @@
1
+ import type { CareerCompensation, CareerJob } from "./types"
2
+
3
+ const toRecord = (value: unknown): Record<string, unknown> | null =>
4
+ value && typeof value === "object" ? (value as Record<string, unknown>) : null
5
+
6
+ const string = (value: unknown): string | null =>
7
+ typeof value === "string" && value.trim() ? value.trim() : null
8
+
9
+ const getFieldValue = (
10
+ job: Record<string, unknown>,
11
+ key: string
12
+ ): string | null => {
13
+ const value = job[key]
14
+ if (typeof value === "string") return string(value)
15
+
16
+ const nested = toRecord(value)
17
+ if (!nested) return null
18
+
19
+ return string(nested.name) ?? string(nested.title) ?? string(nested.value)
20
+ }
21
+
22
+ const idFromUrl = (url: string | null): string | null => {
23
+ if (!url) return null
24
+
25
+ try {
26
+ const parsed = new URL(url)
27
+ const fromQuery = string(parsed.searchParams.get("ashby_jid"))
28
+ if (fromQuery) return fromQuery
29
+
30
+ const segments = parsed.pathname.split("/").filter(Boolean)
31
+ const last = segments.at(-1)
32
+ if (last === "application") return string(segments.at(-2))
33
+ return string(last)
34
+ } catch {
35
+ return null
36
+ }
37
+ }
38
+
39
+ const getCompensation = (
40
+ job: Record<string, unknown>
41
+ ): CareerCompensation | null => {
42
+ const compensation = toRecord(job.compensation)
43
+ if (!compensation) return null
44
+
45
+ const summary =
46
+ string(compensation.compensationTierSummary) ??
47
+ string(compensation.scrapeableCompensationSalarySummary)
48
+ if (!summary) return null
49
+
50
+ return {
51
+ summary,
52
+ display: job.shouldDisplayCompensationOnJobPostings !== false,
53
+ }
54
+ }
55
+
56
+ const getWorkType = (job: Record<string, unknown>): string | null => {
57
+ const workplaceType = getFieldValue(job, "workplaceType")
58
+ if (workplaceType) return workplaceType
59
+ if (job.isRemote === true) return "Remote"
60
+ return job.isRemote === false ? "On-site" : null
61
+ }
62
+
63
+ const toCareerJob = (value: unknown, boardName: string): CareerJob | null => {
64
+ const job = toRecord(value)
65
+ if (!job) return null
66
+
67
+ if (job.isListed === false) return null
68
+
69
+ const jobUrl = string(job.jobUrl)
70
+ const applyUrl = string(job.applyUrl)
71
+
72
+ const id =
73
+ string(job.id) ??
74
+ string(job.jobId) ??
75
+ string(job.jobPostingId) ??
76
+ string(job.ashbyJid) ??
77
+ idFromUrl(jobUrl) ??
78
+ idFromUrl(applyUrl)
79
+
80
+ const title = getFieldValue(job, "title")
81
+ const href =
82
+ jobUrl ??
83
+ applyUrl ??
84
+ (id
85
+ ? `https://jobs.ashbyhq.com/${encodeURIComponent(boardName)}/${id}`
86
+ : null)
87
+
88
+ if (!(id && title && href)) return null
89
+
90
+ return {
91
+ id,
92
+ title,
93
+ href,
94
+ applyUrl,
95
+ location:
96
+ getFieldValue(job, "location") ?? getFieldValue(job, "locationName"),
97
+ department:
98
+ getFieldValue(job, "department") ?? getFieldValue(job, "departmentName"),
99
+ team: getFieldValue(job, "team") ?? getFieldValue(job, "teamName"),
100
+ employmentType:
101
+ getFieldValue(job, "employmentType") ?? getFieldValue(job, "jobType"),
102
+ workType: getWorkType(job),
103
+ publishedAt: string(job.publishedAt) ?? string(job.publishedDate) ?? null,
104
+ isRemote: job.isRemote === true,
105
+ descriptionHtml: string(job.descriptionHtml),
106
+ compensation: getCompensation(job),
107
+ }
108
+ }
109
+
110
+ export const normalizeBoard = (
111
+ payload: unknown,
112
+ boardName: string
113
+ ): CareerJob[] => {
114
+ const board = toRecord(payload)
115
+ if (!board) return []
116
+
117
+ const jobs = Array.isArray(board.jobs) ? board.jobs : null
118
+ const legacy = Array.isArray(board.jobPostings) ? board.jobPostings : null
119
+ const raw = jobs ?? legacy ?? []
120
+
121
+ return raw
122
+ .map((job) => toCareerJob(job, boardName))
123
+ .filter((job): job is CareerJob => job !== null)
124
+ }
@@ -0,0 +1,20 @@
1
+ export type CareerCompensation = {
2
+ summary: string
3
+ display: boolean
4
+ }
5
+
6
+ export type CareerJob = {
7
+ id: string
8
+ title: string
9
+ href: string
10
+ applyUrl: string | null
11
+ location: string | null
12
+ department: string | null
13
+ team: string | null
14
+ employmentType: string | null
15
+ workType: string | null
16
+ publishedAt: string | null
17
+ isRemote: boolean
18
+ descriptionHtml: string | null
19
+ compensation: CareerCompensation | null
20
+ }
@@ -0,0 +1,25 @@
1
+ import { ashbyBoardName } from "@/lib/integrations/ashby/env"
2
+
3
+ /**
4
+ * Integration Configuration Checker
5
+ *
6
+ * Utilities to check if integrations are configured via environment variables.
7
+ * This helps with tree-shaking unused integrations from the bundle.
8
+ *
9
+ * Usage:
10
+ * ```ts
11
+ * import { isAshbyConfigured } from '@/integrations/check-integration'
12
+ *
13
+ * if (isAshbyConfigured()) {
14
+ * // Only fetch the job board once a board name is set
15
+ * }
16
+ * ```
17
+ */
18
+
19
+ /**
20
+ * Check if the Ashby job board is configured. Derived from env.ts, not a
21
+ * second read of `process.env`.
22
+ */
23
+ export function isAshbyConfigured(): boolean {
24
+ return Boolean(ashbyBoardName)
25
+ }
@@ -0,0 +1,129 @@
1
+ # AGENTS.md
2
+
3
+ Instructions for AI agents working in this repository. Human-facing setup,
4
+ environment variables and the publish webhook live in the README — read it too.
5
+
6
+ This is a **Next.js + Sanity page builder**: all URL structure is managed in
7
+ Sanity, not in code, and a single catch-all route `[[...slug]]` renders every
8
+ page.
9
+
10
+ **Never add a page route under `app/`.** A new page is a `page` document in the
11
+ Studio with a slug, not a directory. `app/(content)/[[...slug]]/page.tsx`
12
+ resolves every one of them; a hand-written route shadows that resolver and
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.
15
+
16
+ ## Page-builder blocks
17
+
18
+ **A block is `defineBlock` + a registry entry.** Define the schema with
19
+ `defineBlock` (`lib/integrations/sanity/schemas/shared/block.ts`), never
20
+ `defineType` — it implies `type: "document"` and appends **Anchor ID** and
21
+ **Presentation Mode** for you. Register the component in
22
+ `components/page-builder/registry.tsx`; the renderer is driven by that map, so
23
+ there is no switch to extend.
24
+
25
+ Both halves are enforced, not documented: the registry's key set is typed as
26
+ exactly `PageBuilderDocumentType`, so a block registered in
27
+ `page-builder-config.ts` without an entry there — or an entry there for a block
28
+ that was never registered — is a typecheck error.
29
+
30
+ **Every block declares two orthogonal axes** in `page-builder-config.ts`:
31
+ `pageTypes` (which page documents may host it — drives validation) and
32
+ `category` (what the block structurally is, one of `componentCategories` —
33
+ drives the insert menu; `misc` only when nothing else fits). Never collapse them
34
+ into one field: that is what made the menu a single flat list.
35
+
36
+ **And a preview image.** The insert menu renders one tile per block from a
37
+ `studioPreview` document (Components → Component Previews) keyed
38
+ `block.<memberName>`. A block without one falls back to its schema icon, which
39
+ is what made the old menu unreadable.
40
+
41
+ **Never hardcode vertical padding in a block component.** The renderer wraps
42
+ each block in `<Section>` (`components/page-builder/section.tsx`), which owns
43
+ the `<section>` element, the anchor `id`, `scroll-mt` and the responsive padding
44
+ top/bottom. A block component returns its content only — no outer `<section>`,
45
+ no `py-*` / `pt-*` / `pb-*` on its root. Internal gaps between a block's own
46
+ parts are fine.
47
+
48
+ **Never hardcode px for section spacing either.** The scale lives in the Studio
49
+ at **Layout → Spacing Scale**, where editors add or rename steps and mark one as
50
+ the default, and it reaches the page as CSS custom properties. `Section` only
51
+ references `var(--section-space-<key>, var(--section-space-default, 2.5rem))`.
52
+
53
+ **An empty Anchor ID is generated from the block's title** (`headline` /
54
+ `heading` / `teaserHeading` / `title` / `label` — never `internalTitle`, which is
55
+ Studio-only) and de-duplicated per page. Name your block's headline field one of
56
+ those and anchor links work for free.
57
+
58
+ Declare `isEmpty` in the registry when a block can be left unfilled — the
59
+ renderer then skips the block *and* its `<Section>`, so it leaves no padded gap
60
+ and claims no anchor id. Don't early-return `null` from the component instead;
61
+ the renderer has to know before it wraps anything.
62
+
63
+ Full recipe and a checklist for a new block: the `page-builder-block` skill, or
64
+ the "Page-builder blocks" section of the README.
65
+
66
+ ## Sanity
67
+
68
+ - Everything Sanity lives in `lib/integrations/sanity/`.
69
+ - Schemas use `defineType` / `defineField` / `defineArrayMember` (blocks use
70
+ `defineBlock`). GROQ queries are `SCREAMING_SNAKE_CASE` and wrapped in
71
+ `defineQuery`.
72
+ - **Regenerate types after any schema or query change:** `bun sanity:typegen`,
73
+ and commit the result in the same PR. Never hand-edit `sanity.types.ts`.
74
+ - The page query spreads `...` for non-gated blocks, so new block fields flow
75
+ through on their own — except `gatedContent`, whose GROQ branch lists fields
76
+ explicitly in `lib/integrations/sanity/queries.ts`. Add fields there by hand.
77
+ - Read through `sanityFetch` / `fetchSanity`, not a bare client, so the
78
+ draft-mode perspective and cache tags are applied.
79
+ - Publishing a document must invalidate what renders it. Layout singletons are
80
+ listed in `SANITY_LAYOUT_TAGS`; a new one that isn't there serves stale.
81
+ - Add `data-sanity` attributes where visual editing should reach.
82
+ - Nested layouts (e.g. `app/studio/layout.tsx`) must NOT render `<html>` or
83
+ `<body>` — the root layout already provides them.
84
+
85
+ ## React and Next.js
86
+
87
+ - **React Compiler is on.** Do not write `useMemo`, `useCallback` or
88
+ `React.memo`. The one exception is `useRef` to instantiate an object once:
89
+
90
+ ```tsx
91
+ const instanceRef = useRef<SomeClass | null>(null)
92
+ if (!instanceRef.current) instanceRef.current = new SomeClass()
93
+ ```
94
+
95
+ - React 19: refs are regular props, so no `forwardRef` (a lint rule enforces it).
96
+ - **Never call `headers()`, `cookies()`, `draftMode()` or read `searchParams` in
97
+ a root layout** — it takes the whole page out of the prerender. Detect the
98
+ route with `useSelectedLayoutSegment()` in a `"use client"` wrapper, and put
99
+ draft-mode / visual-editing behaviour in a child component with its own
100
+ `<Suspense>` boundary.
101
+ - Use `@/components/ui/image` rather than `next/image` directly, and
102
+ `@/components/ui/sanity-image` for Sanity assets.
103
+ - Compose classNames with `cn` from `@/lib/styles/cn`.
104
+ - Import with the `@/` alias; relative parent imports are a lint error.
105
+ - Gate debug UI behind `process.env.NODE_ENV === "development"` — it is not
106
+ stripped in production otherwise.
107
+
108
+ ## House rules
109
+
110
+ - **Bun only.** `bun install`, `bun run <script>`, `bunx`. Never npm, pnpm or
111
+ yarn — a second lockfile next to `bun.lock` is how the versions drift.
112
+ - **Don't add a dependency without asking.** Check `bun info <package>` and the
113
+ current docs first.
114
+ <!-- bsmnt:if dotenvx -->
115
+ - **Secrets are encrypted at rest with dotenvx.** Never put a real secret in
116
+ plaintext in a committed env file — gitleaks blocks the commit. Use
117
+ `bun env:set KEY value` (`--plain` for trivial config and `NEXT_PUBLIC_*`).
118
+ The `manage-env` skill has the full procedure.
119
+ <!-- bsmnt:endif -->
120
+ - Don't leave commented-out code or `TODO`s behind; write the code so it reads
121
+ without them.
122
+ - **Verify before claiming done:**
123
+
124
+ ```bash
125
+ bun sanity:typegen && bun typecheck && bun lint && bun run build && bun knip
126
+ ```
127
+
128
+ (`bun run build`, not `bun build` — the bare form is Bun's bundler.) Say what
129
+ you ran and what it printed; don't report success you haven't seen.
@@ -0,0 +1,3 @@
1
+ # CLAUDE.md
2
+
3
+ @AGENTS.md
@@ -12,9 +12,79 @@ bun dev
12
12
 
13
13
  - Single catch-all route `[[...slug]]` handles all pages
14
14
  - Sanity page builder with composable content blocks
15
+ - Editor-owned vertical spacing scale and per-block anchor links
15
16
  - Blog with categories, filtering, and pagination
16
17
  - Visual editing and live preview support
17
18
 
19
+ ## Page-builder blocks
20
+
21
+ A block is two halves — a schema document and a React component — and each half
22
+ has one place that makes it a block rather than an ordinary document or a loose
23
+ component. Use them and Anchor ID, Presentation Mode and the spacing scale come
24
+ for free.
25
+
26
+ **Define the schema with `defineBlock`, never `defineType`**
27
+ (`lib/integrations/sanity/schemas/shared/block.ts`). It implies
28
+ `type: "document"` and appends two fields to every block:
29
+
30
+ - **Anchor ID** — optional. Left empty, the anchor is generated from the block's
31
+ own title at render time (`headline` / `heading` / `teaserHeading` / `title` /
32
+ `label` — never `internalTitle`, which is Studio-only) and de-duplicated per
33
+ page, so two blocks with the same heading get `-2`. A value typed by hand
34
+ always wins, and the Studio shows the generated one as the placeholder. Link
35
+ to it as `/<page>#<anchor>`.
36
+ - **Presentation Mode** — padding top and bottom, each picked from the shared
37
+ spacing scale. No free-form px. A block that never touches it follows whichever
38
+ step is marked as the default.
39
+
40
+ Blocks that declare `groups` get a **Presentation** tab for both fields
41
+ automatically — don't list it in `groups` yourself.
42
+
43
+ **Register the component in `components/page-builder/registry.tsx`.** One entry
44
+ per block, keyed by schema type; the renderer is driven by that map, so there is
45
+ no switch to extend. `isEmpty` declares when a block has nothing to show — the
46
+ renderer then skips the block *and* its `<Section>`, so an unfilled block leaves
47
+ no padded gap behind and claims no anchor id. The registry's key set is typed as
48
+ exactly `PageBuilderDocumentType`, so a block registered in one place and not the
49
+ other is a typecheck error.
50
+
51
+ **Never hardcode vertical padding in a block component.** The renderer wraps each
52
+ block in `<Section>` (`components/page-builder/section.tsx`), which owns the
53
+ `<section>` element, the anchor `id`, `scroll-mt` and the padding. A block
54
+ component returns its content only — no outer `<section>`, no `py-*` / `pt-*` /
55
+ `pb-*` on its root. Internal gaps between a block's own parts are fine.
56
+
57
+ ### The spacing scale is content, not code
58
+
59
+ Editors own it at **Layout → Spacing Scale**: a list of steps, each with a name
60
+ and px per breakpoint (mobile / tablet / desktop, empty reuses Desktop), one
61
+ marked as the default. Ticking a second step as default unticks the previous, so
62
+ there is always exactly one. Add a "Hero 120px" step and it shows up in every
63
+ block's dropdown, no deploy.
64
+
65
+ - Blocks store the step's `_key`, so renaming a step breaks nothing and a deleted
66
+ step degrades to the default instead of breaking the page.
67
+ - The list arrives seeded with the built-in five (XS 16 · SM 24 · **MD 32,
68
+ default** · LG 48 · XL 80, from `schemas/shared/spacing.ts`), which also apply
69
+ as the fallback if you empty it. Hit **"Add the built-in steps"** in the field
70
+ to get them back as editable rows.
71
+ - Values reach the page as CSS custom properties injected server-side by
72
+ `components/layout/spacing-scale/`, one `--section-space-<key>` per step plus
73
+ `--section-space-default`, re-declared inside the `768px` and `1440px` media
74
+ queries. `Section` only ever references
75
+ `var(--section-space-<key>, var(--section-space-default, 2.5rem))`, so an
76
+ unknown key degrades instead of breaking.
77
+ - `spacingSettings` is a layout singleton, so publishing it busts the layout
78
+ cache the same way navbar and footer do.
79
+
80
+ After a schema change run `bun sanity:typegen`. The page query spreads `...` for
81
+ non-gated blocks, so new fields flow through on their own — except
82
+ `gatedContent`, whose GROQ branch lists fields explicitly in
83
+ `lib/integrations/sanity/queries.ts`.
84
+
85
+ The bundled `page-builder-block` agent skill has the full recipe and a checklist
86
+ for adding a new block.
87
+
18
88
  ## Commands
19
89
 
20
90
  - `bun dev` - Start dev server
@@ -1,10 +1,12 @@
1
1
  import { JsonLd } from "@/components/layout/json-ld"
2
+ import { SpacingScale } from "@/components/layout/spacing-scale"
2
3
  import { Wrapper } from "@/components/layout/wrapper"
3
4
  import { SanityVisualEditing } from "@/components/sanity/visual-editing"
4
5
 
5
6
  const Layout = ({ children }: { children: React.ReactNode }) => {
6
7
  return (
7
8
  <>
9
+ <SpacingScale />
8
10
  <JsonLd />
9
11
  <Wrapper>{children}</Wrapper>
10
12
  <SanityVisualEditing />
@@ -0,0 +1,18 @@
1
+ import { getLayoutData } from "@/lib/integrations/sanity/fetchers/layout"
2
+ import {
3
+ resolveSpacingSteps,
4
+ spacingScaleCss,
5
+ } from "@/lib/integrations/sanity/schemas/shared/spacing"
6
+
7
+ export const SpacingScale = async () => {
8
+ const { spacingSettings } = await getLayoutData()
9
+
10
+ return (
11
+ <style
12
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: css custom properties from the spacing singleton
13
+ dangerouslySetInnerHTML={{
14
+ __html: spacingScaleCss(resolveSpacingSteps(spacingSettings?.steps)),
15
+ }}
16
+ />
17
+ )
18
+ }
@@ -7,7 +7,7 @@ import { RelatedPostItem } from "./related-post-item"
7
7
  export function ArticleContent({ block }: ArticleContentProps) {
8
8
  const { author, body, categories, date, relatedPosts, thumbnail } = block
9
9
  return (
10
- <div className="py-8 md:py-12">
10
+ <div>
11
11
  <header className="mx-auto max-w-3xl space-y-6">
12
12
  {categories && categories.length > 0 ? (
13
13
  <div className="flex flex-wrap gap-2">
@@ -239,11 +239,11 @@ async function CollectionResults({
239
239
  // clickable before the grid resolves.
240
240
  export function ContentCollection(props: ContentCollectionProps) {
241
241
  return (
242
- <section className="flex flex-col gap-8 py-8">
242
+ <div className="flex flex-col gap-8">
243
243
  <CollectionToolbar pinnedCategory={props.pinnedCategory} />
244
244
  <Suspense fallback={<CollectionResultsSkeleton />}>
245
245
  <CollectionResults {...props} />
246
246
  </Suspense>
247
- </section>
247
+ </div>
248
248
  )
249
249
  }