@thorprovider/storefront 0.3.1 → 0.4.0-mvp.2

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 (263) hide show
  1. package/README.md +321 -152
  2. package/VENDOR.md +69 -0
  3. package/assets/openspec/config.yaml +38 -0
  4. package/assets/templates/AGENTS.md +76 -0
  5. package/assets/templates/app/app/[[...slug]]/page.tsx +54 -2
  6. package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
  7. package/assets/templates/app/app/globals.css +5 -8
  8. package/assets/templates/app/app/layout.tsx +17 -7
  9. package/assets/templates/app/app/robots.ts +19 -0
  10. package/assets/templates/app/app/sitemap.ts +71 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +612 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  13. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +50 -57
  14. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +679 -0
  15. package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
  16. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +197 -0
  17. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  18. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  20. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +264 -51
  21. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +6 -3
  22. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +136 -0
  23. package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
  24. package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  27. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +120 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +38 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +129 -10
  30. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  31. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +191 -0
  32. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  34. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +161 -0
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  39. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +18 -0
  40. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  41. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  42. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
  43. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
  44. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  45. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  46. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
  47. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  48. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  49. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  50. package/assets/templates/features/checkout/handlers.ts +159 -0
  51. package/assets/templates/features/checkout/state.ts +61 -0
  52. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  53. package/assets/templates/features/customer-auth/state.ts +45 -0
  54. package/assets/templates/features/recommendations/state.ts +11 -0
  55. package/assets/templates/views/account/content.ts +35 -0
  56. package/assets/templates/views/account/index.tsx +114 -0
  57. package/assets/templates/views/account/registry.tsx +439 -0
  58. package/assets/templates/views/account/schema.ts +53 -0
  59. package/assets/templates/views/cart/content.ts +11 -0
  60. package/assets/templates/views/cart/data.ts +23 -0
  61. package/assets/templates/views/cart/index.tsx +53 -0
  62. package/assets/templates/views/cart/props.ts +12 -0
  63. package/assets/templates/views/cart/registry.tsx +81 -0
  64. package/assets/templates/views/cart/schema.ts +52 -0
  65. package/assets/templates/views/category/assembly/assembly.json +29 -0
  66. package/assets/templates/views/category/assembly/index.skeleton.tsx +251 -0
  67. package/assets/templates/views/category/assembly/registry.skeleton.tsx +34 -0
  68. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
  69. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
  70. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
  71. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
  72. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  73. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  74. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  75. package/assets/templates/views/category/content.ts +30 -0
  76. package/assets/templates/views/category/data.ts +12 -0
  77. package/assets/templates/views/category/index.tsx +299 -0
  78. package/assets/templates/views/category/props.ts +23 -0
  79. package/assets/templates/views/category/registry.tsx +122 -0
  80. package/assets/templates/views/category/schema.ts +91 -0
  81. package/assets/templates/views/checkout/content.ts +105 -0
  82. package/assets/templates/views/checkout/data.ts +23 -0
  83. package/assets/templates/views/checkout/index.tsx +111 -0
  84. package/assets/templates/views/checkout/props.ts +49 -0
  85. package/assets/templates/views/checkout/registry.tsx +217 -0
  86. package/assets/templates/views/checkout/schema.ts +229 -0
  87. package/assets/templates/views/home/assembly/assembly.json +33 -0
  88. package/assets/templates/views/home/assembly/index.skeleton.tsx +90 -0
  89. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  90. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  91. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  92. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  93. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  94. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  95. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  96. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  97. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  98. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  99. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  100. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  101. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  102. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  103. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  104. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  105. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  106. package/assets/templates/views/home/content.ts +70 -0
  107. package/assets/templates/views/home/data.ts +24 -0
  108. package/assets/templates/views/home/index.tsx +57 -85
  109. package/assets/templates/views/home/props.ts +22 -0
  110. package/assets/templates/views/home/registry.tsx +38 -22
  111. package/assets/templates/views/home/schema.ts +93 -0
  112. package/assets/templates/views/product/content.ts +25 -0
  113. package/assets/templates/views/product/data.ts +50 -0
  114. package/assets/templates/views/product/index.tsx +148 -0
  115. package/assets/templates/views/product/props.ts +27 -0
  116. package/assets/templates/views/product/registry.tsx +103 -0
  117. package/assets/templates/views/product/schema.ts +121 -0
  118. package/assets/templates/views/static/content.ts +99 -0
  119. package/assets/templates/views/static/index.tsx +60 -0
  120. package/assets/templates/views/static/registry.tsx +66 -0
  121. package/assets/templates/views/static/schema.ts +45 -0
  122. package/bin/storefront.js +2424 -148
  123. package/examples/merchandising/category-led.json +37 -0
  124. package/examples/merchandising/collection-led.json +37 -0
  125. package/package.json +15 -9
  126. package/recipes/archetypes.json +28 -14
  127. package/recipes/catalog.json +400 -0
  128. package/recipes/catalog.mjs +128 -0
  129. package/recipes/catalog.schema.json +64 -0
  130. package/recipes/feature.schema.json +89 -0
  131. package/recipes/features.json +162 -0
  132. package/recipes/manifest.schema.json +69 -0
  133. package/recipes/operations.schema.json +43 -0
  134. package/recipes/recipe.schema.json +11 -2
  135. package/recipes/recipes.json +76 -19
  136. package/recipes/sections.json +34 -5
  137. package/recipes/validate.mjs +838 -32
  138. package/scripts/candidate.mjs +236 -0
  139. package/scripts/composers.mjs +160 -0
  140. package/scripts/env-file.mjs +86 -0
  141. package/scripts/file-classes.mjs +121 -0
  142. package/scripts/logical-controls.mjs +61 -0
  143. package/scripts/manifest.mjs +298 -0
  144. package/scripts/migrate-english-routes.mjs +276 -0
  145. package/scripts/operation-registry.mjs +256 -0
  146. package/scripts/operations.mjs +720 -0
  147. package/scripts/ownership-conflict.mjs +356 -0
  148. package/scripts/pinning.mjs +573 -0
  149. package/scripts/plan-apply.mjs +987 -0
  150. package/scripts/preflight.mjs +51 -0
  151. package/scripts/reconcile.mjs +549 -0
  152. package/scripts/render-templates.mjs +338 -68
  153. package/scripts/schema-validate.mjs +132 -0
  154. package/scripts/section-compat.mjs +300 -0
  155. package/scripts/section-config.mjs +253 -0
  156. package/scripts/section-operations.mjs +508 -0
  157. package/scripts/starter.mjs +17 -0
  158. package/scripts/sync-assets.mjs +22 -15
  159. package/scripts/theme/color.mjs +185 -0
  160. package/scripts/theme/css-theme.mjs +233 -0
  161. package/scripts/theme/fonts.mjs +123 -0
  162. package/scripts/theme/import-export.mjs +125 -0
  163. package/scripts/theme/preset.mjs +93 -0
  164. package/scripts/theme/theme-plan.mjs +292 -0
  165. package/scripts/theme/tokens.mjs +137 -0
  166. package/scripts/view-assembly.mjs +337 -0
  167. package/scripts/view-selection.mjs +211 -0
  168. package/skills/sf-feature/SKILL.md +80 -0
  169. package/skills/sf-setup/SKILL.md +79 -0
  170. package/skills/sf-theme-gen/SKILL.md +100 -30
  171. package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
  172. package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
  173. package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
  174. package/skills/sf-view-gen/SKILL.md +146 -93
  175. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -14
  176. package/skills/sf-view-gen/assets/recipes/catalog.json +400 -0
  177. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  178. package/skills/sf-view-gen/assets/recipes/features.json +162 -0
  179. package/skills/sf-view-gen/assets/recipes/recipes.json +76 -19
  180. package/skills/sf-view-gen/assets/recipes/sections.json +34 -5
  181. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  182. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  183. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  184. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  185. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  186. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  187. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  188. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  189. package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
  190. package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
  191. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  192. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  193. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  194. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  195. package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
  196. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  197. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  198. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  199. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  200. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  201. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  202. package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
  203. package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
  204. package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
  205. package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
  206. package/skills/sf-view-gen/references/views.md +58 -33
  207. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  208. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  209. package/skills/sf-view-gen/scripts/validate.mjs +838 -32
  210. package/skills/storefront-workflow/SKILL.md +179 -0
  211. package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
  212. package/CHANGELOG.md +0 -61
  213. package/commands/sf-add-view.md +0 -26
  214. package/commands/sf-theme.md +0 -20
  215. package/commands/sf-view.md +0 -25
  216. package/skills/building-storefronts/SKILL.md +0 -178
  217. package/skills/building-storefronts/references/frontend-integration.md +0 -229
  218. package/skills/json-render-remotion/SKILL.md +0 -111
  219. package/skills/json-render-solid/SKILL.md +0 -204
  220. package/skills/nextjs-shadcn/SKILL.md +0 -367
  221. package/skills/nextjs-shadcn/references/architecture.md +0 -499
  222. package/skills/nextjs-shadcn/references/project-setup.md +0 -162
  223. package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
  224. package/skills/nextjs-shadcn/references/sidebar.md +0 -274
  225. package/skills/nextjs-shadcn/references/styling.md +0 -555
  226. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  227. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  228. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  229. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  230. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  231. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
  232. package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +0 -168
  233. package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +0 -49
  234. package/skills/shadcn-component-discovery/SKILL.md +0 -273
  235. package/skills/shadcn-component-discovery/references/registries.md +0 -226
  236. package/skills/shadcn-theming/SKILL.md +0 -104
  237. package/skills/storefront-best-practices/SKILL.md +0 -421
  238. package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
  239. package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
  240. package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
  241. package/skills/storefront-best-practices/reference/components/footer.md +0 -112
  242. package/skills/storefront-best-practices/reference/components/hero.md +0 -241
  243. package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
  244. package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
  245. package/skills/storefront-best-practices/reference/components/popups.md +0 -221
  246. package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
  247. package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
  248. package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
  249. package/skills/storefront-best-practices/reference/components/search.md +0 -101
  250. package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
  251. package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
  252. package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
  253. package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
  254. package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
  255. package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
  256. package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
  257. package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
  258. package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
  259. package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
  260. package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
  261. package/skills/storefront-best-practices/reference/medusa.md +0 -307
  262. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
  263. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env node
