bsmnt 0.14.0 → 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 (116) 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/index.d.ts.map +1 -1
  17. package/dist/modules/features/cms/sanity-pagebuilder/index.js +1 -0
  18. package/dist/modules/features/cms/sanity-pagebuilder/index.js.map +1 -1
  19. package/dist/modules/features/cms/sanity-pagebuilder-cache/index.d.ts.map +1 -1
  20. package/dist/modules/features/cms/sanity-pagebuilder-cache/index.js +1 -0
  21. package/dist/modules/features/cms/sanity-pagebuilder-cache/index.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/agent-skills/launch-ready/SKILL.md +114 -0
  24. package/src/agent-skills/launch-ready/references/a11y.md +74 -0
  25. package/src/agent-skills/launch-ready/references/content.md +65 -0
  26. package/src/agent-skills/launch-ready/references/crawlers.md +89 -0
  27. package/src/agent-skills/launch-ready/references/launch.md +44 -0
  28. package/src/agent-skills/launch-ready/references/metadata.md +83 -0
  29. package/src/agent-skills/launch-ready/references/performance.md +36 -0
  30. package/src/agent-skills/launch-ready/references/routing.md +47 -0
  31. package/src/agent-skills/launch-ready/references/security.md +39 -0
  32. package/src/modules/features/cms/sanity/files/app/layout.tsx +2 -2
  33. package/src/modules/features/cms/sanity/files/lib/utils/metadata.ts +2 -2
  34. package/src/modules/features/cms/sanity/files/lib/utils/url.ts +6 -3
  35. package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/rich-text.tsx +3 -1
  36. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/redirect-import-dialog.tsx +538 -0
  37. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/csv.ts +88 -0
  38. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/fetchers/breadcrumbs.ts +22 -0
  39. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +15 -0
  40. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +20 -0
  41. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/redirect-import.ts +651 -0
  42. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/sanity.config.ts +2 -0
  43. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/redirect.ts +9 -6
  44. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/structured-data.ts +8 -0
  45. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/metadata.ts +60 -6
  46. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page.ts +22 -1
  47. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/richText.ts +0 -1
  48. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +16 -1
  49. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/tags.ts +5 -2
  50. package/src/modules/features/cms/sanity-pagebuilder/files/lib/scripts/sanity-typegen.ts +8 -11
  51. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/metadata.ts +109 -136
  52. package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/sanity/rich-text.tsx +3 -1
  53. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/redirect-import-dialog.tsx +538 -0
  54. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/csv.ts +88 -0
  55. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/fetchers/breadcrumbs.ts +25 -0
  56. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/page-builder-config.ts +15 -0
  57. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/queries.ts +20 -0
  58. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/redirect-import.ts +651 -0
  59. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/sanity.config.ts +2 -0
  60. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/layout/redirect.ts +9 -6
  61. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/layout/structured-data.ts +8 -0
  62. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/metadata.ts +60 -6
  63. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/page.ts +22 -1
  64. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/richText.ts +0 -1
  65. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/structure.ts +16 -1
  66. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/tags.ts +6 -2
  67. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/scripts/sanity-typegen.ts +8 -11
  68. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/utils/metadata.ts +109 -136
  69. package/src/templates/next-default/.env.example +3 -1
  70. package/src/templates/next-default/app/layout.tsx +2 -1
  71. package/src/templates/next-default/app/robots.ts +9 -3
  72. package/src/templates/next-default/lib/utils/metadata.ts +2 -1
  73. package/src/templates/next-default/lib/utils/url.ts +20 -3
  74. package/src/templates/next-default/next.config.ts +16 -0
  75. package/src/templates/next-experiments/.env.example +3 -1
  76. package/src/templates/next-experiments/app/layout.tsx +2 -1
  77. package/src/templates/next-experiments/app/robots.ts +9 -3
  78. package/src/templates/next-experiments/lib/utils/metadata.ts +2 -1
  79. package/src/templates/next-experiments/lib/utils/url.ts +20 -3
  80. package/src/templates/next-experiments/next.config.ts +16 -0
  81. package/src/templates/next-pagebuilder/.env.example +3 -1
  82. package/src/templates/next-pagebuilder/.github/workflows/ci.yml +32 -8
  83. package/src/templates/next-pagebuilder/AGENTS.md +3 -0
  84. package/src/templates/next-pagebuilder/app/(content)/[[...slug]]/page.tsx +45 -7
  85. package/src/templates/next-pagebuilder/app/layout.tsx +3 -41
  86. package/src/templates/next-pagebuilder/app/robots.ts +9 -3
  87. package/src/templates/next-pagebuilder/biome.json +2 -1
  88. package/src/templates/next-pagebuilder/components/layout/footer/index.tsx +1 -1
  89. package/src/templates/next-pagebuilder/components/page-document/index.tsx +27 -35
  90. package/src/templates/next-pagebuilder/lefthook.yml +1 -1
  91. package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +32 -29
  92. package/src/templates/next-pagebuilder/lib/utils/base-url.ts +18 -3
  93. package/src/templates/next-pagebuilder/lib/utils/breadcrumbs.ts +70 -0
  94. package/src/templates/next-pagebuilder/next.config.ts +16 -0
  95. package/src/templates/next-pagebuilder/package.json +1 -1
  96. package/src/templates/next-pagebuilder-cache/.env.example +3 -1
  97. package/src/templates/next-pagebuilder-cache/.github/workflows/ci.yml +32 -8
  98. package/src/templates/next-pagebuilder-cache/AGENTS.md +3 -0
  99. package/src/templates/next-pagebuilder-cache/app/(content)/[[...slug]]/page.tsx +46 -7
  100. package/src/templates/next-pagebuilder-cache/app/layout.tsx +5 -46
  101. package/src/templates/next-pagebuilder-cache/app/robots.ts +9 -3
  102. package/src/templates/next-pagebuilder-cache/biome.json +2 -1
  103. package/src/templates/next-pagebuilder-cache/components/layout/footer/index.tsx +1 -1
  104. package/src/templates/next-pagebuilder-cache/components/page-document/index.tsx +27 -35
  105. package/src/templates/next-pagebuilder-cache/lefthook.yml +1 -1
  106. package/src/templates/next-pagebuilder-cache/lib/integrations/sanity/sanity.types.ts +32 -29
  107. package/src/templates/next-pagebuilder-cache/lib/utils/base-url.ts +18 -3
  108. package/src/templates/next-pagebuilder-cache/lib/utils/breadcrumbs.ts +70 -0
  109. package/src/templates/next-pagebuilder-cache/next.config.ts +16 -0
  110. package/src/templates/next-pagebuilder-cache/package.json +1 -1
  111. package/src/templates/next-webgl/.env.example +3 -1
  112. package/src/templates/next-webgl/app/layout.tsx +2 -1
  113. package/src/templates/next-webgl/app/robots.ts +9 -3
  114. package/src/templates/next-webgl/lib/utils/metadata.ts +2 -1
  115. package/src/templates/next-webgl/lib/utils/url.ts +20 -3
  116. package/src/templates/next-webgl/next.config.ts +16 -0
