bsmnt 0.4.2 → 0.5.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 (188) hide show
  1. package/dist/application/add-integration/index.d.ts +24 -6
  2. package/dist/application/add-integration/index.d.ts.map +1 -1
  3. package/dist/application/add-integration/index.js +130 -82
  4. package/dist/application/add-integration/index.js.map +1 -1
  5. package/dist/application/create-project/map-selection-to-registry.js +1 -1
  6. package/dist/application/create-project/map-selection-to-registry.js.map +1 -1
  7. package/dist/application/shared/project-detection.d.ts +2 -2
  8. package/dist/application/shared/project-detection.d.ts.map +1 -1
  9. package/dist/application/shared/project-detection.js +2 -2
  10. package/dist/application/shared/project-detection.js.map +1 -1
  11. package/dist/core/create/internal-ids.d.ts +4 -1
  12. package/dist/core/create/internal-ids.d.ts.map +1 -1
  13. package/dist/core/create/internal-ids.js +6 -1
  14. package/dist/core/create/internal-ids.js.map +1 -1
  15. package/dist/domain/cms.d.ts +0 -7
  16. package/dist/domain/cms.d.ts.map +1 -1
  17. package/dist/domain/cms.js.map +1 -1
  18. package/dist/domain/forms.d.ts +17 -0
  19. package/dist/domain/forms.d.ts.map +1 -0
  20. package/dist/domain/forms.js +13 -0
  21. package/dist/domain/forms.js.map +1 -0
  22. package/dist/domain/integration.d.ts +30 -0
  23. package/dist/domain/integration.d.ts.map +1 -0
  24. package/dist/domain/integration.js +12 -0
  25. package/dist/domain/integration.js.map +1 -0
  26. package/dist/domain/skills.d.ts +32 -0
  27. package/dist/domain/skills.d.ts.map +1 -1
  28. package/dist/domain/skills.js +39 -0
  29. package/dist/domain/skills.js.map +1 -1
  30. package/dist/index.js +95 -37
  31. package/dist/index.js.map +1 -1
  32. package/dist/infrastructure/agents/register-sanity-mcp.js +9 -6
  33. package/dist/infrastructure/agents/register-sanity-mcp.js.map +1 -1
  34. package/dist/infrastructure/create/apply-package-json.d.ts.map +1 -1
  35. package/dist/infrastructure/create/apply-package-json.js +19 -0
  36. package/dist/infrastructure/create/apply-package-json.js.map +1 -1
  37. package/dist/infrastructure/create/install-dependencies.d.ts.map +1 -1
  38. package/dist/infrastructure/create/install-dependencies.js +4 -0
  39. package/dist/infrastructure/create/install-dependencies.js.map +1 -1
  40. package/dist/infrastructure/create/merge-registry.d.ts.map +1 -1
  41. package/dist/infrastructure/create/merge-registry.js +8 -0
  42. package/dist/infrastructure/create/merge-registry.js.map +1 -1
  43. package/dist/infrastructure/create/setup-agent.d.ts.map +1 -1
  44. package/dist/infrastructure/create/setup-agent.js +29 -6
  45. package/dist/infrastructure/create/setup-agent.js.map +1 -1
  46. package/dist/infrastructure/create/setup-sanity.d.ts.map +1 -1
  47. package/dist/infrastructure/create/setup-sanity.js +3 -0
  48. package/dist/infrastructure/create/setup-sanity.js.map +1 -1
  49. package/dist/infrastructure/integrations/inject-integration.d.ts +3 -3
  50. package/dist/infrastructure/integrations/inject-integration.d.ts.map +1 -1
  51. package/dist/infrastructure/integrations/inject-integration.js +81 -37
  52. package/dist/infrastructure/integrations/inject-integration.js.map +1 -1
  53. package/dist/modules/features/cms/sanity/config.d.ts.map +1 -1
  54. package/dist/modules/features/cms/sanity/config.js +11 -5
  55. package/dist/modules/features/cms/sanity/config.js.map +1 -1
  56. package/dist/modules/features/cms/sanity/index.js +8 -2
  57. package/dist/modules/features/cms/sanity/index.js.map +1 -1
  58. package/dist/modules/features/cms/sanity/mergers/ci-workflow.d.ts +13 -0
  59. package/dist/modules/features/cms/sanity/mergers/ci-workflow.d.ts.map +1 -0
  60. package/dist/modules/features/cms/sanity/mergers/ci-workflow.js +38 -0
  61. package/dist/modules/features/cms/sanity/mergers/ci-workflow.js.map +1 -0
  62. package/dist/modules/features/cms/sanity/mergers/knip.d.ts +12 -0
  63. package/dist/modules/features/cms/sanity/mergers/knip.d.ts.map +1 -0
  64. package/dist/modules/features/cms/sanity/mergers/knip.js +38 -0
  65. package/dist/modules/features/cms/sanity/mergers/knip.js.map +1 -0
  66. package/dist/modules/features/cms/sanity/mergers/layout.d.ts.map +1 -1
  67. package/dist/modules/features/cms/sanity/mergers/layout.js +7 -3
  68. package/dist/modules/features/cms/sanity/mergers/layout.js.map +1 -1
  69. package/dist/modules/features/cms/sanity/mergers/sitemap.d.ts +3 -3
  70. package/dist/modules/features/cms/sanity/mergers/sitemap.d.ts.map +1 -1
  71. package/dist/modules/features/cms/sanity/mergers/sitemap.js +48 -62
  72. package/dist/modules/features/cms/sanity/mergers/sitemap.js.map +1 -1
  73. package/dist/modules/features/cms/sanity-pagebuilder/config.d.ts.map +1 -1
  74. package/dist/modules/features/cms/sanity-pagebuilder/config.js +8 -5
  75. package/dist/modules/features/cms/sanity-pagebuilder/config.js.map +1 -1
  76. package/dist/modules/features/cms/sanity-pagebuilder/index.js +2 -2
  77. package/dist/modules/features/cms/sanity-pagebuilder/index.js.map +1 -1
  78. package/dist/modules/features/forms/hubspot/config.d.ts +31 -0
  79. package/dist/modules/features/forms/hubspot/config.d.ts.map +1 -0
  80. package/dist/modules/features/forms/hubspot/config.js +25 -0
  81. package/dist/modules/features/forms/hubspot/config.js.map +1 -0
  82. package/dist/paths.d.ts +8 -0
  83. package/dist/paths.d.ts.map +1 -1
  84. package/dist/paths.js +8 -0
  85. package/dist/paths.js.map +1 -1
  86. package/index.js +2 -2
  87. package/package.json +17 -9
  88. package/src/assets/check-node-version.mjs +22 -0
  89. package/src/modules/features/cms/sanity/files/app/blog/page.tsx +54 -0
  90. package/src/modules/features/cms/sanity/files/app/feed.xml/route.ts +36 -0
  91. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/components/disable-draft-mode.tsx +3 -3
  92. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/schemas/article.ts +7 -7
  93. package/src/modules/features/cms/sanity/files/lib/scripts/setup-ci-secrets.ts +117 -0
  94. package/src/modules/features/cms/sanity/files/lib/utils/rss.ts +96 -0
  95. package/src/modules/features/cms/sanity-pagebuilder/files/.knip.json +8 -0
  96. package/src/modules/features/cms/sanity-pagebuilder/files/app/api/[[...slug]]/route.ts +100 -0
  97. package/src/modules/features/cms/sanity-pagebuilder/files/app/api/draft-mode/disable/route.ts +7 -0
  98. package/src/modules/features/cms/sanity-pagebuilder/files/app/api/draft-mode/enable/route.ts +20 -0
  99. package/src/modules/features/cms/sanity-pagebuilder/files/app/api/revalidate/route.ts +126 -0
  100. package/src/modules/features/cms/sanity-pagebuilder/files/app/feed.xml/route.ts +40 -0
  101. package/src/modules/features/cms/sanity-pagebuilder/files/app/sitemap.md/route.ts +124 -0
  102. package/src/modules/features/cms/sanity-pagebuilder/files/app/studio/[[...tool]]/page.tsx +8 -0
  103. package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/draft-mode-toggle.tsx +27 -0
  104. package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/rich-text.tsx +87 -0
  105. package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/visual-editing.tsx +28 -0
  106. package/src/modules/features/cms/sanity-pagebuilder/files/components/ui/sanity-image/index.tsx +41 -0
  107. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/check-integration.ts +5 -0
  108. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/client.ts +27 -0
  109. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/disable-draft-mode.tsx +23 -0
  110. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-builder-input.tsx +39 -0
  111. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-category-input.tsx +50 -0
  112. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/slug-field.tsx +18 -0
  113. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/slug-input.tsx +14 -0
  114. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/confirm-publish-action.ts +40 -0
  115. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/env.ts +34 -0
  116. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/fetchers/layout.ts +48 -0
  117. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/fetchers/redirects.ts +17 -0
  118. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/icons.ts +62 -0
  119. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/live/index.tsx +88 -0
  120. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/markdown-proxy.config.ts +50 -0
  121. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +130 -0
  122. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-category.ts +28 -0
  123. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-tree-pane.tsx +420 -0
  124. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/pinned-pages.ts +54 -0
  125. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/presentation.ts +119 -0
  126. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +385 -0
  127. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/sanity.cli.ts +29 -0
  128. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/sanity.config.ts +113 -0
  129. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/index.ts +4 -0
  130. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/blog-content.ts +89 -0
  131. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/description.ts +29 -0
  132. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/hero.ts +28 -0
  133. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/singleton/content-collection.ts +45 -0
  134. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/content/author.ts +70 -0
  135. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/content/blog-category.ts +55 -0
  136. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts +96 -0
  137. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/company-data.ts +62 -0
  138. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/footer.ts +79 -0
  139. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/navbar.ts +75 -0
  140. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/redirect.ts +120 -0
  141. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/link.ts +125 -0
  142. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/logo-field.ts +9 -0
  143. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/metadata.ts +68 -0
  144. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/nav-objects.ts +192 -0
  145. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page-builder.ts +39 -0
  146. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page-folder.ts +124 -0
  147. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page.ts +242 -0
  148. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/richText.ts +63 -0
  149. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/singletons.ts +39 -0
  150. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +330 -0
  151. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/utils/image.ts +8 -0
  152. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/utils/link.ts +140 -0
  153. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/utils/page-builder-markdown.ts +81 -0
  154. package/src/modules/features/cms/sanity-pagebuilder/files/lib/scripts/sanity-typegen.ts +45 -0
  155. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/check-redirect.ts +30 -0
  156. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/json-ld.tsx +212 -0
  157. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/metadata.ts +167 -0
  158. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/rss.ts +96 -0
  159. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/sitemap.ts +40 -0
  160. package/src/modules/features/cms/sanity-pagebuilder/files/proxy.ts +81 -0
  161. package/src/templates/next-default/.github/workflows/ci.yml +62 -0
  162. package/src/templates/next-default/README.md +22 -0
  163. package/src/templates/next-default/knip.json +14 -0
  164. package/src/templates/next-default/lefthook.yml +5 -0
  165. package/src/templates/next-default/package.json +8 -4
  166. package/src/templates/next-experiments/.github/workflows/ci.yml +62 -0
  167. package/src/templates/next-experiments/README.md +22 -0
  168. package/src/templates/next-experiments/knip.json +14 -0
  169. package/src/templates/next-experiments/lefthook.yml +30 -0
  170. package/src/templates/next-experiments/package.json +10 -2
  171. package/src/templates/next-pagebuilder/.github/workflows/ci.yml +70 -0
  172. package/src/templates/next-pagebuilder/README.md +34 -0
  173. package/src/templates/next-pagebuilder/app/(content)/[[...slug]]/page.tsx +6 -1
  174. package/src/templates/next-pagebuilder/app/(content)/layout.tsx +3 -12
  175. package/src/templates/next-pagebuilder/components/layout/footer/index.tsx +11 -10
  176. package/src/templates/next-pagebuilder/components/layout/header/index.tsx +5 -3
  177. package/src/templates/next-pagebuilder/components/layout/json-ld/index.tsx +9 -10
  178. package/src/templates/next-pagebuilder/components/layout/wrapper/index.tsx +3 -11
  179. package/src/templates/next-pagebuilder/components/page-builder/components/content-collection/index.tsx +1 -1
  180. package/src/templates/next-pagebuilder/knip.json +27 -0
  181. package/src/templates/next-pagebuilder/lefthook.yml +5 -0
  182. package/src/templates/next-pagebuilder/lib/scripts/setup-ci-secrets.ts +117 -0
  183. package/src/templates/next-pagebuilder/package.json +9 -3
  184. package/src/templates/next-webgl/.github/workflows/ci.yml +62 -0
  185. package/src/templates/next-webgl/README.md +22 -0
  186. package/src/templates/next-webgl/knip.json +13 -0
  187. package/src/templates/next-webgl/lefthook.yml +5 -0
  188. package/src/templates/next-webgl/package.json +8 -4
