@thorprovider/storefront 0.3.1 → 0.4.0-mvp.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 (179) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +248 -99
  3. package/assets/templates/app/app/[[...slug]]/page.tsx +13 -2
  4. package/assets/templates/app/app/layout.tsx +18 -4
  5. package/assets/templates/app/app/robots.ts +13 -0
  6. package/assets/templates/app/app/sitemap.ts +12 -0
  7. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +542 -0
  8. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  9. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +40 -57
  10. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +608 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +165 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  13. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  14. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  15. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +232 -51
  16. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -3
  17. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +121 -0
  18. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  20. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +96 -0
  21. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +34 -0
  22. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +100 -11
  23. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  24. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +26 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  27. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +142 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  30. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  31. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  32. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +17 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  34. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  39. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  40. package/assets/templates/features/checkout/handlers.ts +153 -0
  41. package/assets/templates/features/checkout/state.ts +61 -0
  42. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  43. package/assets/templates/features/customer-auth/state.ts +36 -0
  44. package/assets/templates/features/recommendations/state.ts +11 -0
  45. package/assets/templates/views/account/content.ts +35 -0
  46. package/assets/templates/views/account/index.tsx +114 -0
  47. package/assets/templates/views/account/registry.tsx +378 -0
  48. package/assets/templates/views/account/schema.ts +52 -0
  49. package/assets/templates/views/category/assembly/assembly.json +28 -0
  50. package/assets/templates/views/category/assembly/index.skeleton.tsx +193 -0
  51. package/assets/templates/views/category/assembly/registry.skeleton.tsx +33 -0
  52. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  53. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  54. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  55. package/assets/templates/views/category/content.ts +30 -0
  56. package/assets/templates/views/category/data.ts +12 -0
  57. package/assets/templates/views/category/index.tsx +230 -0
  58. package/assets/templates/views/category/props.ts +16 -0
  59. package/assets/templates/views/category/registry.tsx +103 -0
  60. package/assets/templates/views/category/schema.ts +78 -0
  61. package/assets/templates/views/checkout/content.ts +105 -0
  62. package/assets/templates/views/checkout/data.ts +23 -0
  63. package/assets/templates/views/checkout/index.tsx +111 -0
  64. package/assets/templates/views/checkout/props.ts +49 -0
  65. package/assets/templates/views/checkout/registry.tsx +217 -0
  66. package/assets/templates/views/checkout/schema.ts +229 -0
  67. package/assets/templates/views/home/assembly/assembly.json +24 -0
  68. package/assets/templates/views/home/assembly/index.skeleton.tsx +72 -0
  69. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  70. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  71. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  72. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  73. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  74. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  75. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  76. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  77. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  78. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  79. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  80. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  81. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  82. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  83. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  84. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  85. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  86. package/assets/templates/views/home/content.ts +70 -0
  87. package/assets/templates/views/home/data.ts +15 -0
  88. package/assets/templates/views/home/index.tsx +38 -84
  89. package/assets/templates/views/home/props.ts +22 -0
  90. package/assets/templates/views/home/registry.tsx +38 -22
  91. package/assets/templates/views/home/schema.ts +93 -0
  92. package/assets/templates/views/product/content.ts +25 -0
  93. package/assets/templates/views/product/data.ts +48 -0
  94. package/assets/templates/views/product/index.tsx +104 -0
  95. package/assets/templates/views/product/props.ts +20 -0
  96. package/assets/templates/views/product/registry.tsx +78 -0
  97. package/assets/templates/views/product/schema.ts +108 -0
  98. package/assets/templates/views/static/content.ts +99 -0
  99. package/assets/templates/views/static/index.tsx +60 -0
  100. package/assets/templates/views/static/registry.tsx +66 -0
  101. package/assets/templates/views/static/schema.ts +45 -0
  102. package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/index.tsx +8 -70
  103. package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/registry.tsx +23 -18
  104. package/assets/templates/views/user-panel/schema.ts +79 -0
  105. package/bin/storefront.js +1278 -79
  106. package/commands/sf-add-view.md +44 -13
  107. package/commands/sf-feature.md +28 -0
  108. package/commands/sf-theme.md +14 -8
  109. package/commands/sf-view.md +54 -12
  110. package/package.json +6 -4
  111. package/recipes/archetypes.json +28 -1
  112. package/recipes/catalog.json +361 -0
  113. package/recipes/catalog.mjs +128 -0
  114. package/recipes/catalog.schema.json +64 -0
  115. package/recipes/feature.schema.json +89 -0
  116. package/recipes/features.json +139 -0
  117. package/recipes/manifest.schema.json +69 -0
  118. package/recipes/operations.schema.json +43 -0
  119. package/recipes/recipe.schema.json +11 -2
  120. package/recipes/recipes.json +60 -5
  121. package/recipes/sections.json +24 -5
  122. package/recipes/validate.mjs +784 -32
  123. package/scripts/candidate.mjs +236 -0
  124. package/scripts/composers.mjs +140 -0
  125. package/scripts/file-classes.mjs +116 -0
  126. package/scripts/logical-controls.mjs +61 -0
  127. package/scripts/manifest.mjs +298 -0
  128. package/scripts/operation-registry.mjs +256 -0
  129. package/scripts/operations.mjs +720 -0
  130. package/scripts/ownership-conflict.mjs +356 -0
  131. package/scripts/pinning.mjs +504 -0
  132. package/scripts/plan-apply.mjs +843 -0
  133. package/scripts/preflight.mjs +51 -0
  134. package/scripts/reconcile.mjs +511 -0
  135. package/scripts/render-templates.mjs +245 -28
  136. package/scripts/schema-validate.mjs +132 -0
  137. package/scripts/section-compat.mjs +300 -0
  138. package/scripts/section-operations.mjs +508 -0
  139. package/scripts/sync-assets.mjs +22 -15
  140. package/scripts/view-assembly.mjs +291 -0
  141. package/scripts/view-selection.mjs +211 -0
  142. package/skills/sf-feature/SKILL.md +67 -0
  143. package/skills/sf-theme-gen/SKILL.md +12 -5
  144. package/skills/sf-view-gen/SKILL.md +123 -93
  145. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -1
  146. package/skills/sf-view-gen/assets/recipes/catalog.json +361 -0
  147. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  148. package/skills/sf-view-gen/assets/recipes/features.json +139 -0
  149. package/skills/sf-view-gen/assets/recipes/recipes.json +60 -5
  150. package/skills/sf-view-gen/assets/recipes/sections.json +24 -5
  151. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  152. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  153. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  154. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  155. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  156. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  157. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  158. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  159. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  160. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  161. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  162. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  163. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  164. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  165. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  166. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  167. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  168. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  169. package/skills/sf-view-gen/references/views.md +44 -10
  170. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  171. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  172. package/skills/sf-view-gen/scripts/validate.mjs +784 -32
  173. package/skills/storefront-workflow/SKILL.md +116 -0
  174. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  175. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  176. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  177. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  178. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  179. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
