bsmnt 0.13.1 → 0.15.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 (177) hide show
  1. package/dist/core/create/internal-ids.d.ts +1 -0
  2. package/dist/core/create/internal-ids.d.ts.map +1 -1
  3. package/dist/core/create/internal-ids.js +1 -0
  4. package/dist/core/create/internal-ids.js.map +1 -1
  5. package/dist/domain/skills.d.ts +5 -0
  6. package/dist/domain/skills.d.ts.map +1 -1
  7. package/dist/domain/skills.js +9 -1
  8. package/dist/domain/skills.js.map +1 -1
  9. package/dist/infrastructure/create/format-project.d.ts +9 -0
  10. package/dist/infrastructure/create/format-project.d.ts.map +1 -0
  11. package/dist/infrastructure/create/format-project.js +21 -0
  12. package/dist/infrastructure/create/format-project.js.map +1 -0
  13. package/dist/infrastructure/create/lifecycle-registry.d.ts.map +1 -1
  14. package/dist/infrastructure/create/lifecycle-registry.js +5 -0
  15. package/dist/infrastructure/create/lifecycle-registry.js.map +1 -1
  16. package/dist/modules/features/cms/sanity-pagebuilder/config.d.ts.map +1 -1
  17. package/dist/modules/features/cms/sanity-pagebuilder/config.js +1 -0
  18. package/dist/modules/features/cms/sanity-pagebuilder/config.js.map +1 -1
  19. package/dist/modules/features/cms/sanity-pagebuilder/index.d.ts.map +1 -1
  20. package/dist/modules/features/cms/sanity-pagebuilder/index.js +1 -0
  21. package/dist/modules/features/cms/sanity-pagebuilder/index.js.map +1 -1
  22. package/dist/modules/features/cms/sanity-pagebuilder-cache/config.d.ts.map +1 -1
  23. package/dist/modules/features/cms/sanity-pagebuilder-cache/config.js +1 -0
  24. package/dist/modules/features/cms/sanity-pagebuilder-cache/config.js.map +1 -1
  25. package/dist/modules/features/cms/sanity-pagebuilder-cache/index.d.ts.map +1 -1
  26. package/dist/modules/features/cms/sanity-pagebuilder-cache/index.js +1 -0
  27. package/dist/modules/features/cms/sanity-pagebuilder-cache/index.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/agent-skills/ashby-pagebuilder-block/SKILL.md +29 -11
  30. package/src/agent-skills/hubspot-pagebuilder-block/SKILL.md +31 -12
  31. package/src/agent-skills/launch-ready/SKILL.md +114 -0
  32. package/src/agent-skills/launch-ready/references/a11y.md +74 -0
  33. package/src/agent-skills/launch-ready/references/content.md +65 -0
  34. package/src/agent-skills/launch-ready/references/crawlers.md +89 -0
  35. package/src/agent-skills/launch-ready/references/launch.md +44 -0
  36. package/src/agent-skills/launch-ready/references/metadata.md +83 -0
  37. package/src/agent-skills/launch-ready/references/performance.md +36 -0
  38. package/src/agent-skills/launch-ready/references/routing.md +47 -0
  39. package/src/agent-skills/launch-ready/references/security.md +39 -0
  40. package/src/agent-skills/page-builder-block/SKILL.md +26 -13
  41. package/src/modules/features/cms/sanity/files/app/layout.tsx +2 -2
  42. package/src/modules/features/cms/sanity/files/lib/utils/metadata.ts +2 -2
  43. package/src/modules/features/cms/sanity/files/lib/utils/url.ts +6 -3
  44. package/src/modules/features/cms/sanity-pagebuilder/files/app/api/revalidate/route.ts +2 -2
  45. package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/rich-text.tsx +4 -2
  46. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/block-actions-item.tsx +2 -2
  47. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/component-preview-frame.tsx +124 -0
  48. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/component-preview-pane.tsx +25 -0
  49. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-builder-input.tsx +3 -26
  50. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-builder-insert-menu.tsx +121 -93
  51. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/redirect-import-dialog.tsx +538 -0
  52. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/csv.ts +88 -0
  53. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/fetchers/breadcrumbs.ts +22 -0
  54. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/icons.ts +2 -2
  55. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +36 -49
  56. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/presentation.ts +1 -50
  57. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +76 -64
  58. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/redirect-import.ts +651 -0
  59. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/sanity.config.ts +6 -8
  60. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/index.ts +0 -1
  61. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts +1 -4
  62. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/redirect.ts +9 -6
  63. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/structured-data.ts +8 -0
  64. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/block.ts +4 -4
  65. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/metadata.ts +60 -6
  66. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page-builder.ts +4 -18
  67. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page.ts +33 -38
  68. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/richText.ts +0 -1
  69. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/singletons.ts +0 -16
  70. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +52 -35
  71. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/tags.ts +5 -2
  72. package/src/modules/features/cms/sanity-pagebuilder/files/lib/scripts/sanity-typegen.ts +8 -11
  73. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/metadata.ts +109 -136
  74. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/studio-embed.ts +22 -0
  75. package/src/modules/features/cms/sanity-pagebuilder-cache/files/app/api/revalidate/route.ts +2 -2
  76. package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/sanity/rich-text.tsx +4 -2
  77. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/block-actions-item.tsx +2 -2
  78. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/component-preview-frame.tsx +124 -0
  79. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/component-preview-pane.tsx +25 -0
  80. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/page-builder-input.tsx +3 -26
  81. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/page-builder-insert-menu.tsx +121 -93
  82. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/redirect-import-dialog.tsx +538 -0
  83. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/csv.ts +88 -0
  84. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/fetchers/breadcrumbs.ts +25 -0
  85. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/icons.ts +2 -2
  86. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/page-builder-config.ts +36 -49
  87. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/presentation.ts +1 -50
  88. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/queries.ts +76 -64
  89. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/redirect-import.ts +651 -0
  90. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/sanity.config.ts +6 -8
  91. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/index.ts +0 -1
  92. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/index.ts +1 -4
  93. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/layout/redirect.ts +9 -6
  94. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/layout/structured-data.ts +8 -0
  95. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/block.ts +4 -4
  96. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/metadata.ts +60 -6
  97. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/page-builder.ts +4 -18
  98. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/page.ts +33 -38
  99. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/richText.ts +0 -1
  100. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/singletons.ts +0 -16
  101. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/structure.ts +52 -35
  102. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/tags.ts +6 -2
  103. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/scripts/sanity-typegen.ts +8 -11
  104. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/utils/metadata.ts +109 -136
  105. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/utils/studio-embed.ts +22 -0
  106. package/src/modules/features/forms/hubspot/files/_sanity-adapter/form-block.ts +4 -10
  107. package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/README.md +10 -5
  108. package/src/modules/features/jobs/ashby/files/_sanity-adapter/job-board-block.ts +0 -7
  109. package/src/modules/features/jobs/ashby/files/lib/integrations/ashby/README.md +11 -5
  110. package/src/templates/next-default/.env.example +3 -1
  111. package/src/templates/next-default/app/layout.tsx +2 -1
  112. package/src/templates/next-default/app/robots.ts +9 -3
  113. package/src/templates/next-default/lib/utils/metadata.ts +2 -1
  114. package/src/templates/next-default/lib/utils/url.ts +20 -3
  115. package/src/templates/next-default/next.config.ts +16 -0
  116. package/src/templates/next-experiments/.env.example +3 -1
  117. package/src/templates/next-experiments/app/layout.tsx +2 -1
  118. package/src/templates/next-experiments/app/robots.ts +9 -3
  119. package/src/templates/next-experiments/lib/utils/metadata.ts +2 -1
  120. package/src/templates/next-experiments/lib/utils/url.ts +20 -3
  121. package/src/templates/next-experiments/next.config.ts +16 -0
  122. package/src/templates/next-pagebuilder/.env.example +3 -1
  123. package/src/templates/next-pagebuilder/.github/workflows/ci.yml +32 -8
  124. package/src/templates/next-pagebuilder/AGENTS.md +22 -7
  125. package/src/templates/next-pagebuilder/README.md +15 -3
  126. package/src/templates/next-pagebuilder/app/(content)/[[...slug]]/page.tsx +45 -7
  127. package/src/templates/next-pagebuilder/app/actions/unlock-gated-content.ts +5 -4
  128. package/src/templates/next-pagebuilder/app/components/page.tsx +81 -0
  129. package/src/templates/next-pagebuilder/app/layout.tsx +3 -41
  130. package/src/templates/next-pagebuilder/app/robots.ts +9 -3
  131. package/src/templates/next-pagebuilder/biome.json +2 -1
  132. package/src/templates/next-pagebuilder/components/layout/footer/index.tsx +1 -1
  133. package/src/templates/next-pagebuilder/components/page-builder/components/gated-content/index.tsx +8 -3
  134. package/src/templates/next-pagebuilder/components/page-builder/library.ts +183 -0
  135. package/src/templates/next-pagebuilder/components/page-builder/registry.tsx +4 -1
  136. package/src/templates/next-pagebuilder/components/page-builder/renderer.tsx +3 -1
  137. package/src/templates/next-pagebuilder/components/page-builder/stage.tsx +10 -0
  138. package/src/templates/next-pagebuilder/components/page-document/index.tsx +28 -35
  139. package/src/templates/next-pagebuilder/lefthook.yml +1 -1
  140. package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +170 -299
  141. package/src/templates/next-pagebuilder/lib/utils/base-url.ts +18 -3
  142. package/src/templates/next-pagebuilder/lib/utils/breadcrumbs.ts +70 -0
  143. package/src/templates/next-pagebuilder/next.config.ts +16 -0
  144. package/src/templates/next-pagebuilder/package.json +1 -1
  145. package/src/templates/next-pagebuilder-cache/.env.example +3 -1
  146. package/src/templates/next-pagebuilder-cache/.github/workflows/ci.yml +32 -8
  147. package/src/templates/next-pagebuilder-cache/AGENTS.md +22 -7
  148. package/src/templates/next-pagebuilder-cache/README.md +15 -3
  149. package/src/templates/next-pagebuilder-cache/app/(content)/[[...slug]]/page.tsx +46 -7
  150. package/src/templates/next-pagebuilder-cache/app/actions/unlock-gated-content.ts +5 -4
  151. package/src/templates/next-pagebuilder-cache/app/components/page.tsx +91 -0
  152. package/src/templates/next-pagebuilder-cache/app/layout.tsx +5 -46
  153. package/src/templates/next-pagebuilder-cache/app/robots.ts +9 -3
  154. package/src/templates/next-pagebuilder-cache/biome.json +2 -1
  155. package/src/templates/next-pagebuilder-cache/components/layout/footer/index.tsx +1 -1
  156. package/src/templates/next-pagebuilder-cache/components/page-builder/components/gated-content/index.tsx +8 -3
  157. package/src/templates/next-pagebuilder-cache/components/page-builder/library.ts +183 -0
  158. package/src/templates/next-pagebuilder-cache/components/page-builder/registry.tsx +4 -1
  159. package/src/templates/next-pagebuilder-cache/components/page-builder/renderer.tsx +3 -1
  160. package/src/templates/next-pagebuilder-cache/components/page-builder/stage.tsx +10 -0
  161. package/src/templates/next-pagebuilder-cache/components/page-document/index.tsx +28 -35
  162. package/src/templates/next-pagebuilder-cache/lefthook.yml +1 -1
  163. package/src/templates/next-pagebuilder-cache/lib/integrations/sanity/sanity.types.ts +170 -299
  164. package/src/templates/next-pagebuilder-cache/lib/utils/base-url.ts +18 -3
  165. package/src/templates/next-pagebuilder-cache/lib/utils/breadcrumbs.ts +70 -0
  166. package/src/templates/next-pagebuilder-cache/next.config.ts +16 -0
  167. package/src/templates/next-pagebuilder-cache/package.json +1 -1
  168. package/src/templates/next-webgl/.env.example +3 -1
  169. package/src/templates/next-webgl/app/layout.tsx +2 -1
  170. package/src/templates/next-webgl/app/robots.ts +9 -3
  171. package/src/templates/next-webgl/lib/utils/metadata.ts +2 -1
  172. package/src/templates/next-webgl/lib/utils/url.ts +20 -3
  173. package/src/templates/next-webgl/next.config.ts +16 -0
  174. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/use-studio-preview-urls.ts +0 -62
  175. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/studio-preview.ts +0 -37
  176. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/use-studio-preview-urls.ts +0 -62
  177. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/studio-preview.ts +0 -37