@@ -0,0 +1,140 @@
1
+ import { getPinnedPageById } from "@/lib/integrations/sanity/pinned-pages";
2
+
3
+ type PageReference = {
4
+ _id?: string;
5
+ _ref?: string;
6
+ _type?: string;
7
+ slug?: { current?: string | null } | null;
8
+ resolvedSlug?: string | null;
9
+ title?: string | null;
10
+ };
11
+
12
+ type PageReferenceLink = {
13
+ _type: "pageReference";
14
+ page?: PageReference | null;
15
+ text?: string | null;
16
+ openInNewTab?: boolean | null;
17
+ };
18
+
19
+ type ExternalLinkItem = {
20
+ _type: "externalLink";
21
+ href?: string | null;
22
+ text?: string | null;
23
+ openInNewTab?: boolean | null;
24
+ };
25
+
26
+ type LinkItem = PageReferenceLink | ExternalLinkItem;
27
+
28
+ // Backward-compatible shape (old data without _type)
29
+ type LegacyLink = {
30
+ page?: PageReference | null;
31
+ href?: string | null;
32
+ text?: string | null;
33
+ openInNewTab?: boolean | null;
34
+ };
35
+
36
+ type LinkLike = LinkItem | LegacyLink;
37
+
38
+ type InternalReference = {
39
+ _id?: string;
40
+ _type: string;
41
+ slug?: { current?: string | null } | null;
42
+ resolvedSlug?: string | null;
43
+ title?: string;
44
+ };
45
+
46
+ /**
47
+ * Resolves a link object to a URL string.
48
+ *
49
+ * Supports both the new discriminated union shape (_type: "pageReference" | "externalLink")
50
+ * and the legacy flat shape (page + href without _type).
51
+ */
52
+ export const urlForReference = (link: LinkLike | null): string => {
53
+ if (!link) return "#";
54
+
55
+ // New discriminated union
56
+ if ("_type" in link && link._type === "pageReference") {
57
+ if (
58
+ link.page &&
59
+ typeof link.page === "object" &&
60
+ ("slug" in link.page || "_id" in link.page)
61
+ ) {
62
+ const ref = link.page as InternalReference;
63
+ return resolveDocumentUrl(
64
+ ref._type,
65
+ ref.resolvedSlug ?? undefined,
66
+ ref.slug?.current ?? undefined,
67
+ ref._id,
68
+ );
69
+ }
70
+ return "#";
71
+ }
72
+
73
+ if ("_type" in link && link._type === "externalLink") {
74
+ return link.href || "#";
75
+ }
76
+
77
+ // Legacy fallback (flat shape without _type)
78
+ if (
79
+ "page" in link &&
80
+ link.page &&
81
+ typeof link.page === "object" &&
82
+ ("slug" in link.page || "_id" in link.page)
83
+ ) {
84
+ const ref = link.page as InternalReference;
85
+ return resolveDocumentUrl(
86
+ ref._type,
87
+ ref.resolvedSlug ?? undefined,
88
+ ref.slug?.current ?? undefined,
89
+ ref._id,
90
+ );
91
+ }
92
+
93
+ if ("href" in link && link.href) {
94
+ return link.href;
95
+ }
96
+
97
+ return "#";
98
+ };
99
+
100
+ function resolveDocumentUrl(
101
+ documentType: string,
102
+ resolvedSlug?: string,
103
+ slug?: string,
104
+ documentId?: string,
105
+ ): string {
106
+ if (documentType === "page") {
107
+ const pinned = getPinnedPageById(documentId);
108
+ if (pinned) return pinned.path;
109
+ if (resolvedSlug) return `/${resolvedSlug}`;
110
+ if (slug) return `/${slug}`;
111
+ return "#";
112
+ }
113
+
114
+ if (!slug) return "#";
115
+
116
+ console.warn("Unknown document type for URL resolution:", documentType);
117
+ return "#";
118
+ }
119
+
120
+ /**
121
+ * Returns href, target, and rel attributes for a link object.
122
+ *
123
+ * External detection: externalLink type, or URLs starting with "http".
124
+ */
125
+ export const getLinkAttributes = (link: LinkLike | null) => {
126
+ if (!link) return { href: "#", target: undefined, rel: undefined };
127
+
128
+ const href = urlForReference(link);
129
+ const isExternal =
130
+ ("_type" in link && link._type === "externalLink") ||
131
+ href.startsWith("http");
132
+
133
+ const shouldOpenNewTab = link.openInNewTab || isExternal;
134
+
135
+ return {
136
+ href,
137
+ target: shouldOpenNewTab ? "_blank" : undefined,
138
+ rel: shouldOpenNewTab ? "noopener noreferrer" : undefined,
139
+ };
140
+ };
@@ -0,0 +1,81 @@
1
+ import { portableTextToMarkdown } from "@portabletext/markdown";
2
+ import type { PAGE_QUERY_RESULT } from "../sanity.types";
3
+
4
+ const markdownOptions = {
5
+ types: {
6
+ table: ({ value }: { value: { rows?: Array<{ cells?: string[] }> } }) => {
7
+ const rows = value?.rows;
8
+ if (!rows?.length) return "";
9
+ return rows
10
+ .map((row) => `| ${(row.cells ?? []).join(" | ")} |`)
11
+ .join("\n");
12
+ },
13
+ },
14
+ } satisfies Parameters<typeof portableTextToMarkdown>[1];
15
+
16
+ type PageBuilderBlock = NonNullable<
17
+ NonNullable<PAGE_QUERY_RESULT>["pageBuilder"]
18
+ >[number];
19
+
20
+ export const renderPageBuilderMarkdown = (
21
+ blocks: PageBuilderBlock[] | null | undefined,
22
+ ) => {
23
+ if (!blocks?.length) return "";
24
+
25
+ return blocks
26
+ .flatMap((block) => {
27
+ switch (block._type) {
28
+ case "hero":
29
+ return block.headline ? [`## ${block.headline}`, ""] : [];
30
+ case "description":
31
+ return block.description ? [block.description, ""] : [];
32
+ case "blogContent": {
33
+ const lines: (string | null)[] = [];
34
+ const categories =
35
+ "categories" in block ? block.categories : undefined;
36
+ const relatedPosts =
37
+ "relatedPosts" in block ? block.relatedPosts : undefined;
38
+
39
+ if (categories?.length) {
40
+ lines.push(
41
+ categories
42
+ .map((c) => c.title)
43
+ .filter(Boolean)
44
+ .join(", "),
45
+ );
46
+ }
47
+ if (block.author?.name) {
48
+ const authorParts = [block.author.name, block.author.role]
49
+ .filter(Boolean)
50
+ .join(", ");
51
+ lines.push(`**Author:** ${authorParts}`);
52
+ }
53
+ if (block.date) {
54
+ lines.push(`**Date:** ${block.date}`);
55
+ }
56
+ lines.push("");
57
+ if (block.body) {
58
+ lines.push(
59
+ portableTextToMarkdown(
60
+ block.body as Parameters<typeof portableTextToMarkdown>[0],
61
+ markdownOptions,
62
+ ),
63
+ );
64
+ }
65
+ if (relatedPosts?.length) {
66
+ lines.push("", "## Related Articles", "");
67
+ for (const post of relatedPosts) {
68
+ if (post.title && post.resolvedSlug) {
69
+ lines.push(`- [${post.title}](/${post.resolvedSlug}.md)`);
70
+ }
71
+ }
72
+ }
73
+ lines.push("");
74
+ return lines.filter((l) => l !== null);
75
+ }
76
+ default:
77
+ return [];
78
+ }
79
+ })
80
+ .join("\n");
81
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Wrapper around `sanity schema extract && sanity typegen generate`
3
+ * that gracefully skips when Sanity is not configured.
4
+ */
5
+ import { execSync } from "node:child_process";
6
+ import { existsSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+
9
+ const sanityDir = resolve(import.meta.dir, "../integrations/sanity");
10
+ const envFile = resolve(import.meta.dir, "../../.env.local");
11
+
12
+ // Check if Sanity project ID is configured
13
+ const hasEnvFile = existsSync(envFile);
14
+
15
+ if (!hasEnvFile) {
16
+ console.log(" Skipping sanity:typegen — .env.local not found");
17
+ process.exit(0);
18
+ }
19
+
20
+ // Read .env.local to check for project ID
21
+ const envContent = Bun.file(envFile);
22
+ const text = await envContent.text();
23
+ const hasProjectId = text
24
+ .split("\n")
25
+ .some(
26
+ (line) =>
27
+ line.startsWith("NEXT_PUBLIC_SANITY_PROJECT_ID=") &&
28
+ line.split("=")[1]?.trim() !== "",
29
+ );
30
+
31
+ if (!hasProjectId) {
32
+ console.log(
33
+ " Skipping sanity:typegen — NEXT_PUBLIC_SANITY_PROJECT_ID not set",
34
+ );
35
+ process.exit(0);
36
+ }
37
+
38
+ try {
39
+ execSync(
40
+ `cd ${sanityDir} && bun --env-file ../../../.env.local sanity schema extract && bun --env-file ../../../.env.local sanity typegen generate && bun biome check --write --unsafe`,
41
+ { stdio: "inherit" },
42
+ );
43
+ } catch {
44
+ console.warn(" sanity:typegen failed — continuing anyway");
45
+ }
@@ -0,0 +1,30 @@
1
+ import { stegaClean } from "@sanity/client/stega";
2
+ import type { Route } from "next";
3
+ import { permanentRedirect, redirect } from "next/navigation";
4
+ import { getRedirects } from "@/lib/integrations/sanity/fetchers/redirects";
5
+
6
+ const normalize = (path: string) =>
7
+ path.replace(/^\/+/, "").replace(/\/+$/, "");
8
+
9
+ /** Redirect (308 permanent / 307 temporary) if `path` matches a redirect source. */
10
+ export const checkRedirect = async (path: string | null): Promise<void> => {
11
+ const redirects = await getRedirects();
12
+ if (redirects.length === 0) return;
13
+
14
+ const source = normalize(path ?? "");
15
+ const match = redirects.find(
16
+ (entry) =>
17
+ entry.source && normalize(stegaClean(entry.source) ?? "") === source,
18
+ );
19
+ if (!match?.destination) return;
20
+
21
+ const destination = stegaClean(match.destination) ?? "";
22
+ const href = (
23
+ /^https?:\/\//.test(destination)
24
+ ? destination
25
+ : `/${normalize(destination)}`
26
+ ) as Route;
27
+
28
+ if (match.permanent === false) redirect(href);
29
+ permanentRedirect(href);
30
+ };
@@ -0,0 +1,212 @@
1
+ import type {
2
+ Article,
3
+ BreadcrumbList,
4
+ Organization,
5
+ Thing,
6
+ WebPage,
7
+ WebSite,
8
+ WithContext,
9
+ } from "schema-dts";
10
+ import { getBaseUrl } from "./base-url";
11
+
12
+ const BASE_URL = getBaseUrl();
13
+
14
+ interface JsonLdProps<T extends Thing> {
15
+ data: WithContext<T>;
16
+ }
17
+
18
+ const serializeJsonLd = <T extends Thing>(data: WithContext<T>) =>
19
+ JSON.stringify(data).replace(/</g, "\\u003c");
20
+
21
+ export const JsonLd = <T extends Thing>({ data }: JsonLdProps<T>) => (
22
+ <script
23
+ type="application/ld+json"
24
+ dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
25
+ />
26
+ );
27
+
28
+ interface WebSiteJsonLdOptions {
29
+ name: string;
30
+ url?: string;
31
+ description?: string;
32
+ }
33
+
34
+ export const generateWebSiteJsonLd = (
35
+ options: WebSiteJsonLdOptions,
36
+ ): WithContext<WebSite> => {
37
+ const { name, url = BASE_URL, description } = options;
38
+ return {
39
+ "@context": "https://schema.org",
40
+ "@type": "WebSite",
41
+ name,
42
+ ...(url ? { url } : {}),
43
+ ...(description && { description }),
44
+ };
45
+ };
46
+
47
+ interface OrganizationJsonLdOptions {
48
+ name: string;
49
+ url?: string;
50
+ logo?: string;
51
+ description?: string;
52
+ sameAs?: string[];
53
+ }
54
+
55
+ export const generateOrganizationJsonLd = (
56
+ options: OrganizationJsonLdOptions,
57
+ ): WithContext<Organization> => {
58
+ const { name, url = BASE_URL, logo, description, sameAs } = options;
59
+
60
+ return {
61
+ "@context": "https://schema.org",
62
+ "@type": "Organization",
63
+ name,
64
+ ...(url ? { url } : {}),
65
+ ...(logo && { logo: `${BASE_URL}${logo}` }),
66
+ ...(description && { description }),
67
+ ...(sameAs && { sameAs }),
68
+ };
69
+ };
70
+
71
+ interface WebPageJsonLdOptions {
72
+ title: string;
73
+ url?: string;
74
+ description?: string;
75
+ image?: string;
76
+ datePublished?: string;
77
+ dateModified?: string;
78
+ }
79
+
80
+ export const generateWebPageJsonLd = (
81
+ options: WebPageJsonLdOptions,
82
+ ): WithContext<WebPage> => {
83
+ const { title, url, description, image, datePublished, dateModified } =
84
+ options;
85
+ const fullUrl = url ? `${BASE_URL}${url}` : BASE_URL;
86
+
87
+ return {
88
+ "@context": "https://schema.org",
89
+ "@type": "WebPage",
90
+ name: title,
91
+ ...(fullUrl ? { url: fullUrl } : {}),
92
+ ...(description && { description }),
93
+ ...(image && { image }),
94
+ ...(datePublished && { datePublished }),
95
+ ...(dateModified && { dateModified }),
96
+ };
97
+ };
98
+
99
+ interface ArticleJsonLdOptions {
100
+ title: string;
101
+ url?: string;
102
+ description?: string;
103
+ image?: string;
104
+ datePublished?: string;
105
+ dateModified?: string;
106
+ authorName?: string;
107
+ authorUrl?: string;
108
+ }
109
+
110
+ export function generateArticleJsonLd(
111
+ options: ArticleJsonLdOptions,
112
+ ): WithContext<Article> {
113
+ const {
114
+ title,
115
+ url,
116
+ description,
117
+ image,
118
+ datePublished,
119
+ dateModified,
120
+ authorName,
121
+ authorUrl,
122
+ } = options;
123
+ const fullUrl = url ? `${BASE_URL}${url}` : BASE_URL;
124
+ return {
125
+ "@context": "https://schema.org",
126
+ "@type": "Article",
127
+ headline: title,
128
+ ...(fullUrl ? { url: fullUrl } : {}),
129
+ ...(description && { description }),
130
+ ...(image && { image }),
131
+ ...(datePublished && { datePublished }),
132
+ ...(dateModified && { dateModified }),
133
+ ...(authorName && {
134
+ author: {
135
+ "@type": "Person",
136
+ name: authorName,
137
+ ...(authorUrl && { url: authorUrl }),
138
+ },
139
+ }),
140
+ };
141
+ }
142
+
143
+ interface BreadcrumbItem {
144
+ name: string;
145
+ url: string;
146
+ }
147
+
148
+ export function generateBreadcrumbJsonLd(
149
+ items: BreadcrumbItem[],
150
+ ): WithContext<BreadcrumbList> {
151
+ return {
152
+ "@context": "https://schema.org",
153
+ "@type": "BreadcrumbList",
154
+ itemListElement: items.map((item, index) => ({
155
+ "@type": "ListItem" as const,
156
+ position: index + 1,
157
+ name: item.name,
158
+ item: `${BASE_URL}${item.url}`,
159
+ })),
160
+ };
161
+ }
162
+
163
+ /* ——————————————————————————————————————————————————————————
164
+ * Sanity helpers
165
+ *
166
+ * Accept flat options instead of a document shape so they
167
+ * work with any Sanity type without interface mismatches.
168
+ * —————————————————————————————————————————————————————————— */
169
+
170
+ interface SanityArticleJsonLdOptions {
171
+ title: string;
172
+ url: string;
173
+ description?: string | null | undefined;
174
+ image?: string | null | undefined;
175
+ datePublished?: string | null | undefined;
176
+ dateModified?: string;
177
+ authorName?: string | null | undefined;
178
+ authorUrl?: string | null | undefined;
179
+ }
180
+
181
+ export function generateSanityArticleJsonLd(
182
+ options: SanityArticleJsonLdOptions,
183
+ ): WithContext<Article> {
184
+ const opts: ArticleJsonLdOptions = { title: options.title, url: options.url };
185
+ if (options.description) opts.description = options.description;
186
+ if (options.image) opts.image = options.image;
187
+ if (options.datePublished) opts.datePublished = options.datePublished;
188
+ if (options.dateModified) opts.dateModified = options.dateModified;
189
+ if (options.authorName) opts.authorName = options.authorName;
190
+ if (options.authorUrl) opts.authorUrl = options.authorUrl;
191
+ return generateArticleJsonLd(opts);
192
+ }
193
+
194
+ interface SanityWebPageJsonLdOptions {
195
+ title: string;
196
+ url: string;
197
+ description?: string | null | undefined;
198
+ image?: string | null | undefined;
199
+ datePublished?: string | null | undefined;
200
+ dateModified?: string;
201
+ }
202
+
203
+ export function generateSanityWebPageJsonLd(
204
+ options: SanityWebPageJsonLdOptions,
205
+ ): WithContext<WebPage> {
206
+ const opts: WebPageJsonLdOptions = { title: options.title, url: options.url };
207
+ if (options.description) opts.description = options.description;
208
+ if (options.image) opts.image = options.image;
209
+ if (options.datePublished) opts.datePublished = options.datePublished;
210
+ if (options.dateModified) opts.dateModified = options.dateModified;
211
+ return generateWebPageJsonLd(opts);
212
+ }
@@ -0,0 +1,167 @@
1
+ import type { Metadata } from "next";
2
+ import { urlForImage } from "@/lib/integrations/sanity/utils/image";
3
+ import { getBaseUrl } from "./base-url";
4
+
5
+ interface GenerateMetadataOptions {
6
+ title?: string | undefined;
7
+ description?: string | undefined;
8
+ keywords?: string[] | undefined;
9
+ image?:
10
+ | {
11
+ url?: string | undefined;
12
+ width?: number | undefined;
13
+ height?: number | undefined;
14
+ }
15
+ | undefined;
16
+ url?: string | undefined;
17
+ siteName?: string | undefined;
18
+ index?: boolean | undefined;
19
+ noIndex?: boolean | undefined;
20
+ type?: "website" | "article";
21
+ publishedTime?: string | undefined;
22
+ modifiedTime?: string | undefined;
23
+ authors?: string[] | undefined;
24
+ }
25
+
26
+ interface SanityMetadataImage {
27
+ asset?: {
28
+ _ref?: string;
29
+ _type?: "reference";
30
+ };
31
+ }
32
+
33
+ interface SanityPageMetadata {
34
+ metaTitle?: string | null;
35
+ metaDescription?: string | null;
36
+ og?: {
37
+ image?: SanityMetadataImage | null;
38
+ } | null;
39
+ index?: boolean | null;
40
+ title?: string | null;
41
+ description?: string | null;
42
+ image?: SanityMetadataImage | null;
43
+ noIndex?: boolean | null;
44
+ }
45
+
46
+ interface SanityMetadataDocument {
47
+ title?: string | null;
48
+ subtitle?: string | null;
49
+ metadata?: SanityPageMetadata | null;
50
+ }
51
+
52
+ interface GenerateSanityMetadataOptions {
53
+ document: SanityMetadataDocument;
54
+ url?: string;
55
+ siteName?: string;
56
+ type?: "website" | "article";
57
+ }
58
+
59
+ const APP_BASE_URL = getBaseUrl();
60
+
61
+ /**
62
+ * Generate complete metadata object for pages
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * export async function generateMetadata({ params }) {
67
+ * const page = await fetchPage(params.slug)
68
+ *
69
+ * return generatePageMetadata({
70
+ * title: page.metadata?.title || page.title,
71
+ * description: page.metadata?.description,
72
+ * image: { url: page.metadata?.image?.asset?.url },
73
+ * url: `/page/${params.slug}`,
74
+ * noIndex: page.metadata?.noIndex,
75
+ * })
76
+ * }
77
+ * ```
78
+ */
79
+ const generatePageMetadata = (options: GenerateMetadataOptions): Metadata => {
80
+ const {
81
+ title,
82
+ description,
83
+ keywords,
84
+ image,
85
+ url,
86
+ siteName = "Basement",
87
+ index,
88
+ noIndex = false,
89
+ type = "website",
90
+ publishedTime,
91
+ modifiedTime,
92
+ authors,
93
+ } = options;
94
+
95
+ const shouldIndex = index ?? !noIndex;
96
+ const fullUrl = url ? `${APP_BASE_URL}${url}` : APP_BASE_URL;
97
+ const imageUrl = image?.url || "/opengraph-image.jpg";
98
+ const width = image?.width || 1200;
99
+ const height = image?.height || 630;
100
+
101
+ const metadata: Metadata = {
102
+ metadataBase: new URL(APP_BASE_URL),
103
+ title,
104
+ description,
105
+ keywords,
106
+ alternates: { canonical: url || "/" },
107
+ openGraph: {
108
+ title,
109
+ description,
110
+ url: fullUrl,
111
+ siteName,
112
+ locale: "en_US",
113
+ type,
114
+ images: [{ url: imageUrl, width, height }],
115
+ ...(publishedTime && { publishedTime }),
116
+ ...(modifiedTime && { modifiedTime }),
117
+ ...(authors && { authors }),
118
+ },
119
+ twitter: {
120
+ card: "summary_large_image",
121
+ title,
122
+ description,
123
+ images: [{ url: imageUrl, width, height }],
124
+ },
125
+ other: { "fb:app_id": process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || "" },
126
+ };
127
+
128
+ if (!shouldIndex) metadata.robots = { index: false, follow: false };
129
+
130
+ return metadata;
131
+ };
132
+
133
+ export const generateSanityMetadata = ({
134
+ document,
135
+ url,
136
+ siteName,
137
+ type = "website",
138
+ }: GenerateSanityMetadataOptions): Metadata => {
139
+ const pageMetadata = document.metadata;
140
+ const title =
141
+ pageMetadata?.metaTitle ||
142
+ pageMetadata?.title ||
143
+ document.title ||
144
+ undefined;
145
+ const description =
146
+ pageMetadata?.metaDescription ||
147
+ pageMetadata?.description ||
148
+ document.subtitle ||
149
+ undefined;
150
+ const socialImageSource = pageMetadata?.image || pageMetadata?.og?.image;
151
+ const imageUrl = socialImageSource
152
+ ? urlForImage(socialImageSource).width(1200).height(630).url()
153
+ : undefined;
154
+ const index =
155
+ pageMetadata?.index ??
156
+ (pageMetadata?.noIndex != null ? !pageMetadata.noIndex : true);
157
+
158
+ return generatePageMetadata({
159
+ title,
160
+ description,
161
+ image: imageUrl ? { url: imageUrl, width: 1200, height: 630 } : undefined,
162
+ url,
163
+ siteName,
164
+ index,
165
+ type,
166
+ });
167
+ };