@@ -0,0 +1,26 @@
1
+ // Optional section fragment: pricing (mirrors recipes/sections.json) — shared contract.
2
+ // Zod mirror of the PricingTier/PricingSection1Props block contracts, shared by
3
+ // the server catalog (pricing.tsx) and the client registry (pricing.client.tsx).
4
+
5
+ import { z } from 'zod'
6
+
7
+ export const pricingTierSchema = z.object({
8
+ id: z.string(),
9
+ name: z.string(),
10
+ description: z.string(),
11
+ // Precio sin símbolo de moneda; el símbolo se pasa aparte en `currencySymbol`.
12
+ price: z.string(),
13
+ // Frecuencia de facturación, p. ej. '/mes'.
14
+ frequency: z.string(),
15
+ features: z.array(z.string()),
16
+ popular: z.boolean().optional(),
17
+ })
18
+
19
+ export const pricingPropsSchema = z.object({
20
+ sectionTitle: z.string().optional(),
21
+ sectionLabel: z.string().optional(),
22
+ currencySymbol: z.string().optional(),
23
+ popularBadgeLabel: z.string().optional(),
24
+ getStartedLabel: z.string().optional(),
25
+ tiers: z.array(pricingTierSchema),
26
+ })
@@ -0,0 +1,25 @@
1
+ // Optional section fragment: statistics (mirrors recipes/sections.json) — shared contract.
2
+ // Zod mirror of the StatisticItem/Statistic1Props block contracts, shared by
3
+ // the server catalog (statistics.tsx) and the client registry
4
+ // (statistics.client.tsx). The block expects `icon` as a Lucide component;
5
+ // specs ship the icon NAME, so it is a string here and the client registry
6
+ // resolves it via resolveIcon.
7
+
8
+ import { z } from 'zod'
9
+
10
+ export const statisticItemSchema = z.object({
11
+ id: z.string(),
12
+ title: z.string(),
13
+ value: z.string(),
14
+ description: z.string(),
15
+ progress: z.number(),
16
+ progressLabel: z.string(),
17
+ icon: z.string().optional(),
18
+ })
19
+
20
+ export const statisticsPropsSchema = z.object({
21
+ title: z.string().optional(),
22
+ badge: z.string().optional(),
23
+ description: z.string().optional(),
24
+ items: z.array(statisticItemSchema),
25
+ })
@@ -0,0 +1,23 @@
1
+ // Optional section fragment: testimonials (mirrors recipes/sections.json) — shared contract.
2
+ // Zod mirror of the Testimonial1Item/Testimonial1Props block contracts, shared
3
+ // by the server catalog (testimonials.tsx) and the client registry
4
+ // (testimonials.client.tsx).
5
+
6
+ import { z } from 'zod'
7
+
8
+ export const testimonialItemSchema = z.object({
9
+ id: z.string(),
10
+ name: z.string(),
11
+ role: z.string(),
12
+ avatar: z.string(),
13
+ content: z.string(),
14
+ rating: z.number(),
15
+ })
16
+
17
+ export const testimonialsPropsSchema = z.object({
18
+ title: z.string().optional(),
19
+ prevLabel: z.string().optional(),
20
+ nextLabel: z.string().optional(),
21
+ slideAriaLabel: z.string().optional(),
22
+ items: z.array(testimonialItemSchema),
23
+ })
@@ -4,15 +4,23 @@
4
4
  // The matching server-side catalog entry lives in statistics.tsx.
