@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.
- package/CHANGELOG.md +23 -0
- package/README.md +248 -99
- package/assets/templates/app/app/[[...slug]]/page.tsx +13 -2
- package/assets/templates/app/app/layout.tsx +18 -4
- package/assets/templates/app/app/robots.ts +13 -0
- package/assets/templates/app/app/sitemap.ts +12 -0
- package/assets/templates/app/lib/__STOREFRONT__/actions.ts +542 -0
- package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
- package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +40 -57
- package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +608 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +165 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
- package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
- package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
- package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +232 -51
- package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -3
- package/assets/templates/app/lib/__STOREFRONT__/schema.ts +121 -0
- package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
- package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +96 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +34 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +100 -11
- package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
- package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +26 -0
- package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
- package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
- package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +142 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +17 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
- package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
- package/assets/templates/features/checkout/handlers.ts +153 -0
- package/assets/templates/features/checkout/state.ts +61 -0
- package/assets/templates/features/customer-auth/handlers.ts +59 -0
- package/assets/templates/features/customer-auth/state.ts +36 -0
- package/assets/templates/features/recommendations/state.ts +11 -0
- package/assets/templates/views/account/content.ts +35 -0
- package/assets/templates/views/account/index.tsx +114 -0
- package/assets/templates/views/account/registry.tsx +378 -0
- package/assets/templates/views/account/schema.ts +52 -0
- package/assets/templates/views/category/assembly/assembly.json +28 -0
- package/assets/templates/views/category/assembly/index.skeleton.tsx +193 -0
- package/assets/templates/views/category/assembly/registry.skeleton.tsx +33 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
- package/assets/templates/views/category/content.ts +30 -0
- package/assets/templates/views/category/data.ts +12 -0
- package/assets/templates/views/category/index.tsx +230 -0
- package/assets/templates/views/category/props.ts +16 -0
- package/assets/templates/views/category/registry.tsx +103 -0
- package/assets/templates/views/category/schema.ts +78 -0
- package/assets/templates/views/checkout/content.ts +105 -0
- package/assets/templates/views/checkout/data.ts +23 -0
- package/assets/templates/views/checkout/index.tsx +111 -0
- package/assets/templates/views/checkout/props.ts +49 -0
- package/assets/templates/views/checkout/registry.tsx +217 -0
- package/assets/templates/views/checkout/schema.ts +229 -0
- package/assets/templates/views/home/assembly/assembly.json +24 -0
- package/assets/templates/views/home/assembly/index.skeleton.tsx +72 -0
- package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
- package/assets/templates/views/home/content.ts +70 -0
- package/assets/templates/views/home/data.ts +15 -0
- package/assets/templates/views/home/index.tsx +38 -84
- package/assets/templates/views/home/props.ts +22 -0
- package/assets/templates/views/home/registry.tsx +38 -22
- package/assets/templates/views/home/schema.ts +93 -0
- package/assets/templates/views/product/content.ts +25 -0
- package/assets/templates/views/product/data.ts +48 -0
- package/assets/templates/views/product/index.tsx +104 -0
- package/assets/templates/views/product/props.ts +20 -0
- package/assets/templates/views/product/registry.tsx +78 -0
- package/assets/templates/views/product/schema.ts +108 -0
- package/assets/templates/views/static/content.ts +99 -0
- package/assets/templates/views/static/index.tsx +60 -0
- package/assets/templates/views/static/registry.tsx +66 -0
- package/assets/templates/views/static/schema.ts +45 -0
- package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/index.tsx +8 -70
- package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/registry.tsx +23 -18
- package/assets/templates/views/user-panel/schema.ts +79 -0
- package/bin/storefront.js +1278 -79
- package/commands/sf-add-view.md +44 -13
- package/commands/sf-feature.md +28 -0
- package/commands/sf-theme.md +14 -8
- package/commands/sf-view.md +54 -12
- package/package.json +6 -4
- package/recipes/archetypes.json +28 -1
- package/recipes/catalog.json +361 -0
- package/recipes/catalog.mjs +128 -0
- package/recipes/catalog.schema.json +64 -0
- package/recipes/feature.schema.json +89 -0
- package/recipes/features.json +139 -0
- package/recipes/manifest.schema.json +69 -0
- package/recipes/operations.schema.json +43 -0
- package/recipes/recipe.schema.json +11 -2
- package/recipes/recipes.json +60 -5
- package/recipes/sections.json +24 -5
- package/recipes/validate.mjs +784 -32
- package/scripts/candidate.mjs +236 -0
- package/scripts/composers.mjs +140 -0
- package/scripts/file-classes.mjs +116 -0
- package/scripts/logical-controls.mjs +61 -0
- package/scripts/manifest.mjs +298 -0
- package/scripts/operation-registry.mjs +256 -0
- package/scripts/operations.mjs +720 -0
- package/scripts/ownership-conflict.mjs +356 -0
- package/scripts/pinning.mjs +504 -0
- package/scripts/plan-apply.mjs +843 -0
- package/scripts/preflight.mjs +51 -0
- package/scripts/reconcile.mjs +511 -0
- package/scripts/render-templates.mjs +245 -28
- package/scripts/schema-validate.mjs +132 -0
- package/scripts/section-compat.mjs +300 -0
- package/scripts/section-operations.mjs +508 -0
- package/scripts/sync-assets.mjs +22 -15
- package/scripts/view-assembly.mjs +291 -0
- package/scripts/view-selection.mjs +211 -0
- package/skills/sf-feature/SKILL.md +67 -0
- package/skills/sf-theme-gen/SKILL.md +12 -5
- package/skills/sf-view-gen/SKILL.md +123 -93
- package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -1
- package/skills/sf-view-gen/assets/recipes/catalog.json +361 -0
- package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
- package/skills/sf-view-gen/assets/recipes/features.json +139 -0
- package/skills/sf-view-gen/assets/recipes/recipes.json +60 -5
- package/skills/sf-view-gen/assets/recipes/sections.json +24 -5
- package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
- package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
- package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
- package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
- package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
- package/skills/sf-view-gen/references/views.md +44 -10
- package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
- package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
- package/skills/sf-view-gen/scripts/validate.mjs +784 -32
- package/skills/storefront-workflow/SKILL.md +116 -0
- package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
- package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
- package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
- package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
- package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
- 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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 **
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
+
}
|