bsmnt 0.5.0 → 0.6.1

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 (123) hide show
  1. package/README.md +1 -0
  2. package/dist/application/add-hooks/index.d.ts.map +1 -1
  3. package/dist/application/add-hooks/index.js +1 -0
  4. package/dist/application/add-hooks/index.js.map +1 -1
  5. package/dist/application/add-integration/index.d.ts +13 -6
  6. package/dist/application/add-integration/index.d.ts.map +1 -1
  7. package/dist/application/add-integration/index.js +145 -62
  8. package/dist/application/add-integration/index.js.map +1 -1
  9. package/dist/application/create-project/build-context.d.ts +3 -1
  10. package/dist/application/create-project/build-context.d.ts.map +1 -1
  11. package/dist/application/create-project/build-context.js +2 -1
  12. package/dist/application/create-project/build-context.js.map +1 -1
  13. package/dist/application/create-project/index.d.ts +3 -1
  14. package/dist/application/create-project/index.d.ts.map +1 -1
  15. package/dist/application/create-project/index.js +4 -2
  16. package/dist/application/create-project/index.js.map +1 -1
  17. package/dist/application/install-skills/index.js +1 -1
  18. package/dist/application/install-skills/index.js.map +1 -1
  19. package/dist/core/create/execute-plan.d.ts +4 -0
  20. package/dist/core/create/execute-plan.d.ts.map +1 -1
  21. package/dist/core/create/execute-plan.js +8 -0
  22. package/dist/core/create/execute-plan.js.map +1 -1
  23. package/dist/core/create/types.d.ts +2 -0
  24. package/dist/core/create/types.d.ts.map +1 -1
  25. package/dist/domain/skills.d.ts +26 -0
  26. package/dist/domain/skills.d.ts.map +1 -1
  27. package/dist/domain/skills.js +13 -0
  28. package/dist/domain/skills.js.map +1 -1
  29. package/dist/index.js +53 -28
  30. package/dist/index.js.map +1 -1
  31. package/dist/infrastructure/create/executor.d.ts.map +1 -1
  32. package/dist/infrastructure/create/executor.js +5 -0
  33. package/dist/infrastructure/create/executor.js.map +1 -1
  34. package/dist/infrastructure/create/lifecycle-registry.d.ts.map +1 -1
  35. package/dist/infrastructure/create/lifecycle-registry.js +2 -0
  36. package/dist/infrastructure/create/lifecycle-registry.js.map +1 -1
  37. package/dist/infrastructure/create/setup-agent.d.ts +2 -0
  38. package/dist/infrastructure/create/setup-agent.d.ts.map +1 -1
  39. package/dist/infrastructure/create/setup-agent.js +9 -8
  40. package/dist/infrastructure/create/setup-agent.js.map +1 -1
  41. package/dist/infrastructure/create/setup-dotenvx.d.ts +41 -0
  42. package/dist/infrastructure/create/setup-dotenvx.d.ts.map +1 -0
  43. package/dist/infrastructure/create/setup-dotenvx.js +502 -0
  44. package/dist/infrastructure/create/setup-dotenvx.js.map +1 -0
  45. package/dist/infrastructure/create/setup-sanity.d.ts +13 -1
  46. package/dist/infrastructure/create/setup-sanity.d.ts.map +1 -1
  47. package/dist/infrastructure/create/setup-sanity.js +71 -31
  48. package/dist/infrastructure/create/setup-sanity.js.map +1 -1
  49. package/dist/modules/features/cms/sanity/config.d.ts.map +1 -1
  50. package/dist/modules/features/cms/sanity/config.js +2 -1
  51. package/dist/modules/features/cms/sanity/config.js.map +1 -1
  52. package/dist/modules/features/cms/sanity/mergers/layout.d.ts.map +1 -1
  53. package/dist/modules/features/cms/sanity/mergers/layout.js +10 -0
  54. package/dist/modules/features/cms/sanity/mergers/layout.js.map +1 -1
  55. package/dist/modules/features/env/dotenvx/config.d.ts +13 -0
  56. package/dist/modules/features/env/dotenvx/config.d.ts.map +1 -0
  57. package/dist/modules/features/env/dotenvx/config.js +47 -0
  58. package/dist/modules/features/env/dotenvx/config.js.map +1 -0
  59. package/dist/paths.d.ts +8 -0
  60. package/dist/paths.d.ts.map +1 -1
  61. package/dist/paths.js +8 -0
  62. package/dist/paths.js.map +1 -1
  63. package/package.json +5 -1
  64. package/src/agent-skills/json-ld/SKILL.md +85 -0
  65. package/src/agent-skills/manage-env/SKILL.md +67 -0
  66. package/src/modules/features/cms/sanity/files/app/blog/[slug]/page.tsx +48 -21
  67. package/src/modules/features/cms/sanity/files/components/sanity/structured-data.tsx +83 -0
  68. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/queries.ts +14 -0
  69. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/sanity.config.ts +19 -3
  70. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/sanity.types.ts +1 -0
  71. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/schemas/index.ts +36 -1
  72. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/schemas/metadata.ts +12 -1
  73. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/schemas/structured-data-blocks.ts +416 -0
  74. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/schemas/structured-data.ts +100 -0
  75. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/structure.ts +37 -2
  76. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/utils/page-json-ld.ts +208 -0
  77. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/same-as-input.tsx +66 -0
  78. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/fetchers/layout.ts +20 -1
  79. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/icons.ts +17 -0
  80. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +22 -0
  81. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts +28 -0
  82. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/company-data.ts +8 -0
  83. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/structured-data.ts +124 -0
  84. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/metadata.ts +12 -1
  85. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/structured-data-blocks.ts +436 -0
  86. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/singletons.ts +13 -1
  87. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/utils/page-json-ld.ts +208 -0
  88. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/json-ld.tsx +324 -15
  89. package/src/modules/features/env/dotenvx/files/decrypt-env.mjs +45 -0
  90. package/src/modules/features/env/dotenvx/files/gitleaks.yml +25 -0
  91. package/src/modules/features/env/dotenvx/files/instrumentation.ts +14 -0
  92. package/src/modules/features/env/dotenvx/files/push-keys.mjs +129 -0
  93. package/src/modules/features/env/dotenvx/files/save-key.mjs +129 -0
  94. package/src/modules/features/env/dotenvx/files/setup-env.mjs +67 -0
  95. package/src/modules/features/env/dotenvx/files/vercelignore +4 -0
  96. package/src/modules/features/env/dotenvx/files/write-env.mjs +47 -0
  97. package/src/templates/next-default/.env.example +14 -6
  98. package/src/templates/next-default/.github/workflows/ci.yml +22 -1
  99. package/src/templates/next-default/.vscode/extensions.json +3 -0
  100. package/src/templates/next-default/README.md +31 -0
  101. package/src/templates/next-default/lib/utils/json-ld.tsx +327 -21
  102. package/src/templates/next-default/next.config.ts +6 -0
  103. package/src/templates/next-experiments/.env.example +14 -6
  104. package/src/templates/next-experiments/.github/workflows/ci.yml +22 -1
  105. package/src/templates/next-experiments/.vscode/extensions.json +3 -0
  106. package/src/templates/next-experiments/README.md +31 -0
  107. package/src/templates/next-experiments/lib/utils/json-ld.tsx +327 -21
  108. package/src/templates/next-experiments/next.config.ts +6 -0
  109. package/src/templates/next-pagebuilder/.env.example +19 -1
  110. package/src/templates/next-pagebuilder/.github/workflows/ci.yml +21 -1
  111. package/src/templates/next-pagebuilder/.vscode/extensions.json +3 -0
  112. package/src/templates/next-pagebuilder/README.md +31 -0
  113. package/src/templates/next-pagebuilder/components/layout/json-ld/index.tsx +32 -19
  114. package/src/templates/next-pagebuilder/components/page-document/index.tsx +38 -0
  115. package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +36 -2
  116. package/src/templates/next-pagebuilder/next.config.ts +6 -0
  117. package/src/templates/next-webgl/.env.example +14 -6
  118. package/src/templates/next-webgl/.github/workflows/ci.yml +22 -1
  119. package/src/templates/next-webgl/.vscode/extensions.json +3 -0
  120. package/src/templates/next-webgl/README.md +31 -0
  121. package/src/templates/next-webgl/lib/utils/json-ld.tsx +327 -21
  122. package/src/templates/next-webgl/next.config.ts +6 -0
  123. package/src/modules/features/cms/sanity/files/lib/utils/json-ld.tsx +0 -244
