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
@@ -15,7 +15,6 @@ import {
15
15
  gatedContent,
16
16
  hero,
17
17
  richTextBlock,
18
- studioPreview,
19
18
  } from "./components";
20
19
  import { author } from "./content/author";
21
20
  import { blogCategory } from "./content/blog-category";
@@ -83,7 +82,6 @@ export {
83
82
  spacingSettings,
84
83
  spacingStep,
85
84
  structuredData,
86
- studioPreview,
87
85
  videoJsonLd,
88
86
  };
89
87
 
@@ -121,8 +119,7 @@ export const schema: { types: SchemaTypeDefinition[] } = {
121
119
  description,
122
120
  richTextBlock,
123
121
  gatedContent,
124
- studioPreview,
125
-
122
+
126
123
  // Layout types (singletons for site-wide layout)
127
124
  companyData,
128
125
  navbar,
@@ -2,6 +2,9 @@ import { defineField, defineType } from "sanity";
2
2
  import { apiVersion } from "@/lib/integrations/sanity/env";
3
3
  import { redirectIcon } from "@/lib/integrations/sanity/icons";
4
4
 
5
+ export const REDIRECT_SOURCE_MAX_LENGTH = 200;
6
+ export const REDIRECT_PATH_PATTERN = /^[a-z0-9-/]+$/;
7
+
5
8
  type RedirectValidationDocument = {
6
9
  _id?: string;
7
10
  source?: { current?: string };
@@ -23,14 +26,14 @@ export const redirect = defineType({
23
26
  type: "slug",
24
27
  description:
25
28
  "Incoming route path to match, without a leading slash (e.g. old-page or blog/old-post).",
26
- options: { maxLength: 200 },
29
+ options: { maxLength: REDIRECT_SOURCE_MAX_LENGTH },
27
30
  validation: (Rule) =>
28
31
  Rule.required().custom(async (slug, context) => {
29
32
  if (!slug?.current) {
30
33
  return "Source is required";
31
34
  }
32
35
 
33
- if (!/^[a-z0-9-/]+$/.test(slug.current)) {
36
+ if (!REDIRECT_PATH_PATTERN.test(slug.current)) {
34
37
  return "Source must be lowercase with hyphens, letters, numbers, and forward slashes only";
35
38
  }
36
39
 
@@ -63,15 +66,15 @@ export const redirect = defineType({
63
66
  title: "Destination",
64
67
  type: "string",
65
68
  description:
66
- "Where to send visitors. An internal route path without a leading slash (e.g. new-page or blog/new-post), or an absolute URL (e.g. https://example.com).",
69
+ "Where to send visitors. An internal route path without a leading slash (e.g. new-page or blog/new-post), / for the homepage, or an absolute URL (e.g. https://example.com).",
67
70
  validation: (Rule) =>
68
71
  Rule.required().custom(async (value, context) => {
69
72
  if (typeof value !== "string") return true;
70
73
 
71
74
  const isAbsolute = /^https?:\/\//.test(value);
72
- if (isAbsolute) return true;
75
+ if (isAbsolute || value === "/") return true;
73
76
 
74
- if (!/^[a-z0-9-/]+$/.test(value)) {
77
+ if (!REDIRECT_PATH_PATTERN.test(value)) {
75
78
  return "Destination must be a slash-free route path (lowercase, hyphens, numbers, forward slashes) or an absolute http(s) URL";
76
79
  }
77
80
 
@@ -141,7 +144,7 @@ export const redirect = defineType({
141
144
  if (destination) {
142
145
  target = /^https?:\/\//.test(destination)
143
146
  ? destination
144
- : `/${destination}`;
147
+ : `/${destination.replace(/^\/+/, "")}`;
145
148
  }
146
149
  return {
147
150
  title: source ? `/${source}` : "Untitled redirect",
@@ -36,6 +36,14 @@ export const structuredData = defineType({
36
36
  "Square or wide logo used in rich results. Min 112x112px, transparent PNG/SVG recommended.",
37
37
  options: { hotspot: true },
38
38
  }),
39
+ defineField({
40
+ name: "shareImage",
41
+ title: "Default Share Image",
42
+ type: "image",
43
+ description:
44
+ "Social preview (Open Graph) for any page without its own OG image. 1200x630px.",
45
+ options: { hotspot: true },
46
+ }),
39
47
  defineField({
40
48
  name: "description",
41
49
  title: "Description",
@@ -1,8 +1,8 @@
1
1
  import {
2
- type DocumentDefinition,
3
2
  defineType,
4
3
  type FieldDefinition,
5
4
  type FieldGroupDefinition,
5
+ type ObjectDefinition,
6
6
  } from "sanity";
7
7
  import { presentationFields } from "./presentation";
8
8
 
@@ -11,7 +11,7 @@ const presentationGroup: FieldGroupDefinition = {
11
11
  title: "Presentation",
12
12
  };
13
13
 
14
- type BlockDefinition = Omit<DocumentDefinition, "type" | "fields" | "groups"> & {
14
+ type BlockDefinition = Omit<ObjectDefinition, "type" | "fields" | "groups"> & {
15
15
  fields: FieldDefinition[];
16
16
  groups?: FieldGroupDefinition[];
17
17
  };
@@ -26,7 +26,7 @@ const withPresentationGroup = (groups: FieldGroupDefinition[] | undefined) => {
26
26
  };
27
27
 
28
28
  /**
29
- * Defines a page-builder block — `type: "document"` plus Anchor ID and
29
+ * Defines a page-builder block — an inline `type: "object"` plus Anchor ID and
30
30
  * Presentation Mode. Use instead of `defineType`.
31
31
  */
32
32
  export const defineBlock = ({ fields, groups, ...rest }: BlockDefinition) => {
@@ -34,7 +34,7 @@ export const defineBlock = ({ fields, groups, ...rest }: BlockDefinition) => {
34
34
 
35
35
  return defineType({
36
36
  ...rest,
37
- type: "document",
37
+ type: "object",
38
38
  ...(blockGroups ? { groups: blockGroups } : {}),
39
39
  fields: [
40
40
  ...fields,
@@ -1,6 +1,38 @@
1
- import { defineArrayMember, defineField, defineType } from "sanity";
1
+ import {
2
+ defineArrayMember,
3
+ defineField,
4
+ defineType,
5
+ type ValidationContext,
6
+ } from "sanity";
7
+ import { apiVersion } from "@/lib/integrations/sanity/env";
2
8
  import { pageJsonLdMembers } from "./structured-data-blocks";
3
9
 
10
+ const duplicateCountQuery = (
11
+ field: "metaTitle" | "metaDescription",
12
+ ) => `count(*[
13
+ defined(metadata.${field}) &&
14
+ metadata.${field} == $value &&
15
+ !(_id in [$id, "drafts." + $id])
16
+ ])`;
17
+
18
+ // Drafts perspective, so a duplicate that's only saved as a draft still warns.
19
+ const warnIfDuplicated =
20
+ (field: "metaTitle" | "metaDescription", label: string) =>
21
+ async (value: string | undefined, context: ValidationContext) => {
22
+ const id = context.document?._id?.replace(
23
+ /^(drafts|versions\.[^.]+)\./,
24
+ "",
25
+ );
26
+ if (!value?.trim() || !id) return true;
27
+ const duplicates = await context
28
+ .getClient({ apiVersion })
29
+ .withConfig({ perspective: "drafts" })
30
+ .fetch<number>(duplicateCountQuery(field), { value, id });
31
+ return duplicates > 0
32
+ ? `Another document already uses this ${label}`
33
+ : true;
34
+ };
35
+
4
36
  export const metadata = defineType({
5
37
  name: "metadata",
6
38
  title: "SEO & Metadata",
@@ -21,10 +53,12 @@ export const metadata = defineType({
21
53
  type: "string",
22
54
  group: "search",
23
55
  description: "Title used by search engines and browser tabs",
24
- validation: (Rule) =>
56
+ validation: (Rule) => [
25
57
  Rule.max(60).warning(
26
58
  "Title should be under 60 characters for optimal SEO",
27
59
  ),
60
+ Rule.custom(warnIfDuplicated("metaTitle", "meta title")).warning(),
61
+ ],
28
62
  }),
29
63
  defineField({
30
64
  name: "metaDescription",
@@ -33,10 +67,14 @@ export const metadata = defineType({
33
67
  rows: 3,
34
68
  group: "search",
35
69
  description: "Description used by search engines",
36
- validation: (Rule) =>
70
+ validation: (Rule) => [
37
71
  Rule.max(160).warning(
38
72
  "Description should be under 160 characters for optimal SEO",
39
73
  ),
74
+ Rule.custom(
75
+ warnIfDuplicated("metaDescription", "meta description"),
76
+ ).warning(),
77
+ ],
40
78
  }),
41
79
  defineField({
42
80
  name: "keywords",
@@ -66,7 +104,9 @@ export const metadata = defineType({
66
104
  description:
67
105
  "Title shown when shared on social (Open Graph & Twitter). Falls back to Meta Title.",
68
106
  validation: (Rule) =>
69
- Rule.max(70).warning("Social titles are often truncated past ~70 chars"),
107
+ Rule.max(70).warning(
108
+ "Social titles are often truncated past ~70 chars",
109
+ ),
70
110
  }),
71
111
  defineField({
72
112
  name: "ogDescription",
@@ -87,9 +127,14 @@ export const metadata = defineType({
87
127
  type: "url",
88
128
  group: "advanced",
89
129
  description:
90
- "Override the canonical URL for this page. Leave blank to use the page's own URL. Use for duplicate/syndicated content.",
130
+ "Override the canonical URL for this page: a full URL, or a path starting with / (e.g. /blog/original-post). Leave blank to use the page's own URL. Use for duplicate/syndicated content.",
91
131
  validation: (Rule) =>
92
- Rule.uri({ scheme: ["http", "https"], allowRelative: true }),
132
+ Rule.uri({ scheme: ["http", "https"], allowRelative: true }).custom(
133
+ (value) =>
134
+ !value || /^(https?:\/\/|\/)/i.test(value)
135
+ ? true
136
+ : "Use a full URL (https://…) or a path starting with /",
137
+ ),
93
138
  }),
94
139
  defineField({
95
140
  name: "index",
@@ -99,6 +144,15 @@ export const metadata = defineType({
99
144
  description: "Allow search engines to index this page",
100
145
  initialValue: true,
101
146
  }),
147
+ defineField({
148
+ name: "follow",
149
+ title: "Follow links",
150
+ type: "boolean",
151
+ group: "advanced",
152
+ description:
153
+ "Allow search engines to follow the links on this page. Independent of Index.",
154
+ initialValue: true,
155
+ }),
102
156
  defineField({
103
157
  name: "jsonLd",
104
158
  title: "Structured Data (JSON-LD)",
@@ -2,8 +2,8 @@ import { defineArrayMember, defineType } from "sanity";
2
2
  import { BlockActionsItem } from "@/lib/integrations/sanity/components/block-actions-item";
3
3
  import { PageBuilderInput } from "@/lib/integrations/sanity/components/page-builder-input";
4
4
  import {
5
+ pageBuilderBlockMembers,
5
6
  pageBuilderInsertMenuGroups,
6
- pageBuilderReferenceMembers,
7
7
  } from "@/lib/integrations/sanity/page-builder-config";
8
8
 
9
9
  export const pageBuilder = defineType({
@@ -17,27 +17,13 @@ export const pageBuilder = defineType({
17
17
  groups: pageBuilderInsertMenuGroups,
18
18
  },
19
19
  },
20
- of: pageBuilderReferenceMembers.map((member) =>
20
+ of: pageBuilderBlockMembers.map((member) =>
21
21
  defineArrayMember({
22
- name: member.name,
22
+ type: member.documentType,
23
23
  title: member.title,
24
- type: "reference",
25
- to: [{ type: member.documentType }],
26
24
  // Per-placement Hide/Show action; hidden state lives in the owning
27
- // document's `hiddenPageBuilderKeys`, not on the ref itself.
25
+ // document's `hiddenPageBuilderKeys`, not on the block itself.
28
26
  components: { item: BlockActionsItem },
29
- ...(member.documentId
30
- ? {
31
- options: {
32
- disableNew: true,
33
- filter: "_id in [$documentId, $draftDocumentId]",
34
- filterParams: {
35
- documentId: member.documentId,
36
- draftDocumentId: `drafts.${member.documentId}`,
37
- },
38
- },
39
- }
40
- : {}),
41
27
  }),
42
28
  ),
43
29
  });
@@ -4,8 +4,9 @@ import { SlugInput } from "@/lib/integrations/sanity/components/slug-input";
4
4
  import { apiVersion } from "@/lib/integrations/sanity/env";
5
5
  import { pageIcon } from "@/lib/integrations/sanity/icons";
6
6
  import {
7
+ countPageHeadingBlocks,
7
8
  getInvalidPageBuilderDocumentTypes,
8
- getPageBuilderReferenceMemberByDocumentType,
9
+ getPageBuilderBlockMember,
9
10
  getPageCategoryIcon,
10
11
  type PageBuilderDocumentType,
11
12
  type PageBuilderGroupName,
@@ -24,8 +25,8 @@ type PageValidationDocument = {
24
25
  pageBuilder?: Array<{
25
26
  _key?: string;
26
27
  _type?: string;
27
- _ref?: string;
28
28
  }>;
29
+ hiddenPageBuilderKeys?: string[];
29
30
  };
30
31
 
31
32
  type PageSlugValidationContext = {
@@ -35,11 +36,6 @@ type PageSlugValidationContext = {
35
36
  };
36
37
  };
37
38
 
38
- type ReferencedPageBuilderDocument = {
39
- _id: string;
40
- _type: PageBuilderDocumentType;
41
- };
42
-
43
39
  const PAGE_FOLDER_QUERY = `*[_type == "pageFolder" && _id in [$publishedId, $draftId]][0].slug.current`;
44
40
 
45
41
  async function getGeneratedPageCategoryForDocument(
@@ -107,6 +103,13 @@ export const page = defineType({
107
103
  type: "string",
108
104
  validation: (Rule) => Rule.required(),
109
105
  }),
106
+ defineField({
107
+ name: "shortTitle",
108
+ title: "Short Title",
109
+ type: "string",
110
+ description:
111
+ "Label for this page in breadcrumbs (e.g. Blog). Falls back to the folder name for a folder's index page, then to Title.",
112
+ }),
110
113
  defineField({
111
114
  name: "type",
112
115
  title: "Page Category",
@@ -155,7 +158,18 @@ export const page = defineType({
155
158
  title: "Page Builder",
156
159
  type: "pageBuilder",
157
160
  description: "Composable content blocks for this page",
158
- validation: (Rule) =>
161
+ validation: (Rule) => [
162
+ // Without a Heading block the page title is the H1, so only more
163
+ // than one is a problem.
164
+ Rule.custom((value, context) => {
165
+ const blocks = Array.isArray(value) ? value : [];
166
+ const hiddenKeys =
167
+ (context.document as PageValidationDocument | undefined)
168
+ ?.hiddenPageBuilderKeys ?? [];
169
+ return countPageHeadingBlocks(blocks, hiddenKeys) > 1
170
+ ? "More than one visible Heading block, so this page has several H1s"
171
+ : true;
172
+ }).warning(),
159
173
  Rule.custom(async (value, context) => {
160
174
  const document = context.document as
161
175
  | PageValidationDocument
@@ -164,55 +178,36 @@ export const page = defineType({
164
178
  document,
165
179
  context.getClient,
166
180
  );
167
- const pageBuilderItems = Array.isArray(value)
168
- ? (value as Array<{ _ref?: string }>)
169
- : [];
170
- const referenceIds = pageBuilderItems
171
- .map((item) => item?._ref?.replace(/^drafts\./, ""))
172
- .filter((item): item is string => typeof item === "string");
173
-
174
- if (referenceIds.length === 0) return true;
175
-
176
- const referencedDocuments = await context
177
- .getClient({ apiVersion })
178
- .fetch<ReferencedPageBuilderDocument[]>(
179
- `*[
180
- _id in $publishedIds || _id in $draftIds
181
- ]{
182
- _id,
183
- _type
184
- }`,
185
- {
186
- publishedIds: referenceIds,
187
- draftIds: referenceIds.map((id) => `drafts.${id}`),
188
- },
181
+ const documentTypes = (Array.isArray(value) ? value : [])
182
+ .map((item) => (item as { _type?: string })?._type)
183
+ .filter(
184
+ (type): type is PageBuilderDocumentType =>
185
+ typeof type === "string",
189
186
  );
190
187
 
188
+ if (documentTypes.length === 0) return true;
189
+
191
190
  const invalidDocumentTypes = getInvalidPageBuilderDocumentTypes(
192
191
  pageType,
193
- referencedDocuments.map((entry) => entry._type),
192
+ documentTypes,
194
193
  );
195
194
 
196
195
  if (invalidDocumentTypes.length === 0) return true;
197
196
 
198
- const invalidMemberTitles = invalidDocumentTypes.map(
197
+ const invalidMemberTitles = [...new Set(invalidDocumentTypes)].map(
199
198
  (documentType) =>
200
- getPageBuilderReferenceMemberByDocumentType(documentType)
201
- ?.title ?? documentType,
199
+ getPageBuilderBlockMember(documentType)?.title ?? documentType,
202
200
  );
203
201
 
204
202
  return `These page builder components do not belong to the "${pageType ?? "generic"}" category: ${invalidMemberTitles.join(", ")}`;
205
203
  }),
204
+ ],
206
205
  }),
207
206
  defineField({
208
207
  name: "collectionCategory",
209
208
  title: "Pinned Collection Category",
210
209
  type: "reference",
211
210
  to: [{ type: "blogCategory" }],
212
- // Lives on the page because the collection block is deliberately a
213
- // singleton (see `singletonComponents`) — editors reuse one block rather
214
- // than creating them, so a category set there would apply site-wide. Move
215
- // the block to the reusable list if you'd rather scope it per instance.
216
211
  description:
217
212
  "Turns this page into a category page: the content collection shows only this category. Leave empty to show everything.",
218
213
  }),
@@ -10,7 +10,6 @@ export const richText = defineType({
10
10
  type: "block",
11
11
  styles: [
12
12
  { title: "Normal", value: "normal" },
13
- { title: "H1", value: "h1" },
14
13
  { title: "H2", value: "h2" },
15
14
  { title: "H3", value: "h3" },
16
15
  { title: "H4", value: "h4" },
@@ -2,22 +2,10 @@ import {
2
2
  companyDataIcon,
3
3
  footerIcon,
4
4
  navbarIcon,
5
- pageListIcon,
6
5
  spacingSettingsIcon,
7
6
  structuredDataIcon,
8
7
  } from "./icons";
9
8
 
10
- // `category` must be one of `componentCategories` in page-builder-config.ts.
11
- export const singletonComponents = [
12
- {
13
- schemaType: "blogCollection",
14
- documentId: "blogCollection",
15
- title: "Blog Collection",
16
- icon: pageListIcon,
17
- category: "list",
18
- },
19
- ] as const;
20
-
21
9
  export const singletonLayout = [
22
10
  {
23
11
  schemaType: "companyData",
@@ -51,10 +39,6 @@ export const singletonLayout = [
51
39
  },
52
40
  ] as const;
53
41
 
54
- export const singletonComponentTypes: Set<string> = new Set(
55
- singletonComponents.map((item) => item.schemaType),
56
- );
57
-
58
42
  export const singletonLayoutTypes: Set<string> = new Set(
59
43
  singletonLayout.map((item) => item.schemaType),
60
44
  );
@@ -1,3 +1,4 @@
1
+ import { UploadIcon } from "@sanity/icons";
1
2
  import { from } from "rxjs";
2
3
  import { map } from "rxjs/operators";
3
4
  import type {
@@ -6,18 +7,21 @@ import type {
6
7
  StructureResolver,
7
8
  StructureResolverContext,
8
9
  } from "sanity/structure";
10
+ import {
11
+ COMPONENT_LIBRARY,
12
+ getComponentPreviewPath,
13
+ } from "@/components/page-builder/library";
14
+ import { pageBuilderInsertMenuGroups } from "@/lib/integrations/sanity/page-builder-config";
15
+ import { ComponentPreviewPane } from "./components/component-preview-pane";
16
+ import { openRedirectImport } from "./components/redirect-import-dialog";
9
17
  import { apiVersion } from "./env";
10
18
  import {
11
19
  authorIcon,
12
20
  blogCategoryIcon,
21
+ componentIcon,
13
22
  componentsFolderIcon,
14
23
  contentFolderIcon,
15
- contentIcon,
16
- descriptionIcon,
17
- gatedContentIcon,
18
- heroIcon,
19
24
  layoutFolderIcon,
20
- pageBuilderCardIcon,
21
25
  pageFolderIcon,
22
26
  pageIcon,
23
27
  pagesFolderIcon,
@@ -25,7 +29,7 @@ import {
25
29
  } from "./icons";
26
30
  import { PageTreePane } from "./page-tree-pane";
27
31
  import { PINNED_PAGE_ENTRIES } from "./pinned-pages";
28
- import { singletonComponents, singletonLayout } from "./singletons";
32
+ import { singletonLayout } from "./singletons";
29
33
 
30
34
  const PAGE_TREE_ITEM_ID_PREFIX = {
31
35
  page: "page-tree-",
@@ -239,6 +243,18 @@ function buildPageTreeComponent(
239
243
  .menuItems([...folderMenuItems, ...createMenuItems]);
240
244
  }
241
245
 
246
+ const redirectList = (S: StructureBuilder) => {
247
+ const list = S.documentTypeList("redirect").title("Redirects");
248
+ return list.menuItems([
249
+ ...(list.getMenuItems() ?? []),
250
+ S.menuItem()
251
+ .title("Import CSV")
252
+ .icon(UploadIcon)
253
+ .action(openRedirectImport)
254
+ .showAsAction(true),
255
+ ]);
256
+ };
257
+
242
258
  // https://www.sanity.io/docs/structure-builder-cheat-sheet
243
259
  export const structure: StructureResolver = (S, context) =>
244
260
  S.list()
@@ -252,7 +268,7 @@ export const structure: StructureResolver = (S, context) =>
252
268
 
253
269
  S.divider(),
254
270
 
255
- // GROUP 2: Content -- blog posts, authors, blog categories
271
+ // GROUP 2: Content -- authors, blog categories
256
272
  S.listItem()
257
273
  .title("Content")
258
274
  .icon(contentFolderIcon)
@@ -260,9 +276,6 @@ export const structure: StructureResolver = (S, context) =>
260
276
  S.list()
261
277
  .title("Content")
262
278
  .items([
263
- S.documentTypeListItem("blogContent")
264
- .title("Blog Posts")
265
- .icon(contentIcon),
266
279
  S.documentTypeListItem("author")
267
280
  .title("Authors")
268
281
  .icon(authorIcon),
@@ -274,7 +287,7 @@ export const structure: StructureResolver = (S, context) =>
274
287
 
275
288
  S.divider(),
276
289
 
277
- // GROUP 3: Components -- singleton and reusable components
290
+ // GROUP 3: Components -- live block previews, grouped like the insert menu
278
291
  S.listItem()
279
292
  .title("Components")
280
293
  .icon(componentsFolderIcon)
@@ -282,30 +295,33 @@ export const structure: StructureResolver = (S, context) =>
282
295
  S.list()
283
296
  .title("Components")
284
297
  .items([
285
- S.divider().title("Singleton Components"),
286
- ...singletonComponents.map((item) =>
287
- S.listItem()
288
- .title(item.title)
289
- .id(item.documentId)
290
- .icon(item.icon)
291
- .child(
292
- S.document()
293
- .schemaType(item.schemaType)
294
- .documentId(item.documentId),
298
+ ...pageBuilderInsertMenuGroups.flatMap((group) => {
299
+ const entries = COMPONENT_LIBRARY.filter((entry) =>
300
+ group.of.includes(entry.documentType),
301
+ );
302
+
303
+ if (entries.length === 0) return [];
304
+
305
+ return [
306
+ S.divider().title(group.title),
307
+ ...entries.map((entry) =>
308
+ S.listItem()
309
+ .title(entry.title)
310
+ .id(entry.documentType)
311
+ .icon(componentIcon)
312
+ .child(
313
+ S.component()
314
+ .id(`component-${entry.documentType}`)
315
+ .title(entry.title)
316
+ .component(ComponentPreviewPane)
317
+ .options({
318
+ notes: entry.notes,
319
+ path: getComponentPreviewPath(entry.documentType),
320
+ }),
321
+ ),
295
322
  ),
296
- ),
297
- S.divider().title("Reusable Components"),
298
- S.documentTypeListItem("hero").title("Headings").icon(heroIcon),
299
- S.documentTypeListItem("description")
300
- .title("Descriptions")
301
- .icon(descriptionIcon),
302
- S.documentTypeListItem("gatedContent")
303
- .title("Gated Content")
304
- .icon(gatedContentIcon),
305
- S.divider(),
306
- S.documentTypeListItem("studioPreview")
307
- .title("Component Previews")
308
- .icon(pageBuilderCardIcon),
323
+ ];
324
+ }),
309
325
  ]),
310
326
  ),
311
327
 
@@ -333,7 +349,8 @@ export const structure: StructureResolver = (S, context) =>
333
349
  S.divider(),
334
350
  S.documentTypeListItem("redirect")
335
351
  .title("Redirects")
336
- .icon(redirectIcon),
352
+ .icon(redirectIcon)
353
+ .child(redirectList(S)),
337
354
  ]),
338
355
  ),
339
356
  ]);
@@ -29,6 +29,7 @@ export const SELF_TAGGED_TYPES = new Set<string>([
29
29
  // Crawler routes: broader than any per-slug tag.
30
30
  export const SITEMAP_TAG = "sitemap";
31
31
  export const FEED_TAG = "feed";
32
+ export const BREADCRUMBS_TAG = "breadcrumbs";
32
33
 
33
34
  // The content-collection block caches each window of results separately.
34
35
  export const BLOG_CATEGORIES_TAG = "blog-categories";
@@ -56,8 +57,9 @@ export const BLOG_COLLECTION_TYPES = new Set<string>([
56
57
  export const CROSS_DOC_TAG_DEPENDENCIES: Record<string, readonly string[]> = {
57
58
  // Redundant while LAYOUT_QUERY is merged; kept so splitting it can't lose this.
58
59
  companyData: [SANITY_STRUCTURED_DATA_TAG],
59
- // Nav links deref pages; sitemap and feed move whenever any page does.
60
- page: [SANITY_NAVBAR_TAG, SITEMAP_TAG, FEED_TAG],
60
+ // Nav links deref pages; sitemap, feed and breadcrumbs move whenever any
61
+ // page does.
62
+ page: [SANITY_NAVBAR_TAG, SITEMAP_TAG, FEED_TAG, BREADCRUMBS_TAG],
61
63
  // ...plus the collections, which decide membership on the folder slug, so a
62
64
  // rename changes which pages are articles at all.
63
65
  pageFolder: [
@@ -65,6 +67,7 @@ export const CROSS_DOC_TAG_DEPENDENCIES: Record<string, readonly string[]> = {
65
67
  ...BLOG_COLLECTION_TAGS,
66
68
  SITEMAP_TAG,
67
69
  FEED_TAG,
70
+ BREADCRUMBS_TAG,
68
71
  ],
69
72
  // The feed derefs the article's block, then its author and categories.
70
73
  blogContent: [FEED_TAG],
@@ -88,4 +91,5 @@ export const PURGEABLE_TAGS = [
88
91
  ...BLOG_COLLECTION_TAGS,
89
92
  SITEMAP_TAG,
90
93
  FEED_TAG,
94
+ BREADCRUMBS_TAG,
91
95
  ];