5
5
 
6
6
  import { Statistic1 as StatisticBlock } from '@thorprovider/blocks'
7
+ import { statisticsPropsSchema } from './schema.statistics'
8
+ import { resolveIcon } from '../../icon-map'
7
9
  import type { ViewRegistry } from '../types'
8
10
 
9
11
  export const registry: ViewRegistry = {
10
- Statistics: ({ props }) => (
11
- <StatisticBlock
12
- badge={props.badge as string}
13
- title={(props.title as string) ?? 'Números que nos respaldan'}
14
- description={props.description as string}
15
- items={props.items as never}
16
- />
17
- ),
12
+ Statistics: ({ props }) => {
13
+ const parsed = statisticsPropsSchema.parse(props)
14
+ return (
15
+ <StatisticBlock
16
+ badge={parsed.badge}
17
+ title={parsed.title ?? 'Números que nos respaldan'}
18
+ description={parsed.description}
19
+ items={parsed.items.map((item) => ({
20
+ ...item,
21
+ icon: resolveIcon(item.icon),
22
+ }))}
23
+ />
24
+ )
25
+ },
18
26
  }
@@ -2,18 +2,13 @@
2
2
  // Server-side part: element type + zod catalog entry. The component mappings
3
3
  // live in statistics.client.tsx ('use client').
