@thorprovider/storefront 0.4.0-mvp.0 → 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.
- package/README.md +173 -153
- package/VENDOR.md +69 -0
- package/assets/openspec/config.yaml +38 -0
- package/assets/templates/AGENTS.md +76 -0
- package/assets/templates/app/app/[[...slug]]/page.tsx +42 -1
- package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
- package/assets/templates/app/app/globals.css +5 -8
- package/assets/templates/app/app/layout.tsx +1 -5
- package/assets/templates/app/app/robots.ts +9 -3
- package/assets/templates/app/app/sitemap.ts +64 -5
- package/assets/templates/app/lib/__STOREFRONT__/actions.ts +70 -0
- package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +15 -5
- package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +71 -0
- package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +36 -4
- package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +82 -50
- package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -0
- package/assets/templates/app/lib/__STOREFRONT__/schema.ts +29 -14
- package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
- package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +31 -7
- package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +4 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +56 -26
- package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +165 -0
- package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +26 -7
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +2 -1
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
- package/assets/templates/features/checkout/handlers.ts +6 -0
- package/assets/templates/features/customer-auth/state.ts +10 -1
- package/assets/templates/views/account/index.tsx +1 -1
- package/assets/templates/views/account/registry.tsx +80 -19
- package/assets/templates/views/account/schema.ts +1 -0
- package/assets/templates/views/cart/content.ts +11 -0
- package/assets/templates/views/cart/data.ts +23 -0
- package/assets/templates/views/cart/index.tsx +53 -0
- package/assets/templates/views/cart/props.ts +12 -0
- package/assets/templates/views/cart/registry.tsx +81 -0
- package/assets/templates/views/cart/schema.ts +52 -0
- package/assets/templates/views/category/assembly/assembly.json +1 -0
- package/assets/templates/views/category/assembly/index.skeleton.tsx +64 -6
- package/assets/templates/views/category/assembly/registry.skeleton.tsx +2 -1
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
- package/assets/templates/views/category/index.tsx +76 -7
- package/assets/templates/views/category/props.ts +7 -0
- package/assets/templates/views/category/registry.tsx +19 -0
- package/assets/templates/views/category/schema.ts +13 -0
- package/assets/templates/views/home/assembly/assembly.json +9 -0
- package/assets/templates/views/home/assembly/index.skeleton.tsx +22 -4
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +1 -1
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +2 -2
- package/assets/templates/views/home/data.ts +9 -0
- package/assets/templates/views/home/index.tsx +23 -5
- package/assets/templates/views/home/registry.tsx +2 -2
- package/assets/templates/views/product/data.ts +4 -2
- package/assets/templates/views/product/index.tsx +49 -5
- package/assets/templates/views/product/props.ts +7 -0
- package/assets/templates/views/product/registry.tsx +25 -0
- package/assets/templates/views/product/schema.ts +13 -0
- package/assets/templates/views/static/content.ts +4 -4
- package/assets/templates/views/static/index.tsx +1 -1
- package/bin/storefront.js +1231 -154
- package/examples/merchandising/category-led.json +37 -0
- package/examples/merchandising/collection-led.json +37 -0
- package/package.json +11 -7
- package/recipes/archetypes.json +9 -22
- package/recipes/catalog.json +40 -1
- package/recipes/features.json +25 -2
- package/recipes/recipe.schema.json +1 -1
- package/recipes/recipes.json +32 -30
- package/recipes/sections.json +10 -0
- package/recipes/validate.mjs +54 -0
- package/scripts/candidate.mjs +4 -4
- package/scripts/composers.mjs +25 -5
- package/scripts/env-file.mjs +86 -0
- package/scripts/file-classes.mjs +5 -0
- package/scripts/migrate-english-routes.mjs +276 -0
- package/scripts/pinning.mjs +74 -5
- package/scripts/plan-apply.mjs +149 -5
- package/scripts/reconcile.mjs +49 -11
- package/scripts/render-templates.mjs +97 -44
- package/scripts/section-config.mjs +253 -0
- package/scripts/starter.mjs +17 -0
- package/scripts/theme/color.mjs +185 -0
- package/scripts/theme/css-theme.mjs +233 -0
- package/scripts/theme/fonts.mjs +123 -0
- package/scripts/theme/import-export.mjs +125 -0
- package/scripts/theme/preset.mjs +93 -0
- package/scripts/theme/theme-plan.mjs +292 -0
- package/scripts/theme/tokens.mjs +137 -0
- package/scripts/view-assembly.mjs +47 -1
- package/skills/sf-feature/SKILL.md +32 -19
- package/skills/sf-setup/SKILL.md +79 -0
- package/skills/sf-theme-gen/SKILL.md +99 -36
- package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
- package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
- package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
- package/skills/sf-view-gen/SKILL.md +63 -40
- package/skills/sf-view-gen/assets/recipes/archetypes.json +9 -22
- package/skills/sf-view-gen/assets/recipes/catalog.json +40 -1
- package/skills/sf-view-gen/assets/recipes/features.json +25 -2
- package/skills/sf-view-gen/assets/recipes/recipes.json +32 -30
- package/skills/sf-view-gen/assets/recipes/sections.json +10 -0
- package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
- package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
- package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
- package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
- package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
- package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
- package/skills/sf-view-gen/references/views.md +28 -37
- package/skills/sf-view-gen/scripts/validate.mjs +54 -0
- package/skills/storefront-workflow/SKILL.md +94 -31
- package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
- package/CHANGELOG.md +0 -84
- package/assets/templates/views/user-panel/index.tsx +0 -106
- package/assets/templates/views/user-panel/registry.tsx +0 -54
- package/assets/templates/views/user-panel/schema.ts +0 -79
- package/commands/sf-add-view.md +0 -57
- package/commands/sf-feature.md +0 -28
- package/commands/sf-theme.md +0 -26
- package/commands/sf-view.md +0 -67
- package/skills/building-storefronts/SKILL.md +0 -178
- package/skills/building-storefronts/references/frontend-integration.md +0 -229
- package/skills/json-render-remotion/SKILL.md +0 -111
- package/skills/json-render-solid/SKILL.md +0 -204
- package/skills/nextjs-shadcn/SKILL.md +0 -367
- package/skills/nextjs-shadcn/references/architecture.md +0 -499
- package/skills/nextjs-shadcn/references/project-setup.md +0 -162
- package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
- package/skills/nextjs-shadcn/references/sidebar.md +0 -274
- package/skills/nextjs-shadcn/references/styling.md +0 -555
- package/skills/shadcn-component-discovery/SKILL.md +0 -273
- package/skills/shadcn-component-discovery/references/registries.md +0 -226
- package/skills/shadcn-theming/SKILL.md +0 -104
- package/skills/storefront-best-practices/SKILL.md +0 -421
- package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
- package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
- package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
- package/skills/storefront-best-practices/reference/components/footer.md +0 -112
- package/skills/storefront-best-practices/reference/components/hero.md +0 -241
- package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
- package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
- package/skills/storefront-best-practices/reference/components/popups.md +0 -221
- package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
- package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
- package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
- package/skills/storefront-best-practices/reference/components/search.md +0 -101
- package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
- package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
- package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
- package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
- package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
- package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
- package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
- package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
- package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
- package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
- package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
- package/skills/storefront-best-practices/reference/medusa.md +0 -307
- /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
- /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "category-led",
|
|
3
|
+
"title": "Category-led combined presentation plus freshness selection",
|
|
4
|
+
"intent": "Lead with a category-focused combined grid-and-carousel of the newest arrivals, keep category navigation visible, and cross-sell a related category below. Selection (category handle and ordering) is independent of the presentation variant.",
|
|
5
|
+
"sections": [
|
|
6
|
+
{ "id": "slot:hero", "type": "hero", "variant": "StorefrontHero1" },
|
|
7
|
+
{ "id": "slot:benefits", "type": "benefits", "variant": "Feature1" },
|
|
8
|
+
{ "id": "slot:categories", "type": "categories", "variant": "ProductCategory2" },
|
|
9
|
+
{
|
|
10
|
+
"id": "home-products-1",
|
|
11
|
+
"type": "products",
|
|
12
|
+
"variant": "ProductShowcase1",
|
|
13
|
+
"objective": "Showcase the newest arrivals in a category as a browsable carousel",
|
|
14
|
+
"destination": "/categories/repuestos",
|
|
15
|
+
"source": {
|
|
16
|
+
"kind": "products",
|
|
17
|
+
"query": { "categories": ["repuestos"], "sort": "newest", "pageSize": 12 }
|
|
18
|
+
},
|
|
19
|
+
"content": {
|
|
20
|
+
"heading": "Novedades en repuestos",
|
|
21
|
+
"description": "Lo último que llega a la categoría."
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "home-products-2",
|
|
26
|
+
"type": "products",
|
|
27
|
+
"variant": "ProductCard1",
|
|
28
|
+
"objective": "Cross-sell a related category below the carousel",
|
|
29
|
+
"destination": "/categories/accesorios",
|
|
30
|
+
"source": {
|
|
31
|
+
"kind": "products",
|
|
32
|
+
"query": { "categories": ["accesorios"], "sort": "featured", "pageSize": 4 }
|
|
33
|
+
},
|
|
34
|
+
"content": { "heading": "Accesorios recomendados" }
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "collection-led",
|
|
3
|
+
"title": "Collection-led grid plus campaign CTA",
|
|
4
|
+
"intent": "Lead the home with one curated collection as the primary merchandise grid, and reinforce it with a single campaign call to action. The selection is a collection handle, not a sales ranking.",
|
|
5
|
+
"sections": [
|
|
6
|
+
{ "id": "slot:hero", "type": "hero", "variant": "StorefrontHero1" },
|
|
7
|
+
{ "id": "slot:benefits", "type": "benefits", "variant": "Feature1" },
|
|
8
|
+
{
|
|
9
|
+
"id": "home-products-1",
|
|
10
|
+
"type": "products",
|
|
11
|
+
"variant": "ProductCard1",
|
|
12
|
+
"objective": "Move collection browsers into the featured collection",
|
|
13
|
+
"destination": "/collections/featured",
|
|
14
|
+
"source": {
|
|
15
|
+
"kind": "products",
|
|
16
|
+
"query": { "collections": ["featured"], "sort": "featured", "pageSize": 8 }
|
|
17
|
+
},
|
|
18
|
+
"content": {
|
|
19
|
+
"heading": "Destacados de la temporada",
|
|
20
|
+
"description": "Una selección curada de la colección destacada."
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "home-cta-1",
|
|
25
|
+
"type": "cta",
|
|
26
|
+
"objective": "Reinforce the campaign message with one clear action",
|
|
27
|
+
"destination": "/collections/featured",
|
|
28
|
+
"content": {
|
|
29
|
+
"badgeText": "Campaña",
|
|
30
|
+
"title": "Envío gratis esta semana",
|
|
31
|
+
"description": "En pedidos superiores al mínimo de la campaña.",
|
|
32
|
+
"primaryCta": { "label": "Ver la colección", "href": "/collections/featured" }
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{ "id": "slot:categories", "type": "categories", "variant": "ProductCategory2" }
|
|
36
|
+
]
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thorprovider/storefront",
|
|
3
|
-
"version": "0.4.0-mvp.
|
|
4
|
-
"description": "Deterministic storefront scaffolder and installable OpenCode
|
|
3
|
+
"version": "0.4.0-mvp.2",
|
|
4
|
+
"description": "Deterministic storefront scaffolder and installable OpenCode skill suite for Thor Commerce (Next.js, @thorprovider/blocks, @thorprovider/adapters, Medusa)",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin":
|
|
6
|
+
"bin": {
|
|
7
|
+
"storefront": "bin/storefront.js"
|
|
8
|
+
},
|
|
7
9
|
"files": [
|
|
8
10
|
"assets",
|
|
9
11
|
"bin",
|
|
10
|
-
"
|
|
12
|
+
"examples",
|
|
11
13
|
"skills",
|
|
12
14
|
"recipes",
|
|
13
15
|
"scripts",
|
|
14
|
-
"README.md"
|
|
16
|
+
"README.md",
|
|
17
|
+
"VENDOR.md"
|
|
15
18
|
],
|
|
16
19
|
"engines": {
|
|
17
20
|
"node": ">=20.9.0"
|
|
@@ -21,7 +24,7 @@
|
|
|
21
24
|
"check-assets": "node ./scripts/sync-assets.mjs --check",
|
|
22
25
|
"pretest": "node ./scripts/sync-assets.mjs --check",
|
|
23
26
|
"test": "vitest run --config vitest.config.ts",
|
|
24
|
-
"test:integration": "vitest run --config vitest.config.ts tests/scaffold-integration.test.ts"
|
|
27
|
+
"test:integration": "vitest run --config vitest.config.ts tests/scaffold-integration.test.ts tests/starter-integration.test.ts"
|
|
25
28
|
},
|
|
26
29
|
"publishConfig": {
|
|
27
30
|
"access": "public",
|
|
@@ -41,10 +44,11 @@
|
|
|
41
44
|
],
|
|
42
45
|
"dependencies": {
|
|
43
46
|
"@clack/prompts": "^1.8.1",
|
|
47
|
+
"@fission-ai/openspec": "1.11.0",
|
|
44
48
|
"semver": "^7.7.4"
|
|
45
49
|
},
|
|
46
50
|
"devDependencies": {
|
|
47
51
|
"vitest": "^3.2.4"
|
|
48
52
|
},
|
|
49
53
|
"license": "MIT"
|
|
50
|
-
}
|
|
54
|
+
}
|
package/recipes/archetypes.json
CHANGED
|
@@ -88,6 +88,15 @@
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
},
|
|
91
|
+
"cart.view": {
|
|
92
|
+
"default": "review",
|
|
93
|
+
"intents": {
|
|
94
|
+
"review": {
|
|
95
|
+
"variant": "ShoppingCart4",
|
|
96
|
+
"when": "A standalone cart review page: line editing plus backend-authoritative totals before checkout."
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
91
100
|
"category.filter": {
|
|
92
101
|
"default": "sidebar",
|
|
93
102
|
"intents": {
|
|
@@ -148,28 +157,6 @@
|
|
|
148
157
|
"when": "A signed-in shopper's order history with per-order totals and detail on demand."
|
|
149
158
|
}
|
|
150
159
|
}
|
|
151
|
-
},
|
|
152
|
-
"user-panel.shell": {
|
|
153
|
-
"default": "standard",
|
|
154
|
-
"intents": {
|
|
155
|
-
"standard": {
|
|
156
|
-
"variant": "AppShell1",
|
|
157
|
-
"when": "A standard account shell with side navigation and content area."
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
},
|
|
161
|
-
"user-panel.orderHistory": {
|
|
162
|
-
"default": "compact",
|
|
163
|
-
"intents": {
|
|
164
|
-
"compact": {
|
|
165
|
-
"variant": "OrderHistory1",
|
|
166
|
-
"when": "A concise list of past orders with status and totals."
|
|
167
|
-
},
|
|
168
|
-
"detailed": {
|
|
169
|
-
"variant": "OrderHistory2",
|
|
170
|
-
"when": "Order history with line-item detail, tracking, and reorder actions."
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
160
|
}
|
|
174
161
|
}
|
|
175
162
|
}
|
package/recipes/catalog.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./catalog.schema.json",
|
|
3
3
|
"catalogVersion": 1,
|
|
4
|
-
"description": "Initial semantic executable catalog (E1 tasks 1.2-1.3), classified by source-shape inspection of the @thorprovider/blocks prop contracts and the view templates. Scope: every slot the curated MVP views (home, product, category, checkout, account) render by default, covering both the selectable archetype slots declared by recipes/archetypes.json and the nested default slots that have no archetype (category.productCard, checkout.confirmation) so no legacy default support is dropped. The account view (change: storefront-customer-auth) declares the login/register slots: LoginPage1/SignUpPage1 are executable while LoginPage2 is held for its divergent callback contract. A variant is `executable` only when the slot's canonical data shape can feed its block props without a prop bridge and without inventing data; every other non-default variant is `held` with the specific reason. The one executable non-default swap is home.benefits Feature1 -> Feature2 (identical item contract, no bridge).
|
|
4
|
+
"description": "Initial semantic executable catalog (E1 tasks 1.2-1.3), classified by source-shape inspection of the @thorprovider/blocks prop contracts and the view templates. Scope: every slot the curated MVP views (home, product, category, checkout, account) render by default, covering both the selectable archetype slots declared by recipes/archetypes.json and the nested default slots that have no archetype (category.productCard, checkout.confirmation) so no legacy default support is dropped. The account view (change: storefront-customer-auth) declares the login/register slots: LoginPage1/SignUpPage1 are executable while LoginPage2 is held for its divergent callback contract. A variant is `executable` only when the slot's canonical data shape can feed its block props without a prop bridge and without inventing data; every other non-default variant is `held` with the specific reason. The one executable non-default swap is home.benefits Feature1 -> Feature2 (identical item contract, no bridge). E2 task 6.1 declares the base listing slot category.filter CategoryFilter2 as the single controlled listing (dataShape `CatalogQuery & CatalogResults`, state controlled) with the search/filter controls contributed by the logical mounts category.search / category.filters (no second block). Source-shape compatibility is not end-to-end backend behavior proof: the render is executable, but the category.search.apply / category.filter.apply actions remain held under the capability catalog (owner: storefront-catalog-search-filters, E2) until a real runtime proof. The `capabilities` map declares those operational capabilities with their requirements and verification state; E1 declares the held category.filter.apply action, E2 adds the held category.search.apply action and the held category.price.range capability that gates the category-filters showPrice option; no capability is claimed verified.",
|
|
5
5
|
"slots": {
|
|
6
6
|
"home.hero": {
|
|
7
7
|
"default": "StorefrontHero1",
|
|
@@ -157,6 +157,32 @@
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
},
|
|
160
|
+
"product.breadcrumb": {
|
|
161
|
+
"default": "Breadcrumb",
|
|
162
|
+
"variants": {
|
|
163
|
+
"Breadcrumb": {
|
|
164
|
+
"status": "executable",
|
|
165
|
+
"block": "Breadcrumb",
|
|
166
|
+
"dataShape": "BreadcrumbItemData[]",
|
|
167
|
+
"events": [],
|
|
168
|
+
"state": "uncontrolled",
|
|
169
|
+
"requirements": ["productBreadcrumb"]
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
"cart.view": {
|
|
174
|
+
"default": "ShoppingCart4",
|
|
175
|
+
"variants": {
|
|
176
|
+
"ShoppingCart4": {
|
|
177
|
+
"status": "executable",
|
|
178
|
+
"block": "ShoppingCart4",
|
|
179
|
+
"dataShape": "CartLineState[] & CartTotalsState",
|
|
180
|
+
"events": ["refreshCart", "updateQuantity", "removeFromCart", "checkout", "continueShopping"],
|
|
181
|
+
"state": "controlled",
|
|
182
|
+
"requirements": ["cart", "cartTotals"]
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
},
|
|
160
186
|
"category.filter": {
|
|
161
187
|
"default": "CategoryFilter2",
|
|
162
188
|
"variants": {
|
|
@@ -339,6 +365,19 @@
|
|
|
339
365
|
"requirements": ["accountCustomer", "accountAuthenticated"]
|
|
340
366
|
}
|
|
341
367
|
}
|
|
368
|
+
},
|
|
369
|
+
"category.breadcrumb": {
|
|
370
|
+
"default": "Breadcrumb",
|
|
371
|
+
"variants": {
|
|
372
|
+
"Breadcrumb": {
|
|
373
|
+
"status": "executable",
|
|
374
|
+
"block": "Breadcrumb",
|
|
375
|
+
"dataShape": "BreadcrumbItemData[]",
|
|
376
|
+
"events": [],
|
|
377
|
+
"state": "uncontrolled",
|
|
378
|
+
"requirements": ["categoryBreadcrumb"]
|
|
379
|
+
}
|
|
380
|
+
}
|
|
342
381
|
}
|
|
343
382
|
},
|
|
344
383
|
"capabilities": {
|
package/recipes/features.json
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"name": "customer-auth",
|
|
31
31
|
"mvp": true,
|
|
32
32
|
"description": "Customer authentication: registration, login, logout, and a minimal signed-in state. Owns the account view.",
|
|
33
|
-
"route": "/
|
|
33
|
+
"route": "/account",
|
|
34
34
|
"views": ["account"],
|
|
35
35
|
"state": [
|
|
36
36
|
"accountCustomer",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"name": "static-pages",
|
|
49
49
|
"mvp": true,
|
|
50
50
|
"description": "Content-driven static pages (contact, legal, about) served with per-page metadata. Owns the static view.",
|
|
51
|
-
"route": "/
|
|
51
|
+
"route": "/contact",
|
|
52
52
|
"views": ["static"],
|
|
53
53
|
"state": [],
|
|
54
54
|
"handlers": [],
|
|
@@ -122,6 +122,29 @@
|
|
|
122
122
|
"mounts": [{ "id": "category-filters", "host": "category", "slot": "filters" }],
|
|
123
123
|
"surfaces": []
|
|
124
124
|
},
|
|
125
|
+
{
|
|
126
|
+
"name": "navigability",
|
|
127
|
+
"mvp": true,
|
|
128
|
+
"description": "Cross-cutting navigability feature: entity-derived breadcrumbs with BreadcrumbList structured data on the product and category surfaces, plus skip-to-content, current-page indication, back-to-top, and a mobile bottom navigation in the shared shell.",
|
|
129
|
+
"views": [],
|
|
130
|
+
"state": [],
|
|
131
|
+
"handlers": [],
|
|
132
|
+
"adapter": { "needs": [] },
|
|
133
|
+
"config": {
|
|
134
|
+
"type": "object",
|
|
135
|
+
"additionalProperties": false,
|
|
136
|
+
"properties": {
|
|
137
|
+
"presentation": { "type": "string", "enum": ["default"], "default": "default" },
|
|
138
|
+
"showBackToTop": { "type": "boolean", "default": true }
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
"presentations": { "default": "Breadcrumb" },
|
|
142
|
+
"mounts": [
|
|
143
|
+
{ "id": "category-breadcrumb", "host": "category", "slot": "breadcrumb" },
|
|
144
|
+
{ "id": "product-breadcrumb", "host": "product", "slot": "breadcrumb" }
|
|
145
|
+
],
|
|
146
|
+
"surfaces": []
|
|
147
|
+
},
|
|
125
148
|
{
|
|
126
149
|
"name": "recommendations",
|
|
127
150
|
"mvp": false,
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"properties": {
|
|
24
24
|
"name": {
|
|
25
25
|
"type": "string",
|
|
26
|
-
"enum": ["home", "product", "category", "checkout", "account", "static"
|
|
26
|
+
"enum": ["home", "product", "cart", "category", "checkout", "account", "static"]
|
|
27
27
|
},
|
|
28
28
|
"description": { "type": "string" },
|
|
29
29
|
"route": { "type": "string" },
|
package/recipes/recipes.json
CHANGED
|
@@ -22,47 +22,69 @@
|
|
|
22
22
|
},
|
|
23
23
|
"optionalSections": ["testimonials", "faq", "statistics", "logoCloud", "pricing"],
|
|
24
24
|
"registeredSlots": ["hero", "benefits", "categories"],
|
|
25
|
-
"allowedSections": ["faq", "cta"],
|
|
25
|
+
"allowedSections": ["faq", "cta", "products"],
|
|
26
26
|
"maxSections": 4
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
"name": "product",
|
|
30
30
|
"mvp": true,
|
|
31
31
|
"description": "Single product detail page with overview, gallery, and reviews",
|
|
32
|
-
"route": "/
|
|
32
|
+
"route": "/products/[id]",
|
|
33
33
|
"loader": "product",
|
|
34
34
|
"requiredDataKeys": ["selectedProduct"],
|
|
35
35
|
"defaultVariants": {
|
|
36
36
|
"overview": "ProductOverview1",
|
|
37
|
-
"reviews": "ReviewRating1"
|
|
37
|
+
"reviews": "ReviewRating1",
|
|
38
|
+
"breadcrumb": "Breadcrumb"
|
|
38
39
|
},
|
|
39
40
|
"allowedVariants": {
|
|
40
41
|
"overview": ["ProductOverview1", "ProductOverview2", "ProductOverview5"],
|
|
41
42
|
"reviews": ["ReviewRating1", "ReviewRating2"],
|
|
43
|
+
"breadcrumb": ["Breadcrumb"],
|
|
42
44
|
"productCard": ["ProductCard1", "ProductCard2", "ProductCard3", "ProductCard5"]
|
|
43
45
|
},
|
|
44
46
|
"optionalSections": ["testimonials", "faq", "statistics"],
|
|
45
|
-
"registeredSlots": ["overview", "reviews"],
|
|
47
|
+
"registeredSlots": ["overview", "reviews", "breadcrumb"],
|
|
46
48
|
"allowedSections": [],
|
|
47
49
|
"maxSections": 4
|
|
48
50
|
},
|
|
51
|
+
{
|
|
52
|
+
"name": "cart",
|
|
53
|
+
"mvp": true,
|
|
54
|
+
"description": "Standalone cart review page with authoritative totals and quantity editing",
|
|
55
|
+
"route": "/cart",
|
|
56
|
+
"loader": "cart",
|
|
57
|
+
"requiredDataKeys": ["cart"],
|
|
58
|
+
"defaultVariants": {
|
|
59
|
+
"view": "ShoppingCart4"
|
|
60
|
+
},
|
|
61
|
+
"allowedVariants": {
|
|
62
|
+
"view": ["ShoppingCart4"]
|
|
63
|
+
},
|
|
64
|
+
"optionalSections": [],
|
|
65
|
+
"registeredSlots": ["view"],
|
|
66
|
+
"allowedSections": [],
|
|
67
|
+
"maxSections": 0
|
|
68
|
+
},
|
|
49
69
|
{
|
|
50
70
|
"name": "category",
|
|
51
71
|
"mvp": true,
|
|
52
72
|
"description": "Product listing filtered by category with filters and sorting",
|
|
53
|
-
"route": "/
|
|
73
|
+
"route": "/categories/[[...slug]]",
|
|
54
74
|
"loader": "category",
|
|
55
75
|
"requiredDataKeys": ["categoryProducts", "storeCategories"],
|
|
56
76
|
"defaultVariants": {
|
|
57
77
|
"filter": "CategoryFilter2",
|
|
58
|
-
"productCard": "ProductCard1"
|
|
78
|
+
"productCard": "ProductCard1",
|
|
79
|
+
"breadcrumb": "Breadcrumb"
|
|
59
80
|
},
|
|
60
81
|
"allowedVariants": {
|
|
61
82
|
"filter": ["CategoryFilter1", "CategoryFilter2", "CategoryFilter4"],
|
|
62
|
-
"productCard": ["ProductCard1", "ProductCard2", "ProductCard3", "ProductCard5"]
|
|
83
|
+
"productCard": ["ProductCard1", "ProductCard2", "ProductCard3", "ProductCard5"],
|
|
84
|
+
"breadcrumb": ["Breadcrumb"]
|
|
63
85
|
},
|
|
64
86
|
"optionalSections": ["logoCloud", "testimonials"],
|
|
65
|
-
"registeredSlots": ["filter", "productCard"],
|
|
87
|
+
"registeredSlots": ["breadcrumb", "filter", "productCard"],
|
|
66
88
|
"allowedSections": ["faq", "cta"],
|
|
67
89
|
"maxSections": 4
|
|
68
90
|
},
|
|
@@ -90,7 +112,7 @@
|
|
|
90
112
|
"name": "account",
|
|
91
113
|
"mvp": true,
|
|
92
114
|
"description": "Customer account area with sign-in, registration, and a minimal signed-in state",
|
|
93
|
-
"route": "/
|
|
115
|
+
"route": "/account",
|
|
94
116
|
"loader": "account",
|
|
95
117
|
"requiredDataKeys": ["accountCustomer"],
|
|
96
118
|
"defaultVariants": {
|
|
@@ -111,7 +133,7 @@
|
|
|
111
133
|
"name": "static",
|
|
112
134
|
"mvp": true,
|
|
113
135
|
"description": "Content-driven static pages (contact, legal, about) with per-page metadata",
|
|
114
|
-
"route": "/
|
|
136
|
+
"route": "/contact",
|
|
115
137
|
"loader": "static",
|
|
116
138
|
"requiredDataKeys": [],
|
|
117
139
|
"defaultVariants": {},
|
|
@@ -120,26 +142,6 @@
|
|
|
120
142
|
"registeredSlots": [],
|
|
121
143
|
"allowedSections": [],
|
|
122
144
|
"maxSections": 0
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
"name": "user-panel",
|
|
126
|
-
"mvp": false,
|
|
127
|
-
"description": "Customer account area with order history and account navigation",
|
|
128
|
-
"route": "/cuenta",
|
|
129
|
-
"loader": "userPanel",
|
|
130
|
-
"requiredDataKeys": ["lastOrder", "lastOrderLines", "orderPlaced"],
|
|
131
|
-
"defaultVariants": {
|
|
132
|
-
"shell": "AppShell1",
|
|
133
|
-
"orderHistory": "OrderHistory1"
|
|
134
|
-
},
|
|
135
|
-
"allowedVariants": {
|
|
136
|
-
"shell": ["AppShell1"],
|
|
137
|
-
"orderHistory": ["OrderHistory1", "OrderHistory2"]
|
|
138
|
-
},
|
|
139
|
-
"optionalSections": [],
|
|
140
|
-
"registeredSlots": ["shell", "orderHistory"],
|
|
141
|
-
"allowedSections": [],
|
|
142
|
-
"maxSections": 4
|
|
143
145
|
}
|
|
144
146
|
]
|
|
145
147
|
}
|
package/recipes/sections.json
CHANGED
|
@@ -31,6 +31,16 @@
|
|
|
31
31
|
"propsHint": "badgeText, title, description, primaryCta/secondaryCta (label, href)",
|
|
32
32
|
"status": "executable"
|
|
33
33
|
},
|
|
34
|
+
{
|
|
35
|
+
"name": "products",
|
|
36
|
+
"description": "Data-bound product presentation (grid or combined grid-and-carousel) whose selection is configured independently per instance",
|
|
37
|
+
"elementType": "Products",
|
|
38
|
+
"variants": ["ProductCard1", "ProductShowcase1"],
|
|
39
|
+
"defaultVariant": "ProductCard1",
|
|
40
|
+
"dataBound": true,
|
|
41
|
+
"propsHint": "per-instance selection configured in views/<view>/data.ts sectionSources[id] (canonical category/collection/search/ordering query); heading, description, objective, ctaLabel/ctaHref are editorial content",
|
|
42
|
+
"status": "executable"
|
|
43
|
+
},
|
|
34
44
|
{
|
|
35
45
|
"name": "statistics",
|
|
36
46
|
"description": "Key metrics or trust statistics",
|
package/recipes/validate.mjs
CHANGED
|
@@ -455,6 +455,27 @@ export function sectionRegistry() {
|
|
|
455
455
|
}))
|
|
456
456
|
}
|
|
457
457
|
|
|
458
|
+
/**
|
|
459
|
+
* The full registry entry for a section type, or null when it is not
|
|
460
|
+
* registered. Unlike `sectionRegistry()` (the projected introspection view),
|
|
461
|
+
* this exposes every declared field — including `dataBound` and `variants` —
|
|
462
|
+
* so the CLI can tell which instances need user-owned source configuration.
|
|
463
|
+
*/
|
|
464
|
+
export function sectionDefinitionByName(name) {
|
|
465
|
+
const list = Array.isArray(sections.sections) ? sections.sections : []
|
|
466
|
+
return list.find((entry) => isPlainObject(entry) && entry.name === name) ?? null
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Whether a section type resolves its content from a data source configured by
|
|
471
|
+
* the user (for example the `products` type) rather than from static editorial
|
|
472
|
+
* content. Unregistered types are not data-bound.
|
|
473
|
+
*/
|
|
474
|
+
export function sectionIsDataBound(name) {
|
|
475
|
+
const section = sectionDefinitionByName(name)
|
|
476
|
+
return section?.status === 'executable' && section.dataBound === true
|
|
477
|
+
}
|
|
478
|
+
|
|
458
479
|
export function getRecipe(name) {
|
|
459
480
|
return recipes.views.find((v) => v.name === name) ?? null
|
|
460
481
|
}
|
|
@@ -485,6 +506,39 @@ export function isCuratedFeature(name) {
|
|
|
485
506
|
return getFeature(name)?.mvp === true
|
|
486
507
|
}
|
|
487
508
|
|
|
509
|
+
/**
|
|
510
|
+
* The starter composition: every curated (installable) feature plus every
|
|
511
|
+
* curated view no installed curated feature owns. A feature-owned view (for
|
|
512
|
+
* example `checkout`, owned by the `checkout` feature) is installed through its
|
|
513
|
+
* feature, not listed directly. The set is derived from the registry's
|
|
514
|
+
* `mvp: true` declaration — never a hand-maintained list — so a curated unit
|
|
515
|
+
* added or removed flows automatically and a held unit (`recommendations`, a
|
|
516
|
+
* held section) is excluded by construction.
|
|
517
|
+
*
|
|
518
|
+
* `registries` is the injection seam for tests: pass fixture `{ views }` /
|
|
519
|
+
* `{ features }` sources to exercise the derivation without mutating the loaded
|
|
520
|
+
* registry.
|
|
521
|
+
*/
|
|
522
|
+
export function starterComposition(registries = {}) {
|
|
523
|
+
const recipeSource = registries.recipes ?? recipes
|
|
524
|
+
const featureSource = registries.features ?? features
|
|
525
|
+
const featureNames = featureSource.features
|
|
526
|
+
.filter((f) => f.mvp === true)
|
|
527
|
+
.map((f) => f.name)
|
|
528
|
+
const ownedViews = new Set()
|
|
529
|
+
for (const name of featureNames) {
|
|
530
|
+
const feature = featureSource.features.find((f) => f.name === name)
|
|
531
|
+
for (const view of Array.isArray(feature?.views) ? feature.views : []) {
|
|
532
|
+
ownedViews.add(view)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const viewNames = recipeSource.views
|
|
536
|
+
.filter((v) => v.mvp === true)
|
|
537
|
+
.map((v) => v.name)
|
|
538
|
+
.filter((name) => !ownedViews.has(name))
|
|
539
|
+
return { views: viewNames, features: featureNames }
|
|
540
|
+
}
|
|
541
|
+
|
|
488
542
|
/**
|
|
489
543
|
* The catalog entry for one slot ("<view>.<slot>"), or null when the slot is
|
|
490
544
|
* outside the catalog scope (a held, non-curated view). The catalog is the
|
package/scripts/candidate.mjs
CHANGED
|
@@ -42,10 +42,10 @@ export const CANDIDATE_TOOLSET = Object.freeze({
|
|
|
42
42
|
'@json-render/next': '0.21.0',
|
|
43
43
|
}),
|
|
44
44
|
local: Object.freeze({
|
|
45
|
-
'@thorprovider/blocks': '0.1.
|
|
46
|
-
'@thorprovider/adapters': '5.0.
|
|
47
|
-
'@thorprovider/runtime': '0.1.
|
|
48
|
-
'@thorprovider/types': '5.
|
|
45
|
+
'@thorprovider/blocks': '0.1.2',
|
|
46
|
+
'@thorprovider/adapters': '5.0.12',
|
|
47
|
+
'@thorprovider/runtime': '0.1.4',
|
|
48
|
+
'@thorprovider/types': '5.8.0',
|
|
49
49
|
}),
|
|
50
50
|
})
|
|
51
51
|
|
package/scripts/composers.mjs
CHANGED
|
@@ -83,16 +83,27 @@ function isPlainObject(value) {
|
|
|
83
83
|
* derived from the manifest — never hardcoded as always-mounted.
|
|
84
84
|
*
|
|
85
85
|
* `features` accepts bare names or `{ name, config }` entries (the fresh
|
|
86
|
-
* scaffold and a later reconcile both pass manifest entries).
|
|
86
|
+
* scaffold and a later reconcile both pass manifest entries). `views` accepts
|
|
87
|
+
* bare view names or `{ name }` entries; it drives the typed route/action
|
|
88
|
+
* availability flags below so the generated host can suppress links to routes
|
|
89
|
+
* that are not installed (e.g. the cart review page or checkout).
|
|
87
90
|
*/
|
|
88
|
-
export function buildCompositionComposer(features) {
|
|
91
|
+
export function buildCompositionComposer(features, views = []) {
|
|
89
92
|
const entries = (Array.isArray(features) ? features : []).map((feature) =>
|
|
90
93
|
typeof feature === 'string' ? { name: feature, config: {} } : feature,
|
|
91
94
|
)
|
|
92
95
|
const installed = new Set(entries.map((entry) => entry.name))
|
|
96
|
+
const viewNames = (Array.isArray(views) ? views : []).map((view) =>
|
|
97
|
+
typeof view === 'string' ? view : view.name,
|
|
98
|
+
)
|
|
99
|
+
const installedViews = new Set(viewNames)
|
|
100
|
+
const cartViewAvailable = installedViews.has('cart')
|
|
101
|
+
const checkoutAvailable = installed.has('checkout') || installedViews.has('checkout')
|
|
93
102
|
const names = CATALOG_FEATURE_NAMES.filter((name) => installed.has(name))
|
|
94
103
|
const list = names.map((name) => JSON.stringify(name)).join(', ')
|
|
95
|
-
const
|
|
104
|
+
const installedNames = [...installed].sort()
|
|
105
|
+
const featureNameList = installedNames.map((name) => JSON.stringify(name)).join(', ')
|
|
106
|
+
const configLines = installedNames
|
|
96
107
|
.map((name) => {
|
|
97
108
|
const entry = entries.find((candidate) => candidate.name === name)
|
|
98
109
|
const config = entry && isPlainObject(entry.config) ? entry.config : {}
|
|
@@ -109,9 +120,18 @@ export function buildCompositionComposer(features) {
|
|
|
109
120
|
`// visibility/config from the installed manifest only.\n\n` +
|
|
110
121
|
`import type { CatalogFeatureName } from '@thorprovider/runtime/contracts'\n\n` +
|
|
111
122
|
`export const mountedFeatures: readonly CatalogFeatureName[] = [${list}]\n\n` +
|
|
112
|
-
|
|
123
|
+
`// Every installed feature name, unfiltered by the catalog URL boundary. The\n` +
|
|
124
|
+
`// general presence seam cross-cutting features read to gate their own\n` +
|
|
125
|
+
`// contributions (e.g. the navigability feature's shell chrome).\n` +
|
|
126
|
+
`export const mountedFeatureNames: readonly string[] = [${featureNameList}]\n\n` +
|
|
127
|
+
`export const mountedFeatureConfigs: Partial<Record<string, Record<string, unknown>>> = {\n` +
|
|
113
128
|
`${configLines ? `${configLines}\n` : ''}` +
|
|
114
|
-
`}\n`
|
|
129
|
+
`}\n\n` +
|
|
130
|
+
`// Typed route/action availability derived from the installed manifest. The\n` +
|
|
131
|
+
`// generated host uses these to hide links to routes that are not installed,\n` +
|
|
132
|
+
`// so a removed view never leaves an active dead-end navigation target.\n` +
|
|
133
|
+
`export const cartViewAvailable = ${cartViewAvailable}\n\n` +
|
|
134
|
+
`export const checkoutAvailable = ${checkoutAvailable}\n`
|
|
115
135
|
)
|
|
116
136
|
}
|
|
117
137
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Environment-file helpers for the scaffold.
|
|
2
|
+
//
|
|
3
|
+
// `create` starts a storefront in mock mode with a minimal, working `.env.local`
|
|
4
|
+
// and never writes a blank `MEDUSA_*` value; `setup` later upserts the real-backend
|
|
5
|
+
// keys without disturbing anything else in the file. Both behaviours are pure
|
|
6
|
+
// enough to unit-test here rather than only through the network-heavy create path.
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { writeFile } from 'node:fs/promises'
|
|
9
|
+
import { randomBytes } from 'node:crypto'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
// The minimal mock environment `create` writes: an explicit mock data source, the
|
|
13
|
+
// store identity, and the per-install session secret the guest cart and checkout
|
|
14
|
+
// cookie signing requires. No backend key is written here.
|
|
15
|
+
export function initialEnvLocal(name, sessionSecret) {
|
|
16
|
+
return [
|
|
17
|
+
'THOR_DATA_SOURCE=mock',
|
|
18
|
+
`THOR_STORE_ID=${name}`,
|
|
19
|
+
`THOR_SESSION_SECRET=${sessionSecret}`,
|
|
20
|
+
].join('\n') + '\n'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The committed example documenting both modes. The real-backend keys are blank
|
|
24
|
+
// placeholders — `storefront setup` fills them.
|
|
25
|
+
export function envExample(name) {
|
|
26
|
+
return [
|
|
27
|
+
'# Data source: "mock" for explicit demo data (no backend needed), "medusa" for a real backend.',
|
|
28
|
+
'THOR_DATA_SOURCE=mock',
|
|
29
|
+
'# Store identity (written by create; required only in real mode).',
|
|
30
|
+
`THOR_STORE_ID=${name}`,
|
|
31
|
+
'# Real backend configuration (required only when THOR_DATA_SOURCE=medusa).',
|
|
32
|
+
'# Run `storefront setup` to write these: it takes the sales channel id (from the',
|
|
33
|
+
'# Medusa admin) and derives the currency from the selected region.',
|
|
34
|
+
'MEDUSA_BASE_URL=http://localhost:9000',
|
|
35
|
+
'MEDUSA_PUBLISHABLE_KEY=',
|
|
36
|
+
'MEDUSA_REGION_ID=',
|
|
37
|
+
'MEDUSA_SALES_CHANNEL_ID=',
|
|
38
|
+
'MEDUSA_CURRENCY_CODE=',
|
|
39
|
+
'# Host-only cookie signing secret; >=16 chars, 32 recommended. Generated by create.',
|
|
40
|
+
'THOR_SESSION_SECRET=',
|
|
41
|
+
].join('\n') + '\n'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Write the initial `.env.local` only when one is absent (an existing file is
|
|
45
|
+
// user-owned — a `setup` run or a hand edit — and is never clobbered), and always
|
|
46
|
+
// refresh the committed `.env.example`. Returns whether `.env.local` was written.
|
|
47
|
+
export async function writeInitialEnv(target, name) {
|
|
48
|
+
const envPath = join(target, '.env.local')
|
|
49
|
+
let wrote = false
|
|
50
|
+
if (!existsSync(envPath)) {
|
|
51
|
+
const sessionSecret = randomBytes(32).toString('base64url')
|
|
52
|
+
await writeFile(envPath, initialEnvLocal(name, sessionSecret), 'utf8')
|
|
53
|
+
wrote = true
|
|
54
|
+
}
|
|
55
|
+
await writeFile(join(target, '.env.example'), envExample(name), 'utf8')
|
|
56
|
+
return wrote
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Replace the managed keys in a dotenv-style file, appending any that are absent,
|
|
60
|
+
// and leave every other line (comments, blank lines, unrelated keys) exactly as it
|
|
61
|
+
// was. Idempotent: re-running with the same values produces the same file.
|
|
62
|
+
export function upsertEnv(text, managed) {
|
|
63
|
+
const seen = new Set()
|
|
64
|
+
const lines = text.length ? text.split('\n') : []
|
|
65
|
+
const out = lines.map((line) => {
|
|
66
|
+
const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)
|
|
67
|
+
if (match && Object.prototype.hasOwnProperty.call(managed, match[1])) {
|
|
68
|
+
seen.add(match[1])
|
|
69
|
+
return `${match[1]}=${managed[match[1]]}`
|
|
70
|
+
}
|
|
71
|
+
return line
|
|
72
|
+
})
|
|
73
|
+
const missing = Object.keys(managed).filter((key) => !seen.has(key))
|
|
74
|
+
// Drop trailing blank lines before appending so re-runs do not accumulate them.
|
|
75
|
+
while (out.length && out[out.length - 1] === '') out.pop()
|
|
76
|
+
if (missing.length) {
|
|
77
|
+
if (out.length) out.push('')
|
|
78
|
+
for (const key of missing) out.push(`${key}=${managed[key]}`)
|
|
79
|
+
}
|
|
80
|
+
return out.join('\n') + '\n'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Read a dotenv file as text, or '' when it does not exist.
|
|
84
|
+
export function readEnvFile(path) {
|
|
85
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
86
|
+
}
|