@@ -0,0 +1,208 @@
1
+ import { stegaClean } from "next-sanity";
2
+ import type { Thing, WithContext } from "schema-dts";
3
+ import {
4
+ type FaqJsonLdItem,
5
+ generateEventJsonLd,
6
+ generateFaqJsonLd,
7
+ generateItemListJsonLd,
8
+ generateJobPostingJsonLd,
9
+ generateSoftwareApplicationJsonLd,
10
+ generateVideoJsonLd,
11
+ type ItemListEntry,
12
+ } from "@/lib/utils/json-ld";
13
+ import { urlForImage } from "./image";
14
+
15
+ /**
16
+ * Turns `metadata.jsonLd` blocks from Sanity into renderable JSON-LD —
17
+ * stega-cleaned, with image refs resolved to absolute CDN URLs.
18
+ */
19
+
20
+ type SanityImageRef = Parameters<typeof urlForImage>[0];
21
+
22
+ type JsonLdBlock = {
23
+ _type?: string;
24
+ _key?: string;
25
+ [key: string]: unknown;
26
+ };
27
+
28
+ const clean = (value: unknown): string | undefined => {
29
+ const cleaned = stegaClean(value);
30
+ return typeof cleaned === "string" && cleaned.trim() !== ""
31
+ ? cleaned
32
+ : undefined;
33
+ };
34
+
35
+ const resolveImage = (value: unknown): string | undefined => {
36
+ if (!value || typeof value !== "object") return undefined;
37
+ try {
38
+ return urlForImage(value as SanityImageRef).url();
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ };
43
+
44
+ const buildFaq = (block: JsonLdBlock): WithContext<Thing> | null => {
45
+ const questions = Array.isArray(block.questions) ? block.questions : [];
46
+ const items = questions
47
+ .map((entry): FaqJsonLdItem | null => {
48
+ const question = clean((entry as JsonLdBlock)?.question);
49
+ const answer = clean((entry as JsonLdBlock)?.answer);
50
+ return question && answer ? { question, answer } : null;
51
+ })
52
+ .filter((item): item is FaqJsonLdItem => item !== null);
53
+ return items.length ? generateFaqJsonLd(items) : null;
54
+ };
55
+
56
+ const buildJobPosting = (block: JsonLdBlock): WithContext<Thing> | null => {
57
+ const title = clean(block.title);
58
+ const description = clean(block.description);
59
+ const datePosted = clean(block.datePosted);
60
+ if (!title || !description || !datePosted) return null;
61
+ return generateJobPostingJsonLd({
62
+ title,
63
+ description,
64
+ datePosted,
65
+ validThrough: clean(block.validThrough),
66
+ employmentType: clean(block.employmentType),
67
+ organizationName: clean(block.organizationName),
68
+ organizationUrl: clean(block.organizationUrl),
69
+ remote: block.remote === true,
70
+ locality: clean(block.locality),
71
+ region: clean(block.region),
72
+ country: clean(block.country),
73
+ });
74
+ };
75
+
76
+ const buildSoftwareApplication = (
77
+ block: JsonLdBlock,
78
+ ): WithContext<Thing> | null => {
79
+ const name = clean(block.name);
80
+ if (!name) return null;
81
+ const priceValue = stegaClean(block.price);
82
+ const price =
83
+ typeof priceValue === "number" && Number.isFinite(priceValue)
84
+ ? priceValue
85
+ : undefined;
86
+ return generateSoftwareApplicationJsonLd({
87
+ name,
88
+ description: clean(block.description),
89
+ applicationCategory: clean(block.applicationCategory),
90
+ operatingSystem: clean(block.operatingSystem),
91
+ price,
92
+ currency: clean(block.currency),
93
+ url: clean(block.url),
94
+ image: resolveImage(block.image),
95
+ });
96
+ };
97
+
98
+ const buildItemList = (block: JsonLdBlock): WithContext<Thing> | null => {
99
+ const rawItems = Array.isArray(block.items) ? block.items : [];
100
+ const items = rawItems
101
+ .map((entry): ItemListEntry | null => {
102
+ const name = clean((entry as JsonLdBlock)?.name);
103
+ if (!name) return null;
104
+ return { name, url: clean((entry as JsonLdBlock)?.url) };
105
+ })
106
+ .filter((item): item is ItemListEntry => item !== null);
107
+ if (!items.length) return null;
108
+ return generateItemListJsonLd({ name: clean(block.name), items });
109
+ };
110
+
111
+ const buildEvent = (block: JsonLdBlock): WithContext<Thing> | null => {
112
+ const name = clean(block.name);
113
+ const startDate = clean(block.startDate);
114
+ if (!name || !startDate) return null;
115
+ return generateEventJsonLd({
116
+ name,
117
+ startDate,
118
+ endDate: clean(block.endDate),
119
+ description: clean(block.description),
120
+ locationName: clean(block.locationName),
121
+ locationAddress: clean(block.locationAddress),
122
+ url: clean(block.url),
123
+ image: resolveImage(block.image),
124
+ });
125
+ };
126
+
127
+ const buildVideo = (block: JsonLdBlock): WithContext<Thing> | null => {
128
+ const name = clean(block.name);
129
+ const description = clean(block.description);
130
+ const uploadDate = clean(block.uploadDate);
131
+ const thumbnail = resolveImage(block.thumbnail);
132
+ if (!name || !description || !uploadDate || !thumbnail) return null;
133
+ return generateVideoJsonLd({
134
+ name,
135
+ description,
136
+ thumbnail,
137
+ uploadDate,
138
+ contentUrl: clean(block.contentUrl),
139
+ embedUrl: clean(block.embedUrl),
140
+ });
141
+ };
142
+
143
+ const buildCustom = (block: JsonLdBlock): WithContext<Thing> | null => {
144
+ const raw = clean(block.json);
145
+ if (!raw) return null;
146
+ try {
147
+ const parsed = JSON.parse(raw);
148
+ // Skip structurally-incomplete blocks: schema.org requires an @type.
149
+ if (
150
+ typeof parsed !== "object" ||
151
+ parsed === null ||
152
+ Array.isArray(parsed) ||
153
+ !("@type" in parsed)
154
+ ) {
155
+ return null;
156
+ }
157
+ // Default @context so editors can omit the boilerplate (an explicit
158
+ // @context in the pasted object always wins).
159
+ return {
160
+ "@context": "https://schema.org",
161
+ ...parsed,
162
+ } as WithContext<Thing>;
163
+ } catch {
164
+ return null;
165
+ }
166
+ };
167
+
168
+ export type PageJsonLd = { key: string; data: WithContext<Thing> };
169
+
170
+ export function buildPageJsonLd(blocks: unknown): PageJsonLd[] {
171
+ if (!Array.isArray(blocks)) return [];
172
+
173
+ return blocks
174
+ .map((entry, index): PageJsonLd | null => {
175
+ const block = entry as JsonLdBlock;
176
+ let data: WithContext<Thing> | null;
177
+ switch (block._type) {
178
+ case "faqJsonLd":
179
+ data = buildFaq(block);
180
+ break;
181
+ case "eventJsonLd":
182
+ data = buildEvent(block);
183
+ break;
184
+ case "videoJsonLd":
185
+ data = buildVideo(block);
186
+ break;
187
+ case "jobPostingJsonLd":
188
+ data = buildJobPosting(block);
189
+ break;
190
+ case "softwareApplicationJsonLd":
191
+ data = buildSoftwareApplication(block);
192
+ break;
193
+ case "itemListJsonLd":
194
+ data = buildItemList(block);
195
+ break;
196
+ case "customJsonLd":
197
+ data = buildCustom(block);
198
+ break;
199
+ default:
200
+ data = null;
201
+ }
202
+ if (!data) return null;
203
+ const key =
204
+ typeof block._key === "string" ? block._key : `jsonld-${index}`;
205
+ return { key, data };
206
+ })
207
+ .filter((item): item is PageJsonLd => item !== null);
208
+ }
@@ -0,0 +1,66 @@
1
+ import { Badge, Card, Flex, Stack, Text } from "@sanity/ui";
2
+ import { useEffect, useState } from "react";
3
+ import { type ArrayOfPrimitivesInputProps, useClient } from "sanity";
4
+ import { apiVersion } from "../env";
5
+
6
+ type CompanyProfile = { name?: string | null; url?: string | null };
7
+
8
+ // Draft-first so editors see unpublished toggle changes.
9
+ export const INCLUDED_COMPANY_PROFILES_QUERY = `coalesce(
10
+ *[_id == "drafts.companyData"][0],
11
+ *[_id == "companyData"][0]
12
+ ).socialLinks[includeInStructuredData == true]{ name, url }`;
13
+
14
+ // Shows which Company Data social links are merged into sameAs automatically.
15
+ export function SameAsInput(props: ArrayOfPrimitivesInputProps) {
16
+ const { renderDefault } = props;
17
+ const client = useClient({ apiVersion });
18
+ const [included, setIncluded] = useState<CompanyProfile[]>([]);
19
+
20
+ useEffect(() => {
21
+ let active = true;
22
+ client
23
+ .fetch<CompanyProfile[] | null>(INCLUDED_COMPANY_PROFILES_QUERY)
24
+ .then((rows) => {
25
+ if (active) setIncluded(rows ?? []);
26
+ })
27
+ .catch(() => {
28
+ // Best-effort preview — never block editing.
29
+ });
30
+ return () => {
31
+ active = false;
32
+ };
33
+ }, [client]);
34
+
35
+ return (
36
+ <Stack space={3}>
37
+ <Card padding={3} radius={2} border tone="transparent">
38
+ <Stack space={3}>
39
+ {included.length > 0 ? (
40
+ <>
41
+ <Text size={1} muted>
42
+ Included from Company Data (toggle off there to remove):
43
+ </Text>
44
+ {included.map((profile) => (
45
+ <Flex key={profile.url} gap={2} align="center">
46
+ <Badge fontSize={0} mode="outline">
47
+ {profile.name ?? "Profile"}
48
+ </Badge>
49
+ <Text size={1} muted textOverflow="ellipsis">
50
+ {profile.url}
51
+ </Text>
52
+ </Flex>
53
+ ))}
54
+ </>
55
+ ) : (
56
+ <Text size={1} muted>
57
+ No Company Data social links are included yet — enable "Include in
58
+ Structured Data" on a social link to add it here.
59
+ </Text>
60
+ )}
61
+ </Stack>
62
+ </Card>
63
+ {renderDefault(props)}
64
+ </Stack>
65
+ );
66
+ }
@@ -4,19 +4,27 @@ import {
4
4
  COMPANY_DATA_QUERY,
5
5
  FOOTER_QUERY,
6
6
  NAVBAR_QUERY,
7
+ STRUCTURED_DATA_QUERY,
7
8
  } from "@/lib/integrations/sanity/queries";
8
9
  import type {
9
10
  COMPANY_DATA_QUERY_RESULT,
10
11
  FOOTER_QUERY_RESULT,
11
12
  NAVBAR_QUERY_RESULT,
13
+ STRUCTURED_DATA_QUERY_RESULT,
12
14
  } from "@/lib/integrations/sanity/sanity.types";
13
15
 
14
16
  export type NavbarData = NonNullable<NAVBAR_QUERY_RESULT>;
15
17
  export type FooterData = NonNullable<FOOTER_QUERY_RESULT>;
16
18
  export type CompanyData = NonNullable<COMPANY_DATA_QUERY_RESULT>;
19
+ export type StructuredData = NonNullable<STRUCTURED_DATA_QUERY_RESULT>;
17
20
 
18
21
  // Singleton document types that, when published, should bust the layout cache.
19
- export const SANITY_LAYOUT_TAGS = ["navbar", "footer", "companyData"];
22
+ export const SANITY_LAYOUT_TAGS = [
23
+ "navbar",
24
+ "footer",
25
+ "companyData",
26
+ "structuredData",
27
+ ];
20
28
 
21
29
  // Wrapped in React cache so components sharing a singleton (e.g. Footer + JsonLd
22
30
  // both reading companyData) only hit Sanity once per request.
@@ -46,3 +54,14 @@ export const getCompanyData = cache(async (): Promise<CompanyData | null> => {
46
54
 
47
55
  return data ?? null;
48
56
  });
57
+
58
+ export const getStructuredData = cache(
59
+ async (): Promise<StructuredData | null> => {
60
+ const { data } = await fetchSanity<StructuredData>({
61
+ query: STRUCTURED_DATA_QUERY,
62
+ tags: ["structuredData"],
63
+ });
64
+
65
+ return data ?? null;
66
+ },
67
+ );
@@ -1,9 +1,14 @@
1
1
  import type { Icon, IconProps } from "@phosphor-icons/react";
2
2
  import {
3
+ AppWindowIcon,
3
4
  ArrowBendUpRightIcon,
4
5
  ArticleNyTimesIcon,
6
+ BracketsCurlyIcon,
7
+ BriefcaseIcon,
5
8
  BrowsersIcon,
6
9
  BuildingsIcon,
10
+ CalendarBlankIcon,
11
+ CardsIcon,
7
12
  FileTextIcon,
8
13
  FolderOpenIcon,
9
14
  FootprintsIcon,
@@ -12,12 +17,15 @@ import {
12
17
  ListIcon,
13
18
  PackageIcon,
14
19
  PushPinIcon,
20
+ QuestionIcon,
15
21
  RocketIcon,
16
22
  ShapesIcon,
17
23
  ShareNetworkIcon,
18
24
  TagIcon,
19
25
  TextAlignLeftIcon,
26
+ TreeStructureIcon,
20
27
  UserCircleIcon,
28
+ VideoCameraIcon,
21
29
  } from "@phosphor-icons/react/dist/ssr";
22
30
  import { createElement, forwardRef } from "react";
23
31
 
@@ -50,6 +58,15 @@ export const navbarIcon = asDuotoneIcon(ListIcon);
50
58
  export const footerIcon = asDuotoneIcon(FootprintsIcon);
51
59
  export const redirectIcon = asDuotoneIcon(ArrowBendUpRightIcon);
52
60
  export const layoutFolderIcon = asDuotoneIcon(LayoutIcon);
61
+ export const structuredDataIcon = asDuotoneIcon(TreeStructureIcon);
62
+
63
+ export const faqJsonLdIcon = asDuotoneIcon(QuestionIcon);
64
+ export const eventJsonLdIcon = asDuotoneIcon(CalendarBlankIcon);
65
+ export const videoJsonLdIcon = asDuotoneIcon(VideoCameraIcon);
66
+ export const jobPostingJsonLdIcon = asDuotoneIcon(BriefcaseIcon);
67
+ export const softwareApplicationJsonLdIcon = asDuotoneIcon(AppWindowIcon);
68
+ export const itemListJsonLdIcon = asDuotoneIcon(CardsIcon);
69
+ export const customJsonLdIcon = asDuotoneIcon(BracketsCurlyIcon);
53
70
 
54
71
  export const heroIcon = asDuotoneIcon(RocketIcon);
55
72
  export const descriptionIcon = asDuotoneIcon(TextAlignLeftIcon);
@@ -361,6 +361,21 @@ const footerProjection = `
361
361
  }
362
362
  `;
363
363
 
364
+ // sameAs: flagged Company Data social links + manual profiles, deduped.
365
+ const structuredDataProjection = `
366
+ name,
367
+ legalName,
368
+ description,
369
+ url,
370
+ "logo": logo.asset->url,
371
+ contactPoint,
372
+ address,
373
+ "sameAs": array::unique(array::compact(
374
+ coalesce(*[_type == "companyData"][0].socialLinks[includeInStructuredData == true].url, [])
375
+ + coalesce(sameAs, [])
376
+ ))
377
+ `;
378
+
364
379
  export const NAVBAR_QUERY = defineQuery(`
365
380
  *[_type == "navbar"][0]{
366
381
  ${navbarProjection}
@@ -374,6 +389,13 @@ export const FOOTER_QUERY = defineQuery(`
374
389
  }
375
390
  `);
376
391
 
392
+ // Structured Data (site-wide JSON-LD identity)
393
+ export const STRUCTURED_DATA_QUERY = defineQuery(`
394
+ *[_type == "structuredData"][0]{
395
+ ${structuredDataProjection}
396
+ }
397
+ `);
398
+
377
399
  // Redirects -- source/destination rules applied by the proxy.
378
400
  // `source` is a slug; flatten to its slash-free path string.
379
401
  export const REDIRECTS_QUERY = defineQuery(`
@@ -15,6 +15,7 @@ import { companyData } from "./layout/company-data";
15
15
  import { footer } from "./layout/footer";
16
16
  import { navbar } from "./layout/navbar";
17
17
  import { redirect } from "./layout/redirect";
18
+ import { structuredData } from "./layout/structured-data";
18
19
  import { externalLink, link, pageReference } from "./shared/link";
19
20
  import { metadata } from "./shared/metadata";
20
21
  import {
@@ -27,6 +28,15 @@ import { page } from "./shared/page";
27
28
  import { pageBuilder } from "./shared/page-builder";
28
29
  import { pageFolder } from "./shared/page-folder";
29
30
  import { richText } from "./shared/richText";
31
+ import {
32
+ customJsonLd,
33
+ eventJsonLd,
34
+ faqJsonLd,
35
+ itemListJsonLd,
36
+ jobPostingJsonLd,
37
+ softwareApplicationJsonLd,
38
+ videoJsonLd,
39
+ } from "./shared/structured-data-blocks";
30
40
 
31
41
  // Re-export all schemas for convenience
32
42
  export {
@@ -35,10 +45,15 @@ export {
35
45
  blogCollection,
36
46
  blogContent,
37
47
  companyData,
48
+ customJsonLd,
38
49
  description,
50
+ eventJsonLd,
39
51
  externalLink,
52
+ faqJsonLd,
40
53
  footer,
41
54
  hero,
55
+ itemListJsonLd,
56
+ jobPostingJsonLd,
42
57
  link,
43
58
  metadata,
44
59
  navbar,
@@ -52,6 +67,9 @@ export {
52
67
  pageReference,
53
68
  redirect,
54
69
  richText,
70
+ softwareApplicationJsonLd,
71
+ structuredData,
72
+ videoJsonLd,
55
73
  };
56
74
 
57
75
  // Schema collection for Sanity configuration
@@ -65,6 +83,15 @@ export const schema: { types: SchemaTypeDefinition[] } = {
65
83
  pageBuilder,
66
84
  richText,
67
85
 
86
+ // Page-level JSON-LD blocks (selectable in metadata)
87
+ faqJsonLd,
88
+ eventJsonLd,
89
+ videoJsonLd,
90
+ jobPostingJsonLd,
91
+ softwareApplicationJsonLd,
92
+ itemListJsonLd,
93
+ customJsonLd,
94
+
68
95
  // Navigation object types (used by navbar)
69
96
  navLeafItem,
70
97
  navColumn,
@@ -81,6 +108,7 @@ export const schema: { types: SchemaTypeDefinition[] } = {
81
108
  companyData,
82
109
  navbar,
83
110
  footer,
111
+ structuredData,
84
112
 
85
113
  // Layout collections (managed lists, not singletons)
86
114
  redirect,
@@ -42,6 +42,14 @@ export const companyData = defineType({
42
42
  description:
43
43
  "Accessible label for the link (e.g. 'Follow us on LinkedIn')",
44
44
  }),
45
+ defineField({
46
+ name: "includeInStructuredData",
47
+ title: "Include in Structured Data",
48
+ type: "boolean",
49
+ description:
50
+ "Add this profile to the site's JSON-LD (schema.org sameAs) emitted for search engines.",
51
+ initialValue: false,
52
+ }),
45
53
  ],
46
54
  preview: {
47
55
  select: {
@@ -0,0 +1,124 @@
1
+ import { defineField, defineType } from "sanity";
2
+ import {
3
+ INCLUDED_COMPANY_PROFILES_QUERY,
4
+ SameAsInput,
5
+ } from "@/lib/integrations/sanity/components/same-as-input";
6
+ import { apiVersion } from "@/lib/integrations/sanity/env";
7
+ import { structuredDataIcon } from "@/lib/integrations/sanity/icons";
8
+
9
+ export const structuredData = defineType({
10
+ name: "structuredData",
11
+ title: "Structured Data",
12
+ type: "document",
13
+ icon: structuredDataIcon,
14
+ description:
15
+ "Site-wide identity emitted as JSON-LD (schema.org Organization + WebSite) on every page for search engines.",
16
+ fields: [
17
+ defineField({
18
+ name: "name",
19
+ title: "Organization Name",
20
+ type: "string",
21
+ description: "Public name of the organization (shown in rich results).",
22
+ validation: (Rule) => Rule.required(),
23
+ }),
24
+ defineField({
25
+ name: "legalName",
26
+ title: "Legal Name",
27
+ type: "string",
28
+ description:
29
+ "Registered legal entity name, if different from the public name.",
30
+ }),
31
+ defineField({
32
+ name: "logo",
33
+ title: "Logo",
34
+ type: "image",
35
+ description:
36
+ "Square or wide logo used in rich results. Min 112x112px, transparent PNG/SVG recommended.",
37
+ options: { hotspot: true },
38
+ }),
39
+ defineField({
40
+ name: "description",
41
+ title: "Description",
42
+ type: "text",
43
+ rows: 3,
44
+ description: "Short sentence describing the organization.",
45
+ }),
46
+ defineField({
47
+ name: "url",
48
+ title: "Canonical URL",
49
+ type: "url",
50
+ description:
51
+ "Optional override for the canonical site URL. Defaults to the deployed origin.",
52
+ }),
53
+ defineField({
54
+ name: "contactPoint",
55
+ title: "Contact Point",
56
+ type: "object",
57
+ options: { collapsible: true, collapsed: true },
58
+ fields: [
59
+ defineField({ name: "email", title: "Email", type: "string" }),
60
+ defineField({ name: "telephone", title: "Telephone", type: "string" }),
61
+ defineField({
62
+ name: "contactType",
63
+ title: "Contact Type",
64
+ type: "string",
65
+ description:
66
+ "e.g. 'customer support', 'sales', 'press'. See schema.org/ContactPoint.",
67
+ }),
68
+ ],
69
+ }),
70
+ defineField({
71
+ name: "address",
72
+ title: "Address",
73
+ type: "object",
74
+ options: { collapsible: true, collapsed: true },
75
+ fields: [
76
+ defineField({
77
+ name: "streetAddress",
78
+ title: "Street Address",
79
+ type: "string",
80
+ }),
81
+ defineField({ name: "locality", title: "City", type: "string" }),
82
+ defineField({
83
+ name: "region",
84
+ title: "Region / State",
85
+ type: "string",
86
+ }),
87
+ defineField({
88
+ name: "postalCode",
89
+ title: "Postal Code",
90
+ type: "string",
91
+ }),
92
+ defineField({ name: "country", title: "Country", type: "string" }),
93
+ ],
94
+ }),
95
+ defineField({
96
+ name: "sameAs",
97
+ title: "Same-As Profiles",
98
+ type: "array",
99
+ description:
100
+ "Additional official profiles representing this organization (LinkedIn, X, GitHub, etc.). Company Data social links with 'Include in Structured Data' enabled are merged in automatically.",
101
+ of: [
102
+ {
103
+ type: "url",
104
+ validation: (Rule) =>
105
+ Rule.custom(async (value, context) => {
106
+ if (!value) return true;
107
+ const client = context.getClient({ apiVersion });
108
+ const included = await client.fetch<
109
+ { url?: string | null }[] | null
110
+ >(INCLUDED_COMPANY_PROFILES_QUERY);
111
+ return (included ?? []).some((p) => p.url === value)
112
+ ? "Already included from Company Data — remove it here or toggle it off there."
113
+ : true;
114
+ }),
115
+ },
116
+ ],
117
+ validation: (Rule) => Rule.unique(),
118
+ components: { input: SameAsInput },
119
+ }),
120
+ ],
121
+ preview: {
122
+ prepare: () => ({ title: "Structured Data" }),
123
+ },
124
+ });
@@ -1,4 +1,5 @@
1
- import { defineField, defineType } from "sanity";
1
+ import { defineArrayMember, defineField, defineType } from "sanity";
2
+ import { pageJsonLdMembers } from "./structured-data-blocks";
2
3
 
3
4
  export const metadata = defineType({
4
5
  name: "metadata",
@@ -46,6 +47,16 @@ export const metadata = defineType({
46
47
  description: "Allow search engines to index this page",
47
48
  initialValue: true,
48
49
  }),
50
+ defineField({
51
+ name: "jsonLd",
52
+ title: "Structured Data (JSON-LD)",
53
+ type: "array",
54
+ description:
55
+ "Page-level schema.org structured data emitted as JSON-LD. Pick a predefined type, or add a Custom JSON-LD block for anything not listed.",
56
+ of: pageJsonLdMembers.map((member) =>
57
+ defineArrayMember({ type: member.name }),
58
+ ),
59
+ }),
49
60
  defineField({
50
61
  name: "title",
51
62
  title: "Legacy Meta Title",