bsmnt 0.5.0 → 0.6.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 (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 +26 -8
  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 +108 -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,436 @@
1
+ import { defineArrayMember, defineField, defineType } from "sanity";
2
+ import {
3
+ customJsonLdIcon,
4
+ eventJsonLdIcon,
5
+ faqJsonLdIcon,
6
+ itemListJsonLdIcon,
7
+ jobPostingJsonLdIcon,
8
+ softwareApplicationJsonLdIcon,
9
+ videoJsonLdIcon,
10
+ } from "@/lib/integrations/sanity/icons";
11
+
12
+ /**
13
+ * Editor-selectable JSON-LD blocks (predefined schema.org types + a raw
14
+ * `customJsonLd` escape hatch), each emitted as its own script tag.
15
+ */
16
+
17
+ export const faqJsonLd = defineType({
18
+ name: "faqJsonLd",
19
+ title: "FAQ",
20
+ type: "object",
21
+ icon: faqJsonLdIcon,
22
+ fields: [
23
+ defineField({
24
+ name: "questions",
25
+ title: "Questions",
26
+ type: "array",
27
+ validation: (Rule) => Rule.min(1),
28
+ of: [
29
+ defineArrayMember({
30
+ type: "object",
31
+ fields: [
32
+ defineField({
33
+ name: "question",
34
+ title: "Question",
35
+ type: "string",
36
+ validation: (Rule) => Rule.required(),
37
+ }),
38
+ defineField({
39
+ name: "answer",
40
+ title: "Answer",
41
+ type: "text",
42
+ rows: 3,
43
+ validation: (Rule) => Rule.required(),
44
+ }),
45
+ ],
46
+ preview: {
47
+ select: { title: "question", subtitle: "answer" },
48
+ },
49
+ }),
50
+ ],
51
+ }),
52
+ ],
53
+ preview: {
54
+ select: { questions: "questions" },
55
+ prepare: ({ questions }) => ({
56
+ title: "FAQ",
57
+ subtitle: `${Array.isArray(questions) ? questions.length : 0} question(s)`,
58
+ }),
59
+ },
60
+ });
61
+
62
+ export const jobPostingJsonLd = defineType({
63
+ name: "jobPostingJsonLd",
64
+ title: "Job Posting",
65
+ type: "object",
66
+ icon: jobPostingJsonLdIcon,
67
+ fields: [
68
+ defineField({
69
+ name: "title",
70
+ title: "Job Title",
71
+ type: "string",
72
+ validation: (Rule) => Rule.required(),
73
+ }),
74
+ defineField({
75
+ name: "description",
76
+ title: "Description",
77
+ type: "text",
78
+ rows: 4,
79
+ validation: (Rule) => Rule.required(),
80
+ }),
81
+ defineField({
82
+ name: "datePosted",
83
+ title: "Date Posted",
84
+ type: "datetime",
85
+ validation: (Rule) => Rule.required(),
86
+ }),
87
+ defineField({
88
+ name: "validThrough",
89
+ title: "Valid Through",
90
+ type: "datetime",
91
+ description: "When the posting expires.",
92
+ }),
93
+ defineField({
94
+ name: "employmentType",
95
+ title: "Employment Type",
96
+ type: "string",
97
+ options: {
98
+ list: [
99
+ { title: "Full-time", value: "FULL_TIME" },
100
+ { title: "Part-time", value: "PART_TIME" },
101
+ { title: "Contractor", value: "CONTRACTOR" },
102
+ { title: "Temporary", value: "TEMPORARY" },
103
+ { title: "Intern", value: "INTERN" },
104
+ { title: "Volunteer", value: "VOLUNTEER" },
105
+ { title: "Per diem", value: "PER_DIEM" },
106
+ { title: "Other", value: "OTHER" },
107
+ ],
108
+ },
109
+ }),
110
+ defineField({
111
+ name: "organizationName",
112
+ title: "Hiring Organization",
113
+ type: "string",
114
+ }),
115
+ defineField({
116
+ name: "organizationUrl",
117
+ title: "Organization URL",
118
+ type: "url",
119
+ }),
120
+ defineField({
121
+ name: "remote",
122
+ title: "Remote / Telecommute",
123
+ type: "boolean",
124
+ initialValue: false,
125
+ }),
126
+ defineField({ name: "locality", title: "City", type: "string" }),
127
+ defineField({ name: "region", title: "Region / State", type: "string" }),
128
+ defineField({ name: "country", title: "Country", type: "string" }),
129
+ ],
130
+ preview: {
131
+ select: { title: "title", subtitle: "organizationName" },
132
+ prepare: ({ title, subtitle }) => ({
133
+ title: title || "Job Posting",
134
+ subtitle: subtitle ? `Job · ${subtitle}` : "Job Posting",
135
+ }),
136
+ },
137
+ });
138
+
139
+ export const softwareApplicationJsonLd = defineType({
140
+ name: "softwareApplicationJsonLd",
141
+ title: "Software Application",
142
+ type: "object",
143
+ icon: softwareApplicationJsonLdIcon,
144
+ fields: [
145
+ defineField({
146
+ name: "name",
147
+ title: "Name",
148
+ type: "string",
149
+ validation: (Rule) => Rule.required(),
150
+ }),
151
+ defineField({
152
+ name: "description",
153
+ title: "Description",
154
+ type: "text",
155
+ rows: 2,
156
+ }),
157
+ defineField({
158
+ name: "applicationCategory",
159
+ title: "Application Category",
160
+ type: "string",
161
+ options: {
162
+ list: [
163
+ { title: "Web application", value: "WebApplication" },
164
+ { title: "Mobile application", value: "MobileApplication" },
165
+ { title: "Developer tool", value: "DeveloperApplication" },
166
+ { title: "Business application", value: "BusinessApplication" },
167
+ { title: "Design application", value: "DesignApplication" },
168
+ { title: "Game", value: "GameApplication" },
169
+ ],
170
+ },
171
+ }),
172
+ defineField({
173
+ name: "operatingSystem",
174
+ title: "Operating System",
175
+ type: "string",
176
+ description: "e.g. 'Web', 'macOS, Windows', 'iOS, Android'.",
177
+ }),
178
+ defineField({
179
+ name: "price",
180
+ title: "Price",
181
+ type: "number",
182
+ description: "Use 0 for free apps. Enables the rich result.",
183
+ }),
184
+ defineField({
185
+ name: "currency",
186
+ title: "Currency",
187
+ type: "string",
188
+ description: "ISO 4217 code, e.g. USD, EUR.",
189
+ initialValue: "USD",
190
+ }),
191
+ defineField({ name: "url", title: "URL", type: "url" }),
192
+ defineField({ name: "image", title: "Screenshot / Icon", type: "image" }),
193
+ ],
194
+ preview: {
195
+ select: { title: "name", subtitle: "applicationCategory" },
196
+ prepare: ({ title, subtitle }) => ({
197
+ title: title || "Software Application",
198
+ subtitle: subtitle ? `App · ${subtitle}` : "Software Application",
199
+ }),
200
+ },
201
+ });
202
+
203
+ export const itemListJsonLd = defineType({
204
+ name: "itemListJsonLd",
205
+ title: "Item List / Carousel",
206
+ type: "object",
207
+ icon: itemListJsonLdIcon,
208
+ description:
209
+ "An ordered list of links (e.g. a carousel of articles or products). Each item points to a page on your site.",
210
+ fields: [
211
+ defineField({
212
+ name: "name",
213
+ title: "List Name",
214
+ type: "string",
215
+ description: "Optional title for the list.",
216
+ }),
217
+ defineField({
218
+ name: "items",
219
+ title: "Items",
220
+ type: "array",
221
+ validation: (Rule) => Rule.min(1),
222
+ of: [
223
+ defineArrayMember({
224
+ type: "object",
225
+ fields: [
226
+ defineField({
227
+ name: "name",
228
+ title: "Name",
229
+ type: "string",
230
+ validation: (Rule) => Rule.required(),
231
+ }),
232
+ defineField({
233
+ name: "url",
234
+ title: "URL",
235
+ type: "url",
236
+ validation: (Rule) => Rule.required(),
237
+ }),
238
+ ],
239
+ preview: {
240
+ select: { title: "name", subtitle: "url" },
241
+ },
242
+ }),
243
+ ],
244
+ }),
245
+ ],
246
+ preview: {
247
+ select: { title: "name", items: "items" },
248
+ prepare: ({ title, items }) => ({
249
+ title: title || "Item List / Carousel",
250
+ subtitle: `${Array.isArray(items) ? items.length : 0} item(s)`,
251
+ }),
252
+ },
253
+ });
254
+
255
+ export const eventJsonLd = defineType({
256
+ name: "eventJsonLd",
257
+ title: "Event",
258
+ type: "object",
259
+ icon: eventJsonLdIcon,
260
+ fields: [
261
+ defineField({
262
+ name: "name",
263
+ title: "Name",
264
+ type: "string",
265
+ validation: (Rule) => Rule.required(),
266
+ }),
267
+ defineField({
268
+ name: "description",
269
+ title: "Description",
270
+ type: "text",
271
+ rows: 2,
272
+ }),
273
+ defineField({
274
+ name: "startDate",
275
+ title: "Start Date",
276
+ type: "datetime",
277
+ validation: (Rule) => Rule.required(),
278
+ }),
279
+ defineField({ name: "endDate", title: "End Date", type: "datetime" }),
280
+ defineField({
281
+ name: "locationName",
282
+ title: "Location Name",
283
+ type: "string",
284
+ description: "Venue name, or leave blank for online events.",
285
+ }),
286
+ defineField({
287
+ name: "locationAddress",
288
+ title: "Location Address",
289
+ type: "string",
290
+ }),
291
+ defineField({
292
+ name: "url",
293
+ title: "Event URL",
294
+ type: "url",
295
+ }),
296
+ defineField({ name: "image", title: "Image", type: "image" }),
297
+ ],
298
+ preview: {
299
+ select: { title: "name", subtitle: "startDate" },
300
+ prepare: ({ title, subtitle }) => ({
301
+ title: title || "Event",
302
+ subtitle: subtitle ? `Event · ${subtitle}` : "Event",
303
+ }),
304
+ },
305
+ });
306
+
307
+ export const videoJsonLd = defineType({
308
+ name: "videoJsonLd",
309
+ title: "Video",
310
+ type: "object",
311
+ icon: videoJsonLdIcon,
312
+ fields: [
313
+ defineField({
314
+ name: "name",
315
+ title: "Name",
316
+ type: "string",
317
+ validation: (Rule) => Rule.required(),
318
+ }),
319
+ defineField({
320
+ name: "description",
321
+ title: "Description",
322
+ type: "text",
323
+ rows: 2,
324
+ validation: (Rule) => Rule.required(),
325
+ }),
326
+ defineField({
327
+ name: "thumbnail",
328
+ title: "Thumbnail",
329
+ type: "image",
330
+ validation: (Rule) => Rule.required(),
331
+ }),
332
+ defineField({
333
+ name: "uploadDate",
334
+ title: "Upload Date",
335
+ type: "datetime",
336
+ validation: (Rule) => Rule.required(),
337
+ }),
338
+ defineField({
339
+ name: "contentUrl",
340
+ title: "Content URL",
341
+ type: "url",
342
+ description: "Direct link to the video file (.mp4, etc.).",
343
+ }),
344
+ defineField({
345
+ name: "embedUrl",
346
+ title: "Embed URL",
347
+ type: "url",
348
+ description: "Player embed URL (YouTube, Vimeo, etc.).",
349
+ }),
350
+ ],
351
+ preview: {
352
+ select: { title: "name" },
353
+ prepare: ({ title }) => ({
354
+ title: title || "Video",
355
+ subtitle: "Video",
356
+ }),
357
+ },
358
+ });
359
+
360
+ export const customJsonLd = defineType({
361
+ name: "customJsonLd",
362
+ title: "Custom JSON-LD",
363
+ type: "object",
364
+ icon: customJsonLdIcon,
365
+ description:
366
+ "Optional JSON-LD block for advanced rich results (FAQ, Product, HowTo, etc.). Emits its own <script> tag. Validate snippets with https://search.google.com/test/rich-results — invalid blocks are skipped at render time.",
367
+ fields: [
368
+ defineField({
369
+ name: "label",
370
+ title: "Internal Label",
371
+ type: "string",
372
+ description:
373
+ "For your reference only (e.g. 'FAQ – pricing tiers'). Not emitted.",
374
+ validation: (Rule) => Rule.required(),
375
+ }),
376
+ defineField({
377
+ name: "json",
378
+ title: "JSON-LD",
379
+ type: "text",
380
+ rows: 10,
381
+ description:
382
+ "Paste a single schema.org JSON-LD object. Omit the <script> wrapper. Must include an @type (@context is added automatically if omitted).",
383
+ validation: (Rule) =>
384
+ Rule.required().custom((value) => {
385
+ if (typeof value !== "string" || value.trim() === "") {
386
+ return "JSON-LD is required";
387
+ }
388
+ try {
389
+ const parsed = JSON.parse(value);
390
+ if (Array.isArray(parsed)) {
391
+ return "Use one block per entry (split arrays into multiple blocks).";
392
+ }
393
+ if (typeof parsed !== "object" || parsed === null) {
394
+ return "Must be a JSON object.";
395
+ }
396
+ if (!("@type" in parsed)) {
397
+ return 'Must include an @type (e.g. "@type": "FAQPage").';
398
+ }
399
+ return true;
400
+ } catch {
401
+ return "Invalid JSON. Use https://jsonlint.com to debug.";
402
+ }
403
+ }),
404
+ }),
405
+ ],
406
+ preview: {
407
+ select: { title: "label", subtitle: "json" },
408
+ prepare: ({ title, subtitle }) => {
409
+ let type: string | undefined;
410
+ if (typeof subtitle === "string") {
411
+ try {
412
+ const parsed = JSON.parse(subtitle);
413
+ if (parsed && typeof parsed === "object" && "@type" in parsed) {
414
+ type = String(parsed["@type"]);
415
+ }
416
+ } catch {
417
+ // ignore — render-time skips invalid blocks
418
+ }
419
+ }
420
+ return {
421
+ title: title || "Custom JSON-LD",
422
+ subtitle: type ? `@type: ${type}` : "JSON-LD",
423
+ };
424
+ },
425
+ },
426
+ });
427
+
428
+ export const pageJsonLdMembers = [
429
+ faqJsonLd,
430
+ eventJsonLd,
431
+ videoJsonLd,
432
+ jobPostingJsonLd,
433
+ softwareApplicationJsonLd,
434
+ itemListJsonLd,
435
+ customJsonLd,
436
+ ] as const;
@@ -1,4 +1,10 @@
1
- import { companyDataIcon, footerIcon, navbarIcon, pageListIcon } from "./icons";
1
+ import {
2
+ companyDataIcon,
3
+ footerIcon,
4
+ navbarIcon,
5
+ pageListIcon,
6
+ structuredDataIcon,
7
+ } from "./icons";
2
8
 
3
9
  export const singletonComponents = [
4
10
  {
@@ -28,6 +34,12 @@ export const singletonLayout = [
28
34
  title: "Footer",
29
35
  icon: footerIcon,
30
36
  },
37
+ {
38
+ schemaType: "structuredData",
39
+ documentId: "structuredData",
40
+ title: "Structured Data",
41
+ icon: structuredDataIcon,
42
+ },
31
43
  ] as const;
32
44
 
33
45
  export const singletonComponentTypes: Set<string> = new Set(
@@ -0,0 +1,208 @@
1
+ import { stegaClean } from "@sanity/client/stega";
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
+ }