4
4
 
5
- import { z } from 'zod'
5
+ import { statisticsPropsSchema } from './schema.statistics'
6
6
 
7
7
  export const statisticsSection = {
8
8
  elementType: 'Statistics',
9
9
  catalog: {
10
10
  Statistics: {
11
- props: z.object({
12
- title: z.string().optional(),
13
- badge: z.string().optional(),
14
- description: z.string().optional(),
15
- items: z.array(z.record(z.string(), z.unknown())),
16
- }),
11
+ props: statisticsPropsSchema,
17
12
  description: 'Key metrics or trust statistics.',
18
13
  },
19
14
  },
@@ -4,16 +4,20 @@
4
4
  // The matching server-side catalog entry lives in testimonials.tsx.
5
5
 
6
6
  import { Testimonial1 as TestimonialBlock } from '@thorprovider/blocks'
7
+ import { testimonialsPropsSchema } from './schema.testimonials'
7
8
  import type { ViewRegistry } from '../types'
8
9
 
9
10
  export const registry: ViewRegistry = {
10
- Testimonials: ({ props }) => (
11
- <TestimonialBlock
12
- title={(props.title as string) ?? 'Lo que dicen nuestros clientes'}
13
- items={props.items as never}
14
- prevLabel={(props.prevLabel as string) ?? 'Anterior'}
15
- nextLabel={(props.nextLabel as string) ?? 'Siguiente'}
16
- slideAriaLabel={(props.slideAriaLabel as string) ?? 'Testimonio'}
17
- />
18
- ),
11
+ Testimonials: ({ props }) => {
12
+ const parsed = testimonialsPropsSchema.parse(props)
13
+ return (
14
+ <TestimonialBlock
15
+ title={parsed.title ?? 'Lo que dicen nuestros clientes'}
16
+ items={parsed.items}
17
+ prevLabel={parsed.prevLabel ?? 'Anterior'}
18
+ nextLabel={parsed.nextLabel ?? 'Siguiente'}
19
+ slideAriaLabel={parsed.slideAriaLabel ?? 'Testimonio'}
20
+ />
21
+ )
22
+ },
19
23
  }
@@ -2,16 +2,13 @@
2
2
  // Server-side part: element type + zod catalog entry. The component mappings
3
3
  // live in testimonials.client.tsx ('use client').
4
4
 
5
- import { z } from 'zod'
5
+ import { testimonialsPropsSchema } from './schema.testimonials'
6
6
 
7
7
  export const testimonialsSection = {
8
8
  elementType: 'Testimonials',
9
9
  catalog: {
10
10
  Testimonials: {
11
- props: z.object({
12
- title: z.string().optional(),
13
- items: z.array(z.record(z.string(), z.unknown())),
14
- }),
11
+ props: testimonialsPropsSchema,
15
12
  description: 'Customer testimonials or social proof.',
16
13
  },
17
14
  },
@@ -1,15 +1,19 @@
1
1
  # View templates
2
2
 
3
3
  Each folder here is a self-contained view module. `npx @thorprovider/storefront create`
4
- copies `home/` at init; `/sf-add-view` copies one folder per added view.
4
+ generates `home/` at init; `npx @thorprovider/storefront view add <view>`
5
+ generates one view's derived files and reconciles the barrel. The CLI — not the
6
+ agent — writes the barrel and every derived file.
5
7
 
6
8
  ## Module shape
7
9
 
8
- Each view is the **two-file split** (RSC):
10
+ Each view is the **three-file split** (RSC):
9
11
 
10
12
  - `views/<view>/index.tsx` — pure Server Component module: name, elements,
11
13
  zod catalog, route, loader. Never imports values from `@json-render/react`
12
14
  or `@thorprovider/blocks`.
15
+ - `views/<view>/schema.ts` — pure Zod schemas mirroring the block contracts
16
+ the view renders (props only). No React, no `'use client'`, no Next imports.
13
17
  - `views/<view>/registry.tsx` — `'use client'`: exports
14
18
  `registry: ViewRegistry`, the component mappings
15
19
  (`Record<string, (ctx: BaseComponentProps<Record<string, unknown>>) => ReactNode>`).
@@ -39,14 +43,12 @@ view never edits them.
39
43
 
40
44
  ## Registering a view
41
45
 
42
- Add **one import and one entry** to `lib/<name>/views/index.ts`:
43
-
44
- ```ts
45
- import { home } from './home'
46
- import { product } from './product'
47
-
48
- export const views: ViewModule[] = [home, product]
49
- ```
46
+ The barrel `lib/<name>/views/index.ts` (server) and `lib/<name>/views/registry.ts`
47
+ (client) are **generated from the manifest by the CLI** on every reconcile. Do
48
+ not hand-edit them; add or remove a view with
49
+ `npx @thorprovider/storefront view add|remove <name>`. The CLI emits one import
50
+ and one entry per installed view, so a view removed from the manifest cannot
51
+ reappear through folder scanning.
50
52
 
51
53
  ## Variant substitution convention
52
54
 
@@ -67,3 +69,35 @@ import { ProductOverview5 as ProductOverviewBlock } from '@thorprovider/blocks'
67
69
  The alias is the element type's contract; the identifier is the presentation
68
70
  choice. Keeping props identical across variants is why the catalog declares the
69
71
  stable subset of each element's props.
72
+
73
+ ## Data contracts and icons
74
+
75
+ Two rules that keep generated views from crashing at render time:
76
+
77
+ 1. **Mappers are applied at loaders, never in registries.** Adapter canon does
78
+ not match block props 1:1 (e.g. a category may carry `productCount?: number`
79
+ or no count at all, and `ProductCategory2.itemCount` is **optional**). The
80
+ view's loader maps adapter data through `lib/<name>/mappers.ts`
81
+ (`toFeaturedProduct`, `toCategoryCard`, `toOverviewProduct`) before it reaches
82
+ state. Registries receive already-mapped, block-shaped props. When a value is
83
+ unknown (e.g. no category count), **omit** it — never fabricate a `0` default,
84
+ which would render a false "0 items". A genuine `0` from a richer source is
85
+ preserved.
86
+
87
+ 2. **Icons cross registries as components, via `icon-map`.** Several blocks
88
+ type icon props as `LucideIcon` (a component). A string like `'truck'`
89
+ bound directly renders a literal `<truck>` tag in the DOM. Registries
90
+ resolve icon names through `lib/<name>/icon-map.ts` (`resolveIcon(name)`,
91
+ kebab-case → `LucideIcon`, neutral `Circle` fallback). When adding a view
92
+ that uses a new icon, extend `icon-map.ts` with an entry — never inline a
93
+ string where a component is expected.
94
+
95
+ Supporting rules for the same goal:
96
+
97
+ - View schemas live in `views/<view>/schema.ts` and **mirror the block's Zod
98
+ contracts locally** — never import schemas from `@thorprovider/blocks` (the
99
+ scaffold runs Zod v4 while blocks ships v3; cross-major schema assignment
100
+ breaks `defineCatalog`).
101
+ - Registries **parse props through the view schema** (`schema.parse(props)`)
102
+ instead of casting bindings (`as never` / `as any` are forbidden in scaffold
103
+ code). Type errors on `parse` are the contract check.
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ // Semantic executable catalog: the single contract that states, per view slot
3
+ // and candidate block variant, whether the variant is `executable` or `held`,
4
+ // the block it renders, the canonical data shape it consumes, the events it
5
+ // emits, its controlled/uncontrolled state, its requirements, and an optional
6
+ // prop bridge from the canonical shape to the block props.
7
+ //
8
+ // The published shape lives in `recipes/catalog.schema.json`; this module loads
9
+ // that same artifact and adds only the invariants the supported schema subset
10
+ // cannot express (a held entry must carry a reason; a slot's default must be a
11
+ // declared variant; an unverified capability must name its owning change). It
12
+ // does not classify the initial set, derive `supported`, or wire any
13
+ // generator/introspection — those are owned by later tasks.
14
+
15
+ import { existsSync, readFileSync } from 'node:fs'
16
+ import { dirname, join } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+ import { validateAgainstSchema, validateSchemaSupported } from '../scripts/schema-validate.mjs'
19
+
20
+ const here = dirname(fileURLToPath(import.meta.url))
21
+
22
+ // Layout-agnostic catalog location: at the package root the catalog lives beside
23
+ // this module (`recipes/`); in an installed skill it is distributed under
24
+ // `skills/sf-view-gen/assets/recipes/` and addressed by relative path. Either
25
+ // layout holds the same synced artifact, so there is one catalog source. The
26
+ // `../scripts/schema-validate.mjs` import resolves to the package `scripts/` at
27
+ // the root and to the skill's own `scripts/` once distributed (same directory).
28
+ const catalogDir = existsSync(join(here, 'catalog.json'))
29
+ ? here
30
+ : join(here, '..', 'assets', 'recipes')
31
+
32
+ export const CATALOG_VERSION = 1
33
+ export const CATALOG_SCHEMA_ID = 'https://thorcommerce.dev/storefront/catalog.schema.json'
34
+
35
+ const catalogSchema = JSON.parse(readFileSync(join(catalogDir, 'catalog.schema.json'), 'utf8'))
36
+
37
+ function isPlainObject(value) {
38
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
39
+ }
40
+
41
+ function hasOwn(object, key) {
42
+ return Object.prototype.hasOwnProperty.call(object, key)
43
+ }
44
+
45
+ export function loadCatalogSchema() {
46
+ return catalogSchema
47
+ }
48
+
49
+ /**
50
+ * Load the published semantic catalog document (`recipes/catalog.json`) — the
51
+ * single source of the executable/held classification. Callers validate it with
52
+ * `validateCatalog`; classification is data, not logic (E1 task 1.2).
53
+ */
54
+ export function loadCatalog() {
55
+ return JSON.parse(readFileSync(join(catalogDir, 'catalog.json'), 'utf8'))
56
+ }
57
+
58
+ /**
59
+ * The slot variant entry schema, read from the published catalog schema so the
60
+ * per-entry contract has exactly one source. Throws loudly if the schema is
61
+ * reshaped, rather than silently validating against nothing.
62
+ */
63
+ function variantEntrySchema() {
64
+ const entry =
65
+ catalogSchema?.properties?.slots?.additionalProperties?.properties?.variants
66
+ ?.additionalProperties
67
+ if (!isPlainObject(entry)) {
68
+ throw new Error('catalog.schema.json does not declare a slot variant entry schema.')
69
+ }
70
+ return entry
71
+ }
72
+
73
+ /**
74
+ * Validate one slot variant entry against the published contract.
75
+ * Returns { ok, errors }.
76
+ */
77
+ export function validateCatalogEntry(entry) {
78
+ const errors = validateAgainstSchema(entry, variantEntrySchema(), 'catalog entry')
79
+ return { ok: errors.length === 0, errors }
80
+ }
81
+
82
+ /**
83
+ * Validate a complete catalog document against the published contract plus the
84
+ * invariants the schema subset cannot express. Returns { ok, errors }.
85
+ */
86
+ export function validateCatalog(catalog) {
87
+ if (!isPlainObject(catalog)) {
88
+ return { ok: false, errors: ['Catalog must be a JSON object.'] }
89
+ }
90
+ const errors = validateSchemaSupported(catalogSchema)
91
+ errors.push(...validateAgainstSchema(catalog, catalogSchema, 'catalog'))
92
+
93
+ const slots = isPlainObject(catalog.slots) ? catalog.slots : {}
94
+ for (const [slotId, slot] of Object.entries(slots)) {
95
+ if (!isPlainObject(slot)) continue
96
+ const variants = isPlainObject(slot.variants) ? slot.variants : {}
97
+ if (Object.keys(variants).length === 0) {
98
+ errors.push(`Slot "${slotId}" must declare at least one variant.`)
99
+ }
100
+ if (typeof slot.default === 'string' && !hasOwn(variants, slot.default)) {
101
+ errors.push(`Slot "${slotId}" default "${slot.default}" is not a declared variant.`)
102
+ }
103
+ for (const [variantName, entry] of Object.entries(variants)) {
104
+ if (!isPlainObject(entry)) continue
105
+ if (entry.status === 'held' && !(typeof entry.reason === 'string' && entry.reason.length > 0)) {
106
+ errors.push(`Held variant "${slotId}.${variantName}" must declare a non-empty reason.`)
107
+ }
108
+ }
109
+ }
110
+
111
+ // An unverified capability is held: it must name the change that owns its
112
+ // implementation, so a consumer can see that the capability is not available
113
+ // and who will make it available. This is the honest gate the schema subset
114
+ // cannot express (E1 task 4.2 / CU-04).
115
+ const capabilities = isPlainObject(catalog.capabilities) ? catalog.capabilities : {}
116
+ for (const [capabilityId, capability] of Object.entries(capabilities)) {
117
+ if (!isPlainObject(capability)) continue
118
+ if (
119
+ capability.verification === 'unverified' &&
120
+ !(typeof capability.owner === 'string' && capability.owner.length > 0)
121
+ ) {
122
+ errors.push(
123
+ `Unverified capability "${capabilityId}" must declare the owning change in "owner".`
124
+ )
125
+ }
126
+ }
127
+ return { ok: errors.length === 0, errors }
128
+ }
@@ -0,0 +1,132 @@
1
+ // Minimal, dependency-free validator for the JSON Schema subset used by the
2
+ // published storefront schemas. It supports exactly the keywords those schemas
3
+ // use (type, required, properties, items, additionalProperties, minLength,
4
+ // enum) so a document is validated against the same artifact the package
5
+ // publishes, with no duplicated hand-rolled shape contract.
6
+
7
+ // Validation keywords this validator understands. Annotation-only keywords are
8
+ // allowed through without affecting the result. Any other keyword is reported
9
+ // as unsupported so a future schema cannot silently be treated as valid.
10
+ const VALIDATION_KEYWORDS = new Set([
11
+ 'type',
12
+ 'enum',
13
+ 'minLength',
14
+ 'required',
15
+ 'properties',
16
+ 'additionalProperties',
17
+ 'items',
18
+ ])
19
+ // `default` is an annotation: it documents a field's default value but does not
20
+ // affect validation. Feature config contracts use it so `normalizeConfig` can
21
+ // apply declared defaults without a second source of truth.
22
+ const ANNOTATION_KEYWORDS = new Set(['$schema', '$id', 'title', 'description', 'default'])
23
+ const SUPPORTED_KEYWORDS = new Set([...VALIDATION_KEYWORDS, ...ANNOTATION_KEYWORDS])
24
+
25
+ function typeOf(value) {
26
+ if (Array.isArray(value)) return 'array'
27
+ if (value === null) return 'null'
28
+ return typeof value
29
+ }
30
+
31
+ function isObject(value) {
32
+ return typeOf(value) === 'object'
33
+ }
34
+
35
+ // Own-property lookup: inherited keys (`constructor`, `__proto__`, `toString`)
36
+ // must never be mistaken for declared schema properties or present data, or an
37
+ // `additionalProperties` constraint could be bypassed.
38
+ function hasOwn(object, key) {
39
+ return Object.prototype.hasOwnProperty.call(object, key)
40
+ }
41
+
42
+ // Walk the schema itself (independent of any value) so an unsupported keyword
43
+ // anywhere in the tree fails clearly instead of being ignored.
44
+ function collectUnsupportedKeywords(schema, path, errors) {
45
+ if (!isObject(schema)) return
46
+ for (const keyword of Object.keys(schema)) {
47
+ if (!SUPPORTED_KEYWORDS.has(keyword)) {
48
+ errors.push(`${path || 'manifest'} uses an unsupported schema keyword "${keyword}".`)
49
+ }
50
+ }
51
+ const properties = isObject(schema.properties) ? schema.properties : {}
52
+ for (const key of Object.keys(properties)) {
53
+ collectUnsupportedKeywords(properties[key], `${path || 'manifest'}.${key}`, errors)
54
+ }
55
+ if (isObject(schema.items)) {
56
+ collectUnsupportedKeywords(schema.items, `${path || 'manifest'}[]`, errors)
57
+ }
58
+ if (isObject(schema.additionalProperties)) {
59
+ collectUnsupportedKeywords(schema.additionalProperties, `${path || 'manifest'}.*`, errors)
60
+ }
61
+ }
62
+
63
+ export function validateSchemaSupported(schema) {
64
+ const errors = []
65
+ collectUnsupportedKeywords(schema, '', errors)
66
+ return errors
67
+ }
68
+
69
+ /**
70
+ * Validate `value` against `schema`, returning a list of human-readable errors.
71
+ * Each error is prefixed with the dotted path of the offending field (the root
72
+ * uses `manifest`), so callers can name the bad input precisely.
73
+ */
74
+ export function validateAgainstSchema(value, schema, path = '') {
75
+ const errors = []
76
+ const label = path || 'manifest'
77
+
78
+ if (!isObject(schema)) {
79
+ errors.push(`${label} uses an invalid schema (expected an object).`)
80
+ return errors
81
+ }
82
+
83
+ // The schema tree is checked once, at the root, so unsupported keywords fail
84
+ // closed regardless of which fields the value happens to contain.
85
+ if (!path) errors.push(...validateSchemaSupported(schema))
86
+
87
+ if (schema.type) {
88
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type]
89
+ if (!types.includes(typeOf(value))) {
90
+ errors.push(`${label} must be of type ${types.join(' | ')}.`)
91
+ return errors
92
+ }
93
+ }
94
+
95
+ if (Array.isArray(schema.enum) && !schema.enum.includes(value)) {
96
+ errors.push(`${label} must be one of: ${schema.enum.join(', ')}.`)
97
+ }
98
+
99
+ if (
100
+ typeof value === 'string' &&
101
+ typeof schema.minLength === 'number' &&
102
+ value.length < schema.minLength
103
+ ) {
104
+ errors.push(`${label} must be at least ${schema.minLength} character(s).`)
105
+ }
106
+
107
+ if (isObject(value)) {
108
+ for (const key of schema.required ?? []) {
109
+ if (!hasOwn(value, key)) errors.push(`${label} is missing required property "${key}".`)
110
+ }
111
+ const properties = isObject(schema.properties) ? schema.properties : {}
112
+ const additional = schema.additionalProperties
113
+ for (const key of Object.keys(value)) {
114
+ const childPath = path ? `${path}.${key}` : key
115
+ if (hasOwn(properties, key)) {
116
+ errors.push(...validateAgainstSchema(value[key], properties[key], childPath))
117
+ } else if (isObject(additional)) {
118
+ errors.push(...validateAgainstSchema(value[key], additional, childPath))
119
+ } else if (additional === false) {
120
+ errors.push(`${label} has unknown property "${key}".`)
121
+ }
122
+ }
123
+ }
124
+
125
+ if (Array.isArray(value) && isObject(schema.items)) {
126
+ value.forEach((item, index) => {
127
+ errors.push(...validateAgainstSchema(item, schema.items, `${path}[${index}]`))
128
+ })
129
+ }
130
+
131
+ return errors
132
+ }