@@ -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,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)",
@@ -4,6 +4,7 @@ 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
9
  getPageBuilderBlockMember,
9
10
  getPageCategoryIcon,
@@ -25,6 +26,7 @@ type PageValidationDocument = {
25
26
  _key?: string;
26
27
  _type?: string;
27
28
  }>;
29
+ hiddenPageBuilderKeys?: string[];
28
30
  };
29
31
 
30
32
  type PageSlugValidationContext = {
@@ -101,6 +103,13 @@ export const page = defineType({
101
103
  type: "string",
102
104
  validation: (Rule) => Rule.required(),
103
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
+ }),
104
113
  defineField({
105
114
  name: "type",
106
115
  title: "Page Category",
@@ -149,7 +158,18 @@ export const page = defineType({
149
158
  title: "Page Builder",
150
159
  type: "pageBuilder",
151
160
  description: "Composable content blocks for this page",
152
- 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(),
153
173
  Rule.custom(async (value, context) => {
154
174
  const document = context.document as
155
175
  | PageValidationDocument
@@ -181,6 +201,7 @@ export const page = defineType({
181
201
 
182
202
  return `These page builder components do not belong to the "${pageType ?? "generic"}" category: ${invalidMemberTitles.join(", ")}`;
183
203
  }),
204
+ ],
184
205
  }),
185
206
  defineField({
186
207
  name: "collectionCategory",
@@ -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" },
@@ -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 {
@@ -12,6 +13,7 @@ import {
12
13
  } from "@/components/page-builder/library";
13
14
  import { pageBuilderInsertMenuGroups } from "@/lib/integrations/sanity/page-builder-config";
14
15
  import { ComponentPreviewPane } from "./components/component-preview-pane";
16
+ import { openRedirectImport } from "./components/redirect-import-dialog";
15
17
  import { apiVersion } from "./env";
16
18
  import {
17
19
  authorIcon,
@@ -241,6 +243,18 @@ function buildPageTreeComponent(
241
243
  .menuItems([...folderMenuItems, ...createMenuItems]);
242
244
  }
243
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
+
244
258
  // https://www.sanity.io/docs/structure-builder-cheat-sheet
245
259
  export const structure: StructureResolver = (S, context) =>
246
260
  S.list()
@@ -335,7 +349,8 @@ export const structure: StructureResolver = (S, context) =>
335
349
  S.divider(),
336
350
  S.documentTypeListItem("redirect")
337
351
  .title("Redirects")
338
- .icon(redirectIcon),
352
+ .icon(redirectIcon)
353
+ .child(redirectList(S)),
339
354
  ]),
340
355
  ),
341
356
  ]);
@@ -10,6 +10,7 @@ const SANITY_STRUCTURED_DATA_TAG = "structuredData";
10
10
  // Crawler routes: broader than any per-slug tag.
11
11
  export const SITEMAP_TAG = "sitemap";
12
12
  export const FEED_TAG = "feed";
13
+ export const BREADCRUMBS_TAG = "breadcrumbs";
13
14
 
14
15
  export const BLOG_COLLECTION_TAGS = [
15
16
  "blog-categories",
@@ -31,8 +32,9 @@ export const BLOG_COLLECTION_TYPES = new Set<string>([
31
32
  export const CROSS_DOC_TAG_DEPENDENCIES: Record<string, readonly string[]> = {
32
33
  // Redundant while LAYOUT_QUERY is merged; kept so splitting it can't lose this.
33
34
  companyData: [SANITY_STRUCTURED_DATA_TAG],
34
- // Nav links deref pages; sitemap and feed move whenever any page does.
35
- page: [SANITY_NAVBAR_TAG, SITEMAP_TAG, FEED_TAG],
35
+ // Nav links deref pages; sitemap, feed and breadcrumbs move whenever any
36
+ // page does.
37
+ page: [SANITY_NAVBAR_TAG, SITEMAP_TAG, FEED_TAG, BREADCRUMBS_TAG],
36
38
  // ...plus the collections, which decide membership on the folder slug, so a
37
39
  // rename changes which pages are articles at all.
38
40
  pageFolder: [
@@ -40,6 +42,7 @@ export const CROSS_DOC_TAG_DEPENDENCIES: Record<string, readonly string[]> = {
40
42
  ...BLOG_COLLECTION_TAGS,
41
43
  SITEMAP_TAG,
42
44
  FEED_TAG,
45
+ BREADCRUMBS_TAG,
43
46
  ],
44
47
  // The feed derefs the article's block, then its author and categories.
45
48
  blogContent: [FEED_TAG],
@@ -10,27 +10,24 @@ import { resolve } from "node:path";
10
10
  const sanityDir = resolve(import.meta.dir, "../integrations/sanity");
11
11
 
12
12
  if (!process.env.NEXT_PUBLIC_SANITY_PROJECT_ID) {
13
+ // Annotate CI runs: the generated-files check verifies nothing without a project.
13
14
  console.info(
14
- " Skipping sanity:typegen — NEXT_PUBLIC_SANITY_PROJECT_ID not set",
15
+ process.env.CI
16
+ ? "::warning::Skipping sanity:typegen — NEXT_PUBLIC_SANITY_PROJECT_ID not set, so committed Sanity types aren't verified"
17
+ : " Skipping sanity:typegen — NEXT_PUBLIC_SANITY_PROJECT_ID not set",
15
18
  );
16
19
  process.exit(0);
17
20
  }
18
21
 
19
- // Format the generated file to the project's biome style so the committed types
20
- // match lint — but only locally. In CI/Vercel the build discards the formatted
21
- // result, so running biome there is pure per-deploy waste. (Scoped to the one
22
- // file; linting the whole dir would rewrite unrelated source.)
23
- const format =
24
- process.env.CI || process.env.VERCEL
25
- ? ""
26
- : " && bun biome check --write --unsafe sanity.types.ts";
22
+ // No formatting pass: `sanity.types.ts` is in biome's ignore list, so `biome check` on it exits 1.
27
23
 
28
24
  try {
29
25
  // No `--env-file` — dotenvx already injected the env into process.env.
30
26
  execSync(
31
- `cd ${sanityDir} && bun sanity schema extract && bun sanity typegen generate${format}`,
27
+ `cd ${sanityDir} && bun sanity schema extract && bun sanity typegen generate`,
32
28
  { stdio: "inherit" },
33
29
  );
34
30
  } catch {
35
- console.warn(" sanity:typegen failed — continuing anyway");
31
+ console.error(" sanity:typegen failed");
32
+ process.exit(1);
36
33
  }
@@ -1,36 +1,13 @@
1
1
  import type { Metadata } from "next";
2
+ import { stegaClean } from "next-sanity";
2
3
  import { urlForImage } from "@/lib/integrations/sanity/utils/image";
3
4
  import { getBaseUrl } from "./base-url";
4
5
 
5
- interface GenerateMetadataOptions {
6
- title?: string | undefined;
7
- description?: string | undefined;
8
- keywords?: string[] | undefined;
9
- ogTitle?: string | undefined;
10
- ogDescription?: string | undefined;
11
- canonicalUrl?: string | undefined;
12
- image?:
13
- | {
14
- url?: string | undefined;
15
- width?: number | undefined;
16
- height?: number | undefined;
17
- }
18
- | undefined;
19
- url?: string | undefined;
20
- siteName?: string | undefined;
21
- index?: boolean | undefined;
22
- noIndex?: boolean | undefined;
23
- type?: "website" | "article";
24
- publishedTime?: string | undefined;
25
- modifiedTime?: string | undefined;
26
- authors?: string[] | undefined;
27
- }
28
-
29
6
  interface SanityMetadataImage {
30
7
  asset?: {
31
8
  _ref?: string;
32
9
  _type?: "reference";
33
- };
10
+ } | null;
34
11
  }
35
12
 
36
13
  interface SanityPageMetadata {
@@ -44,6 +21,7 @@ interface SanityPageMetadata {
44
21
  image?: SanityMetadataImage | null;
45
22
  } | null;
46
23
  index?: boolean | null;
24
+ follow?: boolean | null;
47
25
  title?: string | null;
48
26
  description?: string | null;
49
27
  image?: SanityMetadataImage | null;
@@ -52,138 +30,133 @@ interface SanityPageMetadata {
52
30
 
53
31
  interface SanityMetadataDocument {
54
32
  title?: string | null;
55
- subtitle?: string | null;
56
33
  metadata?: SanityPageMetadata | null;
57
34
  }
58
35
 
36
+ // The Structured Data singleton: its name is the og:site_name and its share
37
+ // image covers any page without an OG image of its own.
38
+ interface SiteMetadata {
39
+ name?: string | null;
40
+ shareImage?: SanityMetadataImage | null;
41
+ }
42
+
43
+ interface ArticleMetadata {
44
+ publishedTime?: string | null | undefined;
45
+ modifiedTime?: string | null | undefined;
46
+ authors?: readonly (string | null | undefined)[] | null | undefined;
47
+ }
48
+
59
49
  interface GenerateSanityMetadataOptions {
60
50
  document: SanityMetadataDocument;
61
51
  url?: string;
62
- siteName?: string;
63
- type?: "website" | "article";
52
+ site?: SiteMetadata | null | undefined;
53
+ fallbackDescription?: string | null | undefined;
54
+ fallbackImage?: SanityMetadataImage | null | undefined;
55
+ article?: ArticleMetadata | undefined;
64
56
  }
65
57
 
58
+ const OG_WIDTH = 1200;
59
+ const OG_HEIGHT = 630;
60
+ const DESCRIPTION_LENGTH = 160;
61
+
66
62
  const APP_BASE_URL = getBaseUrl();
67
63
 
68
- /**
69
- * Generate complete metadata object for pages
70
- *
71
- * @example
72
- * ```ts
73
- * export async function generateMetadata({ params }) {
74
- * const page = await fetchPage(params.slug)
75
- *
76
- * return generatePageMetadata({
77
- * title: page.metadata?.title || page.title,
78
- * description: page.metadata?.description,
79
- * image: { url: page.metadata?.image?.asset?.url },
80
- * url: `/page/${params.slug}`,
81
- * noIndex: page.metadata?.noIndex,
82
- * })
83
- * }
84
- * ```
85
- */
86
- const generatePageMetadata = (options: GenerateMetadataOptions): Metadata => {
87
- const {
88
- title,
89
- description,
90
- keywords,
91
- ogTitle,
92
- ogDescription,
93
- canonicalUrl,
94
- image,
95
- url,
96
- siteName = "Basement",
97
- index,
98
- noIndex = false,
99
- type = "website",
100
- publishedTime,
101
- modifiedTime,
102
- authors,
103
- } = options;
104
-
105
- const shouldIndex = index ?? !noIndex;
106
- const fullUrl = url ? `${APP_BASE_URL}${url}` : APP_BASE_URL;
107
- const imageUrl = image?.url || "/opengraph-image.jpg";
108
- const width = image?.width || 1200;
109
- const height = image?.height || 630;
110
-
111
- // Social title/description fall back to the SEO meta title/description.
112
- const socialTitle = ogTitle || title;
113
- const socialDescription = ogDescription || description;
114
-
115
- const metadata: Metadata = {
64
+ // A relative override is rooted, so "blog/post" can't resolve against the
65
+ // current path.
66
+ const resolveCanonical = (
67
+ override: string | null | undefined,
68
+ ownPath: string,
69
+ ) => {
70
+ const value = override?.trim();
71
+ if (!value) return ownPath;
72
+ if (/^https?:\/\//i.test(value)) return value;
73
+ return `/${value.replace(/^\/+/, "")}`;
74
+ };
75
+
76
+ // Trims body copy to a meta description, cut at a word boundary.
77
+ const summarize = (
78
+ text: string | null | undefined,
79
+ maxLength = DESCRIPTION_LENGTH,
80
+ ) => {
81
+ const clean = text?.replace(/\s+/g, " ").trim();
82
+ if (!clean) return undefined;
83
+ if (clean.length <= maxLength) return clean;
84
+ const cut = clean.slice(0, maxLength - 1);
85
+ const lastSpace = cut.lastIndexOf(" ");
86
+ return `${(lastSpace > 0 ? cut.slice(0, lastSpace) : cut).replace(/[\s,;:.–—-]+$/, "")}…`;
87
+ };
88
+
89
+ const socialImage = (source: SanityMetadataImage | null | undefined) =>
90
+ source?.asset
91
+ ? urlForImage(source).width(OG_WIDTH).height(OG_HEIGHT).url()
92
+ : undefined;
93
+
94
+ export const generateSanityMetadata = ({
95
+ document,
96
+ url = "/",
97
+ site,
98
+ fallbackDescription,
99
+ fallbackImage,
100
+ article,
101
+ }: GenerateSanityMetadataOptions): Metadata => {
102
+ const { name, shareImage } = stegaClean(site) ?? {};
103
+ const siteName = name?.trim() || undefined;
104
+
105
+ const seo = document.metadata;
106
+ const title = seo?.metaTitle || seo?.title || document.title || siteName;
107
+ const description =
108
+ seo?.metaDescription || seo?.description || summarize(fallbackDescription);
109
+ const socialTitle = seo?.ogTitle || title;
110
+ const socialDescription = seo?.ogDescription || description;
111
+ const imageUrl =
112
+ socialImage(seo?.image) ||
113
+ socialImage(seo?.og?.image) ||
114
+ socialImage(fallbackImage) ||
115
+ socialImage(shareImage);
116
+ const images = imageUrl
117
+ ? [{ url: imageUrl, width: OG_WIDTH, height: OG_HEIGHT }]
118
+ : undefined;
119
+
120
+ const canonical = resolveCanonical(seo?.canonicalUrl, url);
121
+
122
+ const index = seo?.index ?? (seo?.noIndex != null ? !seo.noIndex : true);
123
+ // Unset on pages that predate the toggle, which were `nofollow` whenever
124
+ // they were `noindex`.
125
+ const follow = seo?.follow ?? index;
126
+ const authors = article?.authors?.filter((author): author is string =>
127
+ Boolean(author),
128
+ );
129
+
130
+ return {
116
131
  metadataBase: new URL(APP_BASE_URL),
117
132
  title,
118
133
  description,
119
- keywords,
120
- alternates: { canonical: canonicalUrl || url || "/" },
134
+ keywords: seo?.keywords?.length ? seo.keywords : undefined,
135
+ alternates: { canonical },
121
136
  openGraph: {
137
+ title: socialTitle,
122
138
  description: socialDescription,
123
- url: fullUrl,
139
+ // Follows the canonical, so an override doesn't leave og:url pointing
140
+ // at the duplicate.
141
+ url: new URL(canonical, APP_BASE_URL).toString(),
124
142
  siteName,
125
143
  locale: "en_US",
126
- type,
127
- images: [{ url: imageUrl, width, height }],
128
- ...(socialTitle && { title: socialTitle }),
129
- ...(publishedTime && { publishedTime }),
130
- ...(modifiedTime && { modifiedTime }),
131
- ...(authors && { authors }),
144
+ images,
145
+ ...(article
146
+ ? {
147
+ type: "article",
148
+ publishedTime: article.publishedTime || undefined,
149
+ modifiedTime: article.modifiedTime || undefined,
150
+ authors: authors?.length ? authors : undefined,
151
+ }
152
+ : { type: "website" }),
132
153
  },
133
154
  twitter: {
134
155
  card: "summary_large_image",
156
+ title: socialTitle,
135
157
  description: socialDescription,
136
- images: [{ url: imageUrl, width, height }],
137
- ...(socialTitle && { title: socialTitle }),
158
+ images,
138
159
  },
139
- other: { "fb:app_id": process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || "" },
160
+ ...(index && follow ? {} : { robots: { index, follow } }),
140
161
  };
141
-
142
- if (!shouldIndex) metadata.robots = { index: false, follow: false };
143
-
144
- return metadata;
145
- };
146
-
147
- export const generateSanityMetadata = ({
148
- document,
149
- url,
150
- siteName,
151
- type = "website",
152
- }: GenerateSanityMetadataOptions): Metadata => {
153
- const pageMetadata = document.metadata;
154
- const title =
155
- pageMetadata?.metaTitle ||
156
- pageMetadata?.title ||
157
- document.title ||
158
- undefined;
159
- const description =
160
- pageMetadata?.metaDescription ||
161
- pageMetadata?.description ||
162
- document.subtitle ||
163
- undefined;
164
- const socialImageSource = pageMetadata?.image || pageMetadata?.og?.image;
165
- const imageUrl = socialImageSource
166
- ? urlForImage(socialImageSource).width(1200).height(630).url()
167
- : undefined;
168
- const index =
169
- pageMetadata?.index ??
170
- (pageMetadata?.noIndex != null ? !pageMetadata.noIndex : true);
171
-
172
- const keywords = pageMetadata?.keywords?.length
173
- ? pageMetadata.keywords
174
- : undefined;
175
-
176
- return generatePageMetadata({
177
- title,
178
- description,
179
- keywords,
180
- ogTitle: pageMetadata?.ogTitle || undefined,
181
- ogDescription: pageMetadata?.ogDescription || undefined,
182
- canonicalUrl: pageMetadata?.canonicalUrl || undefined,
183
- image: imageUrl ? { url: imageUrl, width: 1200, height: 630 } : undefined,
184
- url,
185
- siteName,
186
- index,
187
- type,
188
- });
189
162
  };
@@ -80,7 +80,9 @@ export const RichText = ({ content }: RichTextProps) => {
80
80
  externalLink: LinkMark,
81
81
  },
82
82
  block: {
83
- h1: ({ children }) => <h1 className="h1">{children}</h1>,
83
+ // The h1 style is retired: the page owns its H1, so legacy content
84
+ // drops a level.
85
+ h1: ({ children }) => <h2 className="h2">{children}</h2>,
84
86
  h2: ({ children }) => <h2 className="h2">{children}</h2>,
85
87
  h3: ({ children }) => <h3 className="h3">{children}</h3>,
86
88
  h4: ({ children }) => <h4 className="h4">{children}</h4>,