2
+ // Deterministic normalization and persisted legacy-id migration for a view's
3
+ // ordered `views[].sections` composition (E3 task 1.5, CU-15/CU-16).
4
+ //
5
+ // The pure entry point maps a declared section list — possibly missing, empty,
6
+ // or in the pre-E3 legacy `{ name, variant }` shape — to the ordered
7
+ // `{ id, type, variant? }` model:
8
+ // * a registered presentation slot is `{ id: "slot:<slot>", type: "<slot>" }`
9
+ // and is mandatory: the normalized list always contains every registered
10
+ // slot exactly once, in registry order unless the declared list reorders it;
11
+ // * a legacy entry migrates to `{ id: "<name>-<n>", type: "<name>",
12
+ // variant? }`, where `n` is the 1-based position among the view's legacy
13
+ // entries in declared order, appended after the slots;
14
+ // * anything that cannot be adapted safely (a held/disallowed legacy type, a
15
+ // reserved-prefixed id, a duplicate) is refused with a diagnostic and no
16
+ // output, so the caller writes nothing.
17
+ //
18
+ // It is a pure function over its arguments (same input -> identical output) and
19
+ // depends only on the Node standard library. Wiring it into the manifest
20
+ // load/validation and CLI paths is owned by the later migration/operation tasks
21
+ // (§3/§5); this module does not touch the filesystem beyond reading the registry
22
+ // for the optional convenience wrapper.
23
+
24
+ import { existsSync, readFileSync } from 'node:fs'
25
+ import { dirname, join } from 'node:path'
26
+ import { fileURLToPath } from 'node:url'
27
+
28
+ const here = dirname(fileURLToPath(import.meta.url))
29
+
30
+ function isPlainObject(value) {
31
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
32
+ }
33
+
34
+ function isNonEmptyString(value) {
35
+ return typeof value === 'string' && value.length > 0
36
+ }
37
+
38
+ function slotEntry(slot) {
39
+ return { id: `slot:${slot}`, type: slot }
40
+ }
41
+
42
+ // Copy only the declared fields, so an entry carrying an unknown property never
43
+ // leaks into the normalized output and the input object is never mutated.
44
+ function copyEntry(entry) {
45
+ return {
46
+ id: entry.id,
47
+ type: entry.type,
48
+ ...(isNonEmptyString(entry.variant) ? { variant: entry.variant } : {}),
49
+ }
50
+ }
51
+
52
+ // A legacy `{ name, variant? }` entry (or its bare-string shorthand) — the
53
+ // pre-E3 shape that has no id/type yet.
54
+ export function isLegacySectionEntry(entry) {
55
+ const value = typeof entry === 'string' ? { name: entry } : entry
56
+ if (!isPlainObject(value)) return false
57
+ if (isNonEmptyString(value.id) || isNonEmptyString(value.type)) return false
58
+ return isNonEmptyString(value.name)
59
+ }
60
+
61
+ // A registered-slot entry: the reserved `id === "slot:" + type` form.
62
+ export function isSlotSectionEntry(entry) {
63
+ return (
64
+ isPlainObject(entry) &&
65
+ isNonEmptyString(entry.id) &&
66
+ isNonEmptyString(entry.type) &&
67
+ entry.id === `slot:${entry.type}`
68
+ )
69
+ }
70
+
71
+ // An editorial instance: a persisted id that must not start with `slot:`.
72
+ export function isEditorialSectionEntry(entry) {
73
+ return (
74
+ isPlainObject(entry) &&
75
+ isNonEmptyString(entry.id) &&
76
+ isNonEmptyString(entry.type) &&
77
+ !entry.id.startsWith('slot:')
78
+ )
79
+ }
80
+
81
+ // Discriminate a declared entry: 'slot' (reserved id form), 'editorial'
82
+ // (persisted non-reserved id), 'legacy' (`{ name }`), or 'invalid'.
83
+ export function classifySectionEntry(entry) {
84
+ const value = typeof entry === 'string' ? { name: entry } : entry
85
+ if (!isPlainObject(value)) return 'invalid'
86
+ const hasId = isNonEmptyString(value.id)
87
+ const hasType = isNonEmptyString(value.type)
88
+ const hasName = isNonEmptyString(value.name)
89
+ if (hasId && hasType && !hasName) {
90
+ return value.id.startsWith('slot:') ? 'slot' : 'editorial'
91
+ }
92
+ if (hasName && !hasId && !hasType) return 'legacy'
93
+ return 'invalid'
94
+ }
95
+
96
+ /**
97
+ * Normalize one view's declared sections to the ordered composition model.
98
+ *
99
+ * @param {string} viewName
100
+ * @param {unknown[]} declaredSections the manifest `views[].sections` (may be
101
+ * missing/empty, legacy, new-form, or a mix)
102
+ * @param {string[]} registeredSlots the view's ordered core slots
103
+ * @param {{ allowedSections?: string[], maxSections?: number }} [options]
104
+ * @returns {{ ok: boolean, sections: object[] | null, diagnostics: object[] }}
105
+ * On failure `sections` is null (no output) and `diagnostics` is non-empty.
106
+ */
107
+ export function normalizeViewSections(viewName, declaredSections, registeredSlots, options = {}) {
108
+ const slots = Array.isArray(registeredSlots) ? registeredSlots.filter(isNonEmptyString) : []
109
+ const allowedSections = Array.isArray(options.allowedSections) ? options.allowedSections : null
110
+ const maxSections = typeof options.maxSections === 'number' ? options.maxSections : null
111
+ const declared = Array.isArray(declaredSections) ? declaredSections : []
112
+
113
+ const diagnostics = []
114
+ const push = (code, message, path) =>
115
+ diagnostics.push({ code, severity: 'error', message, ...(path ? { path } : {}) })
116
+
117
+ // Missing/empty composition: the registered slots in current render order and
118
+ // no editorial instance.
119
+ if (declared.length === 0) {
120
+ return { ok: true, sections: slots.map(slotEntry), diagnostics: [] }
121
+ }
122
+
123
+ const emitted = []
124
+ const seenIds = new Set()
125
+ const emittedSlots = new Set()
126
+ const legacy = []
127
+
128
+ declared.forEach((raw, index) => {
129
+ const path = `views["${viewName}"].sections[${index}]`
130
+ const entry = typeof raw === 'string' ? { name: raw } : raw
131
+ const kind = classifySectionEntry(entry)
132
+
133
+ if (kind === 'slot') {
134
+ if (entry.id !== `slot:${entry.type}`) {
135
+ push(
136
+ 'E_RESERVED_ID',
137
+ `${path} reserved slot id "${entry.id}" must equal "slot:${entry.type}".`,
138
+ path,
139
+ )
140
+ return
141
+ }
142
+ if (!slots.includes(entry.type)) {
143
+ push('E_UNKNOWN_HOST', `${path} names unregistered slot "${entry.type}".`, path)
144
+ return
145
+ }
146
+ if (emittedSlots.has(entry.type) || seenIds.has(entry.id)) {
147
+ push('E_DUPLICATE_ID', `${path} duplicate section id "${entry.id}".`, path)
148
+ return
149
+ }
150
+ emitted.push(copyEntry(entry))
151
+ emittedSlots.add(entry.type)
152
+ seenIds.add(entry.id)
153
+ return
154
+ }
155
+
156
+ if (kind === 'editorial') {
157
+ if (seenIds.has(entry.id)) {
158
+ push('E_DUPLICATE_ID', `${path} duplicate section id "${entry.id}".`, path)
159
+ return
160
+ }
161
+ if (allowedSections && !allowedSections.includes(entry.type)) {
162
+ push(
163
+ 'E_DISALLOWED_TYPE',
164
+ `${path} section type "${entry.type}" is not an allowed section for view "${viewName}".`,
165
+ path,
166
+ )
167
+ return
168
+ }
169
+ emitted.push(copyEntry(entry))
170
+ seenIds.add(entry.id)
171
+ return
172
+ }
173
+
174
+ if (kind === 'legacy') {
175
+ legacy.push({ entry, path })
176
+ return
177
+ }
178
+
179
+ push(
180
+ 'E_UNKNOWN_SECTION_SHAPE',
181
+ `${path} is neither a legacy { name } entry nor an { id, type } entry.`,
182
+ path,
183
+ )
184
+ })
185
+
186
+ // Every registered slot is mandatory: append any the declared list omitted, in
187
+ // registry order, so the normalized composition always contains each slot
188
+ // exactly once.
189
+ for (const slot of slots) {
190
+ if (!emittedSlots.has(slot)) {
191
+ const entry = slotEntry(slot)
192
+ emitted.push(entry)
193
+ emittedSlots.add(slot)
194
+ seenIds.add(entry.id)
195
+ }
196
+ }
197
+
198
+ // Persisted legacy migration: a concrete `<name>-<n>` id derived once from the
199
+ // 1-based position among the view's legacy entries, appended after the slots.
200
+ legacy.forEach(({ entry, path }, index) => {
201
+ const id = `${entry.name}-${index + 1}`
202
+ if (id.startsWith('slot:')) {
203
+ push(
204
+ 'E_RESERVED_ID',
205
+ `${path} legacy name "${entry.name}" would mint a reserved id "${id}".`,
206
+ path,
207
+ )
208
+ return
209
+ }
210
+ if (allowedSections && !allowedSections.includes(entry.name)) {
211
+ push(
212
+ 'E_DISALLOWED_TYPE',
213
+ `${path} legacy section "${entry.name}" is not an allowed section for view "${viewName}" (held or missing a verified bridge/provenance or data source); it cannot be adapted.`,
214
+ path,
215
+ )
216
+ return
217
+ }
218
+ if (seenIds.has(id)) {
219
+ push(
220
+ 'E_DUPLICATE_ID',
221
+ `${path} legacy section "${entry.name}" would mint duplicate id "${id}".`,
222
+ path,
223
+ )
224
+ return
225
+ }
226
+ emitted.push({
227
+ id,
228
+ type: entry.name,
229
+ ...(isNonEmptyString(entry.variant) ? { variant: entry.variant } : {}),
230
+ })
231
+ seenIds.add(id)
232
+ })
233
+
234
+ if (diagnostics.length > 0) {
235
+ return { ok: false, sections: null, diagnostics }
236
+ }
237
+
238
+ const editorialCount = emitted.filter((entry) => isEditorialSectionEntry(entry)).length
239
+ if (maxSections !== null && editorialCount > maxSections) {
240
+ return {
241
+ ok: false,
242
+ sections: null,
243
+ diagnostics: [
244
+ {
245
+ code: 'E_SECTION_LIMIT',
246
+ severity: 'error',
247
+ message: `View "${viewName}" normalized to ${editorialCount} editorial instances, exceeding maxSections ${maxSections}.`,
248
+ },
249
+ ],
250
+ }
251
+ }
252
+
253
+ return { ok: true, sections: emitted, diagnostics: [] }
254
+ }
255
+
256
+ // Package-side seam over the view registry: resolve a view's `registeredSlots`,
257
+ // `allowedSections`, and `maxSections` and normalize its declared sections. The
258
+ // registry is read from the package (`recipes/`) or the installed-skill assets,
259
+ // so the caller passes only the view name and its declared list.
260
+ function resolveRegistryPath() {
261
+ const candidates = [
262
+ join(here, '..', 'recipes', 'recipes.json'),
263
+ join(here, 'recipes.json'),
264
+ join(here, '..', 'assets', 'recipes', 'recipes.json'),
265
+ ]
266
+ return candidates.find((candidate) => existsSync(candidate)) ?? null
267
+ }
268
+
269
+ let cachedRecipes = null
270
+
271
+ function loadRecipes() {
272
+ if (!cachedRecipes) {
273
+ const path = resolveRegistryPath()
274
+ if (!path) throw new Error('section-compat: could not locate the view recipe registry.')
275
+ cachedRecipes = JSON.parse(readFileSync(path, 'utf8'))
276
+ }
277
+ return cachedRecipes
278
+ }
279
+
280
+
281
+ export function normalizeViewSectionsForView(viewName, declaredSections) {
282
+ const recipe = loadRecipes().views.find((view) => view.name === viewName) ?? null
283
+ if (!recipe) {
284
+ return {
285
+ ok: false,
286
+ sections: null,
287
+ diagnostics: [
288
+ {
289
+ code: 'E_UNKNOWN_VIEW',
290
+ severity: 'error',
291
+ message: `Unknown view "${viewName}".`,
292
+ },
293
+ ],
294
+ }
295
+ }
296
+ return normalizeViewSections(viewName, declaredSections, recipe.registeredSlots ?? [], {
297
+ allowedSections: recipe.allowedSections ?? [],
298
+ maxSections: recipe.maxSections,
299
+ })
300
+ }
@@ -0,0 +1,253 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ // User-owned source configuration audit.
5
+ //
6
+ // Data-bound section instances (for example the `products` type) resolve their
7
+ // content from a canonical query the user declares in a user-owned `data.ts`
8
+ // export named `sectionSources`. The manifest records only the structural
9
+ // instance (`{ id, type, variant }`); the query lives in the user file. `sync`
10
+ // never patches user files, so a freshly added instance is intentionally
11
+ // unconfigured until the user supplies its entry. `doctor` reports those gaps
12
+ // with the file and instance id so the repair is actionable.
13
+ //
14
+ // The audit is a conservative static read of the object literal: it extracts
15
+ // the top-level keys of `sectionSources` and whether each value is an object.
16
+ // It never executes the user file and never rewrites it. Deep validity (the
17
+ // canonical query fields) is enforced at build/runtime by the runtime
18
+ // `sectionSourceSchema`, which surfaces an `unconfigured` state for the
19
+ // instance rather than failing the page.
20
+
21
+ /** Locate the `sectionSources` declaration and return the index of its name. */
22
+ function findSectionSourcesIndex(source) {
23
+ const match = /\bsectionSources\b/.exec(source)
24
+ return match ? match.index : -1
25
+ }
26
+
27
+ /**
28
+ * Match the brace that opens at `open` and return the index of its closer,
29
+ * ignoring braces inside string literals and comments. Returns -1 when
30
+ * unbalanced.
31
+ */
32
+ function matchBrace(source, open) {
33
+ let depth = 0
34
+ let i = open
35
+ while (i < source.length) {
36
+ const ch = source[i]
37
+ if (ch === '/' && source[i + 1] === '/') {
38
+ i = source.indexOf('\n', i)
39
+ if (i < 0) return -1
40
+ continue
41
+ }
42
+ if (ch === '/' && source[i + 1] === '*') {
43
+ const end = source.indexOf('*/', i + 2)
44
+ if (end < 0) return -1
45
+ i = end + 2
46
+ continue
47
+ }
48
+ if (ch === '"' || ch === "'" || ch === '`') {
49
+ i = skipString(source, i)
50
+ if (i < 0) return -1
51
+ continue
52
+ }
53
+ if (ch === '{') depth++
54
+ else if (ch === '}') {
55
+ depth--
56
+ if (depth === 0) return i
57
+ }
58
+ i++
59
+ }
60
+ return -1
61
+ }
62
+
63
+ /** Advance past a string literal starting at `start`; returns the index after it. */
64
+ function skipString(source, start) {
65
+ const quote = source[start]
66
+ let i = start + 1
67
+ while (i < source.length) {
68
+ if (source[i] === '\\') {
69
+ i += 2
70
+ continue
71
+ }
72
+ if (source[i] === quote) return i + 1
73
+ i++
74
+ }
75
+ return -1
76
+ }
77
+
78
+ /** Advance past a value starting at `start`, returning the index at depth 0 after it. */
79
+ function skipValue(source, start) {
80
+ let depth = 0
81
+ let i = start
82
+ while (i < source.length) {
83
+ const ch = source[i]
84
+ if (ch === '/' && source[i + 1] === '/') {
85
+ const nl = source.indexOf('\n', i)
86
+ if (nl < 0) return source.length
87
+ i = nl + 1
88
+ continue
89
+ }
90
+ if (ch === '/' && source[i + 1] === '*') {
91
+ const end = source.indexOf('*/', i + 2)
92
+ if (end < 0) return source.length
93
+ i = end + 2
94
+ continue
95
+ }
96
+ if (ch === '"' || ch === "'" || ch === '`') {
97
+ const next = skipString(source, i)
98
+ if (next < 0) return source.length
99
+ i = next
100
+ continue
101
+ }
102
+ if (ch === '{' || ch === '[' || ch === '(') depth++
103
+ else if (ch === '}' || ch === ']' || ch === ')') {
104
+ if (depth === 0) return i
105
+ depth--
106
+ } else if (ch === ',' && depth === 0) return i
107
+ i++
108
+ }
109
+ return source.length
110
+ }
111
+
112
+ /** Read an object key at `start` (quoted string or bare identifier). */
113
+ function readKey(source, start) {
114
+ const ch = source[start]
115
+ if (ch === '"' || ch === "'" || ch === '`') {
116
+ const end = skipString(source, start)
117
+ if (end < 0) return null
118
+ return { key: source.slice(start + 1, end - 1), next: end }
119
+ }
120
+ let i = start
121
+ while (i < source.length && /[A-Za-z0-9_$]/.test(source[i])) i++
122
+ if (i === start) return null
123
+ return { key: source.slice(start, i), next: i }
124
+ }
125
+
126
+ function skipWhitespaceAndComments(source, start) {
127
+ let i = start
128
+ while (i < source.length) {
129
+ const ch = source[i]
130
+ if (/\s/.test(ch)) {
131
+ i++
132
+ continue
133
+ }
134
+ if (ch === '/' && source[i + 1] === '/') {
135
+ const nl = source.indexOf('\n', i)
136
+ if (nl < 0) return source.length
137
+ i = nl + 1
138
+ continue
139
+ }
140
+ if (ch === '/' && source[i + 1] === '*') {
141
+ const end = source.indexOf('*/', i + 2)
142
+ if (end < 0) return source.length
143
+ i = end + 2
144
+ continue
145
+ }
146
+ break
147
+ }
148
+ return i
149
+ }
150
+
151
+ /**
152
+ * Extract the top-level entries of the `sectionSources` object literal in a
153
+ * `data.ts` source. Returns a Map of key -> `{ objectValue }` (whether the
154
+ * declared value is an object literal). An absent or unparsable declaration
155
+ * yields an empty Map.
156
+ */
157
+ export function extractSectionSourceEntries(source) {
158
+ const entries = new Map()
159
+ if (typeof source !== 'string') return entries
160
+ const declIndex = findSectionSourcesIndex(source)
161
+ if (declIndex < 0) return entries
162
+ const bodyStart = source.indexOf('{', declIndex)
163
+ if (bodyStart < 0) return entries
164
+ const bodyEnd = matchBrace(source, bodyStart)
165
+ if (bodyEnd < 0) return entries
166
+ const body = source.slice(bodyStart + 1, bodyEnd)
167
+ let i = 0
168
+ while (i < body.length) {
169
+ i = skipWhitespaceAndComments(body, i)
170
+ if (i >= body.length) break
171
+ const key = readKey(body, i)
172
+ if (!key) break
173
+ const colon = skipWhitespaceAndComments(body, key.next)
174
+ if (body[colon] !== ':') break
175
+ const valueStart = skipWhitespaceAndComments(body, colon + 1)
176
+ entries.set(key.key, { objectValue: body[valueStart] === '{' })
177
+ i = skipValue(body, valueStart)
178
+ if (body[i] === ',') i++
179
+ }
180
+ return entries
181
+ }
182
+
183
+ /**
184
+ * The data-bound section instances declared across a manifest's views, in
185
+ * order. `isDataBound` is the predicate that identifies a data-bound type
186
+ * (typically `sectionIsDataBound` from `recipes/validate.mjs`).
187
+ */
188
+ export function collectDataBoundInstances(manifest, isDataBound) {
189
+ const instances = []
190
+ const views = Array.isArray(manifest?.views) ? manifest.views : []
191
+ for (const view of views) {
192
+ const sections = Array.isArray(view?.sections) ? view.sections : []
193
+ for (const section of sections) {
194
+ if (!section || typeof section.type !== 'string') continue
195
+ if (!isDataBound(section.type)) continue
196
+ instances.push({
197
+ view: view.name,
198
+ id: section.id,
199
+ type: section.type,
200
+ variant: typeof section.variant === 'string' ? section.variant : null,
201
+ })
202
+ }
203
+ }
204
+ return instances
205
+ }
206
+
207
+ /**
208
+ * Audit a project's user-owned source configuration against its data-bound
209
+ * section instances. Returns `{ diagnostics }` where each diagnostic carries
210
+ * the owning view, the instance id/type, the user-owned file, and a repair
211
+ * message. Missing declarations and non-object declarations are both reported;
212
+ * deep query validity is enforced at build/runtime, not here.
213
+ */
214
+ export function auditSectionConfig({ dir, manifest, isDataBound }) {
215
+ const diagnostics = []
216
+ const name = typeof manifest?.name === 'string' && manifest.name.length > 0 ? manifest.name : 'storefront'
217
+ const instances = collectDataBoundInstances(manifest, isDataBound)
218
+ const byView = new Map()
219
+ for (const instance of instances) {
220
+ if (!byView.has(instance.view)) byView.set(instance.view, [])
221
+ byView.get(instance.view).push(instance)
222
+ }
223
+ for (const [view, viewInstances] of byView) {
224
+ const relPath = `lib/${name}/views/${view}/data.ts`
225
+ const absPath = join(dir, relPath)
226
+ const entries = existsSync(absPath)
227
+ ? extractSectionSourceEntries(readFileSync(absPath, 'utf8'))
228
+ : null
229
+ for (const instance of viewInstances) {
230
+ const entry = entries?.get(instance.id)
231
+ if (!entry) {
232
+ diagnostics.push({
233
+ code: 'E_SECTION_CONFIG_MISSING',
234
+ view,
235
+ id: instance.id,
236
+ type: instance.type,
237
+ path: relPath,
238
+ message: `section "${instance.id}" (${instance.type}) is not configured; add a sectionSources["${instance.id}"] entry in ${relPath}`,
239
+ })
240
+ } else if (!entry.objectValue) {
241
+ diagnostics.push({
242
+ code: 'E_SECTION_CONFIG_INVALID',
243
+ view,
244
+ id: instance.id,
245
+ type: instance.type,
246
+ path: relPath,
247
+ message: `sectionSources["${instance.id}"] in ${relPath} must be an object literal (kind/query/maxItems)`,
248
+ })
249
+ }
250
+ }
251
+ }
252
+ return { diagnostics }
253
+ }