@@ -3,15 +3,30 @@
3
3
  *
4
4
  * Priority:
5
5
  * 1. NEXT_PUBLIC_BASE_URL (explicit production URL)
6
- * 2. VERCEL_PROJECT_PRODUCTION_URL (Vercel production domain)
7
- * 3. VERCEL_URL (Vercel preview/deployment URL)
6
+ * 2. VERCEL_PROJECT_PRODUCTION_URL, on production deployments only
7
+ * 3. VERCEL_URL (the deployment's own URL, so previews never emit production URLs)
8
8
  * 4. http://localhost:3000 (local development)
9
9
  */
10
10
  export const getBaseUrl = () =>
11
11
  process.env.NEXT_PUBLIC_BASE_URL ||
12
- (process.env.VERCEL_PROJECT_PRODUCTION_URL
12
+ (process.env.VERCEL_ENV === "production" &&
13
+ process.env.VERCEL_PROJECT_PRODUCTION_URL
13
14
  ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
14
15
  : undefined) ||
15
16
  (process.env.VERCEL_URL
16
17
  ? `https://${process.env.VERCEL_URL}`
17
18
  : "http://localhost:3000")
19
+
20
+ // The production build also answers on *.vercel.app; only the launch domain is indexed.
21
+ export const getIndexableHost = () => {
22
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
23
+ // Off Vercel VERCEL_ENV is unset, and the configured host is the launch domain.
24
+ const vercelEnv = process.env.VERCEL_ENV
25
+ if (!baseUrl || (vercelEnv && vercelEnv !== "production")) return null
26
+ return URL.canParse(baseUrl) ? new URL(baseUrl).host : null
27
+ }
28
+
29
+ export const isIndexableHost = (host: string | null | undefined) => {
30
+ const indexable = getIndexableHost()
31
+ return indexable !== null && host?.toLowerCase() === indexable
32
+ }
@@ -0,0 +1,70 @@
1
+ import type { BREADCRUMB_PAGES_QUERY_RESULT } from "@/lib/integrations/sanity/sanity.types"
2
+
3
+ type BreadcrumbPage = BREADCRUMB_PAGES_QUERY_RESULT[number]
4
+
5
+ type Breadcrumb = {
6
+ name: string
7
+ url: string
8
+ }
9
+
10
+ const HOME_NAME = "Home"
11
+
12
+ const trimSlashes = (path: string) => path.replace(/^\/+|\/+$/g, "")
13
+
14
+ const firstNonEmpty = (...values: (string | null | undefined)[]) =>
15
+ values.find((value) => value?.trim())?.trim()
16
+
17
+ const urlFor = (path: string) => (path ? `/${path}` : "/")
18
+
19
+ const labelFor = (page: BreadcrumbPage | undefined) =>
20
+ page && firstNonEmpty(page.shortTitle, page.folderTitle, page.title)
21
+
22
+ // Every parent path, not the path itself: "a/b/c" → ["a", "a/b"].
23
+ const ancestorPaths = (path: string) => {
24
+ const segments = path.split("/").filter(Boolean)
25
+ return segments
26
+ .slice(0, -1)
27
+ .map((_, index) => segments.slice(0, index + 1).join("/"))
28
+ }
29
+
30
+ // The paths a page's crumbs are named from, besides the homepage: its
31
+ // ancestors and itself.
32
+ export const breadcrumbPaths = (path: string | null) => {
33
+ const current = trimSlashes(path ?? "")
34
+ return current ? [...ancestorPaths(current), current] : []
35
+ }
36
+
37
+ // Crumbs are named from the pages at each level, the current one included:
38
+ // Short Title, then the folder name for a folder's index page, then Title. A
39
+ // level with no page is skipped, so a crumb never links to a 404.
40
+ export const buildBreadcrumbs = ({
41
+ path,
42
+ name,
43
+ pages,
44
+ }: {
45
+ path: string | null
46
+ name: string
47
+ pages: readonly BreadcrumbPage[]
48
+ }): Breadcrumb[] => {
49
+ const pageAt = (target: string) =>
50
+ pages.find((page) => trimSlashes(page.path ?? "") === target)
51
+
52
+ const home = pageAt("")
53
+ const crumbs: Breadcrumb[] = [
54
+ {
55
+ name: firstNonEmpty(home?.shortTitle, home?.title) ?? HOME_NAME,
56
+ url: "/",
57
+ },
58
+ ]
59
+
60
+ const current = trimSlashes(path ?? "")
61
+ if (!current) return crumbs
62
+
63
+ for (const ancestor of ancestorPaths(current)) {
64
+ const ancestorName = labelFor(pageAt(ancestor))
65
+ if (ancestorName) crumbs.push({ name: ancestorName, url: urlFor(ancestor) })
66
+ }
67
+
68
+ crumbs.push({ name: labelFor(pageAt(current)) ?? name, url: urlFor(current) })
69
+ return crumbs
70
+ }
@@ -1,5 +1,9 @@
1
1
  import withBundleAnalyzer from "@next/bundle-analyzer"
2
2
  import type { NextConfig } from "next"
3
+ import { getIndexableHost } from "./lib/utils/base-url"
4
+
5
+ // Only the launch domain is indexed; every other host is sent noindex.
6
+ const indexableHost = getIndexableHost()
3
7
 
4
8
  const nextConfig: NextConfig = {
5
9
  reactStrictMode: true,
@@ -92,6 +96,18 @@ const nextConfig: NextConfig = {
92
96
  imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
93
97
  },
94
98
  headers: async () => [
99
+ {
100
+ source: "/:path*",
101
+ ...(indexableHost && {
102
+ missing: [
103
+ {
104
+ type: "host" as const,
105
+ value: indexableHost.replaceAll(".", "\\."),
106
+ },
107
+ ],
108
+ }),
109
+ headers: [{ key: "X-Robots-Tag", value: "noindex" }],
110
+ },
95
111
  {
96
112
  // Excludes /studio* — must stay mutually exclusive with the rule below.
97
113
  source: "/((?!studio(?:/|$)).*)",
@@ -17,7 +17,7 @@
17
17
  "knip": "knip",
18
18
  "knip:fix": "knip --fix",
19
19
  "lighthouse": "bunx @unlighthouse/cli --site http://localhost:3000",
20
- "lint": "biome lint --max-diagnostics=200",
20
+ "lint": "biome ci --error-on-warnings --max-diagnostics=200 .",
21
21
  "lint:fix": "biome check --write --unsafe --max-diagnostics=200",
22
22
  "prepare": "lefthook install",
23
23
  "start": "next start",
@@ -26,7 +26,9 @@
26
26
 
27
27
  # Base URL
28
28
  # Inlined at build time — scope this to the Production environment only in
29
- # Vercel so Preview deployments fall through to VERCEL_PROJECT_PRODUCTION_URL.
29
+ # Vercel so Preview deployments fall through to their own VERCEL_URL. On a
30
+ # production deployment it is also the only host that gets indexed: every other
31
+ # host is sent `X-Robots-Tag: noindex` and a disallow-all robots.txt.
30
32
  NEXT_PUBLIC_BASE_URL=
31
33
 
32
34
  # Draft Mode
@@ -8,6 +8,13 @@ on:
8
8
  branches:
9
9
  - main
10
10
 
11
+ permissions:
12
+ contents: read
13
+
14
+ concurrency:
15
+ group: ci-${{ github.ref }}
16
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
17
+
11
18
  jobs:
12
19
  ci:
13
20
  runs-on: ubuntu-latest
@@ -43,17 +50,33 @@ jobs:
43
50
  - name: Type check
44
51
  run: bun run typecheck
45
52
 
46
- - name: Lint
53
+ - name: Lint and format
47
54
  run: bun run lint
48
55
 
56
+ # Runs your tests if any exist; a fresh project with none passes cleanly.
57
+ - name: Test
58
+ run: |
59
+ if git ls-files '*.test.*' '*.spec.*' '*_test.*' '*_spec.*' | grep -q .; then
60
+ bun test
61
+ else
62
+ echo "No test files found — skipping bun test."
63
+ fi
64
+
49
65
  # bsmnt:if dotenvx
50
66
  # dotenvx needs the private key to decrypt secrets at build time. It's
51
67
  # pulled from 1Password via an org-wide service-account token + the op-ref
52
68
  # committed in .env. Optional: builds pass without it (only plaintext /
53
69
  # NEXT_PUBLIC_* is read), and a per-repo DOTENV_PRIVATE_KEY secret still
54
70
  # works as a fallback.
71
+ # The `secrets` context isn't available in step `if:`, so the check runs as its own step.
72
+ - name: Detect 1Password token
73
+ id: op
74
+ env:
75
+ OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
76
+ run: '[ -n "$OP_SERVICE_ACCOUNT_TOKEN" ] && echo "enabled=true" >> "$GITHUB_OUTPUT" || true'
77
+
55
78
  - name: Install 1Password CLI
56
- if: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN != '' }}
79
+ if: steps.op.outputs.enabled == 'true'
57
80
  uses: 1password/install-cli-action@v1
58
81
 
59
82
  - name: Build
@@ -92,13 +115,14 @@ jobs:
92
115
  run: bun run build
93
116
 
94
117
  # bsmnt:endif
95
- # Runs your tests if any exist; a fresh project with none passes cleanly.
96
- - name: Test
118
+ # `prebuild` regenerated these; any change (or untracked file) means they were committed stale.
119
+ - name: Generated Sanity files are committed
97
120
  run: |
98
- if git ls-files '*.test.*' '*.spec.*' '*_test.*' '*_spec.*' | grep -q .; then
99
- bun test
100
- else
101
- echo "No test files found — skipping bun test."
121
+ cd lib/integrations/sanity
122
+ if [ -n "$(git status --porcelain -- sanity.types.ts schema.json)" ]; then
123
+ git status --short -- sanity.types.ts schema.json
124
+ echo "::error::Run \`bun sanity:typegen\` and commit the result."
125
+ exit 1
102
126
  fi
103
127
 
104
128
  - name: Knip
@@ -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
@@ -134,3 +146,6 @@ the "Page-builder blocks" section of the README.
134
146
 
135
147
  (`bun run build`, not `bun build` — the bare form is Bun's bundler.) Say what
136
148
  you ran and what it printed; don't report success you haven't seen.
149
+ - **Before a commit or PR, or when told to ship, check the `launch-ready`
150
+ skill's rules against the diff.** Run all of them when asked whether the site
151
+ is ready to launch.
@@ -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
@@ -1,6 +1,9 @@
1
1
  import { cacheLife, cacheTag } from "next/cache"
2
2
  import { notFound } from "next/navigation"
3
- import { PageDocument } from "@/components/page-document"
3
+ import { toPlainText } from "next-sanity"
4
+ import { findBlogBlock, PageDocument } from "@/components/page-document"
5
+ import { getBreadcrumbPages } from "@/lib/integrations/sanity/fetchers/breadcrumbs"
6
+ import { getLayoutData } from "@/lib/integrations/sanity/fetchers/layout"
4
7
  import {
5
8
  type DynamicFetchOptions,
6
9
  getDynamicFetchOptions,
@@ -80,6 +83,23 @@ export const generateStaticParams = async () => {
80
83
  ]
81
84
  }
82
85
 
86
+ // Blog posts fall back to their body and thumbnail when the SEO fields are
87
+ // empty, and are tagged as articles.
88
+ const blogPostMetadata = (page: NonNullable<PAGE_QUERY_RESULT>) => {
89
+ const post = findBlogBlock(page)
90
+ if (!post) return {}
91
+
92
+ return {
93
+ fallbackDescription: post.body ? toPlainText(post.body) : undefined,
94
+ fallbackImage: post.thumbnail,
95
+ article: {
96
+ publishedTime: post.date,
97
+ modifiedTime: page._updatedAt,
98
+ authors: [post.author?.name],
99
+ },
100
+ }
101
+ }
102
+
83
103
  export const generateMetadata = async ({ params }: Props) => {
84
104
  const [{ slug }, fetchOptions] = await Promise.all([
85
105
  params,
@@ -91,11 +111,20 @@ export const generateMetadata = async ({ params }: Props) => {
91
111
 
92
112
  // stega off: it would corrupt a <title> or og:image URL, and metadata renders no
93
113
  // DOM for <VisualEditing> anyway. In published mode this is the body's key.
94
- const page = await getCachedPage(path, { ...fetchOptions, stega: false })
114
+ const metadataFetchOptions = { ...fetchOptions, stega: false }
115
+ const [page, { structuredData }] = await Promise.all([
116
+ getCachedPage(path, metadataFetchOptions),
117
+ getLayoutData(metadataFetchOptions),
118
+ ])
95
119
 
96
- return page
97
- ? generateSanityMetadata({ document: page, url: path ? `/${path}` : "/" })
98
- : {}
120
+ if (!page) return {}
121
+
122
+ return generateSanityMetadata({
123
+ document: page,
124
+ url: path ? `/${path}` : "/",
125
+ site: structuredData,
126
+ ...blogPostMetadata(page),
127
+ })
99
128
  }
100
129
 
101
130
  // No Suspense wrapper needed: `getDynamicFetchOptions` is prerender-safe and
@@ -112,7 +141,10 @@ export default async function CatchAllPage({
112
141
 
113
142
  if (slug?.length && path === null) notFound()
114
143
 
115
- const page = await getCachedPage(path, fetchOptions)
144
+ const [page, breadcrumbPages] = await Promise.all([
145
+ getCachedPage(path, fetchOptions),
146
+ getBreadcrumbPages(path, fetchOptions),
147
+ ])
116
148
 
117
149
  if (!page) {
118
150
  // Fall back to a CMS redirect before 404ing.
@@ -120,5 +152,12 @@ export default async function CatchAllPage({
120
152
  notFound()
121
153
  }
122
154
 
123
- return <PageDocument page={page} path={path} searchParams={searchParams} />
155
+ return (
156
+ <PageDocument
157
+ page={page}
158
+ path={path}
159
+ searchParams={searchParams}
160
+ breadcrumbPages={breadcrumbPages}
161
+ />
162
+ )
124
163
  }
@@ -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
@@ -4,17 +4,11 @@ import type { Metadata } from "next"
4
4
  import { Geist } from "next/font/google"
5
5
  import type { PropsWithChildren } from "react"
6
6
  import { cn } from "@/lib/styles/cn"
7
+ import { getBaseUrl } from "@/lib/utils/base-url"
7
8
  import AppData from "@/package.json"
8
9
 
9
10
  const APP_NAME = AppData.name
10
- const APP_DEFAULT_TITLE = "My Site"
11
- const APP_TITLE_TEMPLATE = "%s"
12
- const APP_DESCRIPTION = AppData.description
13
- // Build-time-stable so static routes (Studio, /_not-found) stay prerenderable
14
- // under Cache Components. NEXT_PUBLIC_* is inlined at build; reading runtime env
15
- // (VERCEL_URL) here would taint every route's metadata as request-time data.
16
- const APP_BASE_URL =
17
- process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"
11
+ const APP_BASE_URL = getBaseUrl()
18
12
 
19
13
  const geist = Geist({
20
14
  subsets: ["latin"],
@@ -22,55 +16,20 @@ const geist = Geist({
22
16
  })
23
17
 
24
18
  export const metadata: Metadata = {
25
- alternates: {
26
- canonical: "/",
27
- languages: {
28
- "en-US": "/en-US",
29
- },
30
- },
19
+ metadataBase: new URL(APP_BASE_URL),
20
+ applicationName: APP_NAME,
31
21
  appleWebApp: {
32
22
  capable: true,
33
23
  statusBarStyle: "default",
34
- title: APP_DEFAULT_TITLE,
35
24
  },
36
- applicationName: APP_NAME,
37
- authors: [{ name: "basement.studio", url: "https://basement.studio" }],
38
- description: APP_DESCRIPTION,
39
25
  formatDetection: { telephone: false },
40
- metadataBase: new URL(APP_BASE_URL),
41
26
  openGraph: {
42
- description: APP_DESCRIPTION,
43
- images: [
44
- {
45
- alt: APP_DEFAULT_TITLE,
46
- height: 630,
47
- url: "/opengraph-image.jpg",
48
- width: 1200,
49
- },
50
- ],
51
- locale: "en_US",
52
27
  siteName: APP_NAME,
53
- title: {
54
- default: APP_DEFAULT_TITLE,
55
- template: APP_TITLE_TEMPLATE,
56
- },
28
+ locale: "en_US",
57
29
  type: "website",
58
- url: APP_BASE_URL,
59
- },
60
- other: {
61
- "fb:app_id": process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || "",
62
- },
63
- title: {
64
- default: APP_DEFAULT_TITLE,
65
- template: APP_TITLE_TEMPLATE,
66
30
  },
67
31
  twitter: {
68
32
  card: "summary_large_image",
69
- description: APP_DESCRIPTION,
70
- title: {
71
- default: APP_DEFAULT_TITLE,
72
- template: APP_TITLE_TEMPLATE,
73
- },
74
33
  },
75
34
  }
76
35
 
@@ -1,12 +1,18 @@
1
1
  import type { MetadataRoute } from "next"
2
- import { getBaseUrl } from "@/lib/utils/base-url"
2
+ import { headers } from "next/headers"
3
+ import { getBaseUrl, isIndexableHost } from "@/lib/utils/base-url"
4
+
5
+ export default async function robots(): Promise<MetadataRoute.Robots> {
6
+ const host = (await headers()).get("host")
7
+ if (!isIndexableHost(host)) {
8
+ return { rules: { userAgent: "*", disallow: "/" } }
9
+ }
3
10
 
4
- export default function robots(): MetadataRoute.Robots {
5
11
  return {
6
12
  rules: {
7
13
  userAgent: "*",
8
14
  allow: "/",
9
- disallow: [],
15
+ disallow: ["/api/", "/studio"],
10
16
  },
11
17
  sitemap: `${getBaseUrl()}/sitemap.xml`,
12
18
  }
@@ -36,7 +36,8 @@
36
36
  "!**/*.mdx",
37
37
  "!**/tailwind.css",
38
38
  "!**/*.grit",
39
- "!**/sanity.types.ts"
39
+ "!**/sanity.types.ts",
40
+ "!next-env.d.ts"
40
41
  ]
41
42
  },
42
43
  "formatter": {
@@ -60,7 +60,7 @@ async function CachedFooter(fetchOptions: DynamicFetchOptions) {
60
60
  <div className="grid grid-cols-5 gap-x-8 gap-y-6 md:grid-cols-3 lg:grid-cols-5">
61
61
  {links.map((linkGroup: FooterLinkGroup) => (
62
62
  <div key={linkGroup._key} className="flex flex-col gap-2">
63
- <h3>{linkGroup.title}</h3>
63
+ <h2>{linkGroup.title}</h2>
64
64
  {linkGroup.items && linkGroup.items.length > 0 ? (
65
65
  <ul className="space-y-1">
66
66
  {linkGroup.items