@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.
- package/README.md +321 -152
- 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 +54 -2
- 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 +17 -7
- package/assets/templates/app/app/robots.ts +19 -0
- package/assets/templates/app/app/sitemap.ts +71 -0
- package/assets/templates/app/lib/__STOREFRONT__/actions.ts +612 -0
- package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
- package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +50 -57
- package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +679 -0
- package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +197 -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 +264 -51
- package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +6 -3
- package/assets/templates/app/lib/__STOREFRONT__/schema.ts +136 -0
- 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__/session.core.ts +470 -0
- package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +120 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +38 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +129 -10
- package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
- package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +191 -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 +161 -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 +18 -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/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.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/schema.products.ts +39 -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 +159 -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 +45 -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 +439 -0
- package/assets/templates/views/account/schema.ts +53 -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 +29 -0
- package/assets/templates/views/category/assembly/index.skeleton.tsx +251 -0
- package/assets/templates/views/category/assembly/registry.skeleton.tsx +34 -0
- 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/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 +299 -0
- package/assets/templates/views/category/props.ts +23 -0
- package/assets/templates/views/category/registry.tsx +122 -0
- package/assets/templates/views/category/schema.ts +91 -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 +33 -0
- package/assets/templates/views/home/assembly/index.skeleton.tsx +90 -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 +24 -0
- package/assets/templates/views/home/index.tsx +57 -85
- 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 +50 -0
- package/assets/templates/views/product/index.tsx +148 -0
- package/assets/templates/views/product/props.ts +27 -0
- package/assets/templates/views/product/registry.tsx +103 -0
- package/assets/templates/views/product/schema.ts +121 -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/bin/storefront.js +2424 -148
- package/examples/merchandising/category-led.json +37 -0
- package/examples/merchandising/collection-led.json +37 -0
- package/package.json +15 -9
- package/recipes/archetypes.json +28 -14
- package/recipes/catalog.json +400 -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 +162 -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 +76 -19
- package/recipes/sections.json +34 -5
- package/recipes/validate.mjs +838 -32
- package/scripts/candidate.mjs +236 -0
- package/scripts/composers.mjs +160 -0
- package/scripts/env-file.mjs +86 -0
- package/scripts/file-classes.mjs +121 -0
- package/scripts/logical-controls.mjs +61 -0
- package/scripts/manifest.mjs +298 -0
- package/scripts/migrate-english-routes.mjs +276 -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 +573 -0
- package/scripts/plan-apply.mjs +987 -0
- package/scripts/preflight.mjs +51 -0
- package/scripts/reconcile.mjs +549 -0
- package/scripts/render-templates.mjs +338 -68
- package/scripts/schema-validate.mjs +132 -0
- package/scripts/section-compat.mjs +300 -0
- package/scripts/section-config.mjs +253 -0
- package/scripts/section-operations.mjs +508 -0
- package/scripts/starter.mjs +17 -0
- package/scripts/sync-assets.mjs +22 -15
- 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 +337 -0
- package/scripts/view-selection.mjs +211 -0
- package/skills/sf-feature/SKILL.md +80 -0
- package/skills/sf-setup/SKILL.md +79 -0
- package/skills/sf-theme-gen/SKILL.md +100 -30
- 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 +146 -93
- package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -14
- package/skills/sf-view-gen/assets/recipes/catalog.json +400 -0
- package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
- package/skills/sf-view-gen/assets/recipes/features.json +162 -0
- package/skills/sf-view-gen/assets/recipes/recipes.json +76 -19
- package/skills/sf-view-gen/assets/recipes/sections.json +34 -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/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.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.products.ts +39 -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/{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 +58 -33
- 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 +838 -32
- package/skills/storefront-workflow/SKILL.md +179 -0
- package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
- package/CHANGELOG.md +0 -61
- package/commands/sf-add-view.md +0 -26
- package/commands/sf-theme.md +0 -20
- package/commands/sf-view.md +0 -25
- 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/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
- package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +0 -168
- package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +0 -49
- 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,51 @@
|
|
|
1
|
+
// Shared preflight for every structural write boundary.
|
|
2
|
+
//
|
|
3
|
+
// A composition is only accepted once it passes all layers:
|
|
4
|
+
// 1. shape — the manifest validates against the published JSON Schema
|
|
5
|
+
// (`recipes/manifest.schema.json`),
|
|
6
|
+
// 2. path safety — the project name is joined into filesystem paths
|
|
7
|
+
// (`lib/<name>/...`), so it must satisfy the same kebab-case contract the
|
|
8
|
+
// CLI produces (`toKebab`), and
|
|
9
|
+
// 3. semantics — every unit, variant, slot, section, and feature/view
|
|
10
|
+
// dependency resolves against the registry (`recipes/validate.mjs`).
|
|
11
|
+
//
|
|
12
|
+
// Callers MUST reject (write nothing) when the result is not ok.
|
|
13
|
+
|
|
14
|
+
import { validateManifest } from './manifest.mjs'
|
|
15
|
+
import { validateCompositionManifest } from '../recipes/validate.mjs'
|
|
16
|
+
import { isValidIdentifier } from './render-templates.mjs'
|
|
17
|
+
import { toCamel } from './composers.mjs'
|
|
18
|
+
|
|
19
|
+
// Mirrors `toKebab` output in scripts/render-templates.mjs: lowercase letters,
|
|
20
|
+
// digits, and single hyphens. `manifest.name` is joined into paths by
|
|
21
|
+
// `reconcile`, so a value like "../escape" must never reach a write.
|
|
22
|
+
const SAFE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
23
|
+
|
|
24
|
+
export function preflightManifest(manifest, options = {}) {
|
|
25
|
+
const shape = validateManifest(manifest)
|
|
26
|
+
if (!shape.ok) return shape
|
|
27
|
+
|
|
28
|
+
const errors = []
|
|
29
|
+
if (typeof manifest.name === 'string' && !SAFE_NAME.test(manifest.name)) {
|
|
30
|
+
errors.push(
|
|
31
|
+
`Manifest "name" must be a kebab-case identifier (lowercase letters, digits, and single hyphens): got "${manifest.name}".`
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Every installed unit becomes an exported binding in generated wiring
|
|
36
|
+
// (views barrel, feature composers). Reject any name that cannot yield a
|
|
37
|
+
// valid JS identifier before a derived file is written.
|
|
38
|
+
for (const view of manifest.views) {
|
|
39
|
+
if (!isValidIdentifier(toCamel(view.name))) {
|
|
40
|
+
errors.push(`View "${view.name}" does not yield a valid export identifier.`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const feature of manifest.features) {
|
|
44
|
+
if (!isValidIdentifier(toCamel(feature.name))) {
|
|
45
|
+
errors.push(`Feature "${feature.name}" does not yield a valid export identifier.`)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const semantic = validateCompositionManifest(manifest, options)
|
|
50
|
+
return { ok: errors.length === 0 && semantic.ok, errors: [...errors, ...semantic.errors] }
|
|
51
|
+
}
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import {
|
|
6
|
+
templatesRoot,
|
|
7
|
+
replacePlaceholders,
|
|
8
|
+
buildBarrel,
|
|
9
|
+
writeBarrel,
|
|
10
|
+
buildClientRegistryBarrel,
|
|
11
|
+
writeClientRegistryBarrel,
|
|
12
|
+
injectLayout,
|
|
13
|
+
migrateLayoutInjection,
|
|
14
|
+
injectThemeMode,
|
|
15
|
+
injectGlobals,
|
|
16
|
+
} from './render-templates.mjs'
|
|
17
|
+
import { buildStateComposer, buildHandlersComposer, buildCompositionComposer, buildFeatureAdapter } from './composers.mjs'
|
|
18
|
+
import { hasViewAssembly, isAssemblyRel, writeViewAssembly } from './view-assembly.mjs'
|
|
19
|
+
import {
|
|
20
|
+
resolveFeatureMountOverrides,
|
|
21
|
+
resolveViewGenerationSelection,
|
|
22
|
+
} from './view-selection.mjs'
|
|
23
|
+
import { classifyFile } from './file-classes.mjs'
|
|
24
|
+
import { DEFAULT_THEME_MODE } from './manifest.mjs'
|
|
25
|
+
import { preflightManifest } from './preflight.mjs'
|
|
26
|
+
import { allowedViewNames, getFeature } from '../recipes/validate.mjs'
|
|
27
|
+
|
|
28
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
29
|
+
const pkgRoot = path.resolve(here, '..')
|
|
30
|
+
const VIEW_GEN_VIEWS = path.join(pkgRoot, 'skills', 'sf-view-gen', 'assets', 'templates', 'views')
|
|
31
|
+
|
|
32
|
+
// Resolve the template directory for a view: the scaffold templates first, then
|
|
33
|
+
// the view-generator skill's vendored copy (the four non-home views live there
|
|
34
|
+
// until they are unified under assets/templates/views).
|
|
35
|
+
export function viewTemplateDir(name) {
|
|
36
|
+
const primary = path.join(templatesRoot, 'views', name)
|
|
37
|
+
if (existsSync(primary)) return primary
|
|
38
|
+
const vendored = path.join(VIEW_GEN_VIEWS, name)
|
|
39
|
+
if (existsSync(vendored)) return vendored
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function walkFiles(dir, base = dir, out = []) {
|
|
44
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const abs = path.join(dir, entry.name)
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
await walkFiles(abs, base, out)
|
|
49
|
+
} else {
|
|
50
|
+
out.push(path.relative(base, abs))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Render a template tree into the project, writing every derived file but only
|
|
57
|
+
// seeding user-owned files that do not exist yet, so reconcile never clobbers a
|
|
58
|
+
// hand edit. `classifyRel` maps a template-relative path to its file class.
|
|
59
|
+
// With `dryRun`, no file is written; `changed` still lists the files that would
|
|
60
|
+
// change, so callers (doctor) can report drift without mutating the project.
|
|
61
|
+
async function renderDerivedTree(fromDir, toDir, subs, classifyRel, changed, skip, dryRun) {
|
|
62
|
+
if (!existsSync(fromDir)) return
|
|
63
|
+
const files = await walkFiles(fromDir)
|
|
64
|
+
for (const rel of files) {
|
|
65
|
+
if (skip && skip(rel)) continue
|
|
66
|
+
const dest = path.join(toDir, rel.replaceAll('__STOREFRONT__', subs.name))
|
|
67
|
+
const fileClass = classifyRel(rel)
|
|
68
|
+
if (fileClass === 'user-owned' && existsSync(dest)) continue
|
|
69
|
+
const raw = await readFile(path.join(fromDir, rel), 'utf8')
|
|
70
|
+
const next = replacePlaceholders(raw, subs)
|
|
71
|
+
const prev = existsSync(dest) ? await readFile(dest, 'utf8') : null
|
|
72
|
+
if (prev === next) continue
|
|
73
|
+
if (!dryRun) {
|
|
74
|
+
await mkdir(path.dirname(dest), { recursive: true })
|
|
75
|
+
await writeFile(dest, next)
|
|
76
|
+
}
|
|
77
|
+
changed.push(dest)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function ensureLayoutAppend(targetDir, name, siteName, mode, dryRun) {
|
|
82
|
+
const layoutPath = path.join(targetDir, 'app', 'layout.tsx')
|
|
83
|
+
if (!existsSync(layoutPath)) {
|
|
84
|
+
if (dryRun) return { changed: true, conflict: null }
|
|
85
|
+
const raw = await readFile(path.join(templatesRoot, 'app', 'app', 'layout.tsx'), 'utf8')
|
|
86
|
+
const content = injectThemeMode(replacePlaceholders(raw, { name, siteName, mode }), mode)
|
|
87
|
+
await mkdir(path.dirname(layoutPath), { recursive: true })
|
|
88
|
+
await writeFile(layoutPath, content)
|
|
89
|
+
return { changed: true, conflict: null }
|
|
90
|
+
}
|
|
91
|
+
const existing = await readFile(layoutPath, 'utf8')
|
|
92
|
+
// Migrate the recognized default-palette injection (CSS owns the theme now).
|
|
93
|
+
// A custom injection is reported as a conflict and nothing is written.
|
|
94
|
+
const migration = migrateLayoutInjection(existing)
|
|
95
|
+
if (migration.conflict) return { changed: false, conflict: migration.conflict }
|
|
96
|
+
const content = injectThemeMode(injectLayout(migration.content, name), mode)
|
|
97
|
+
if (content === existing) return { changed: false, conflict: null }
|
|
98
|
+
if (!dryRun) await writeFile(layoutPath, content)
|
|
99
|
+
return { changed: true, conflict: null }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function ensureGlobalsAppend(targetDir, name, siteName, dryRun) {
|
|
103
|
+
const globalsPath = path.join(targetDir, 'app', 'globals.css')
|
|
104
|
+
if (!existsSync(globalsPath)) {
|
|
105
|
+
if (dryRun) return true
|
|
106
|
+
const raw = await readFile(path.join(templatesRoot, 'app', 'app', 'globals.css'), 'utf8')
|
|
107
|
+
const content = injectGlobals(replacePlaceholders(raw, { name, siteName }))
|
|
108
|
+
await mkdir(path.dirname(globalsPath), { recursive: true })
|
|
109
|
+
await writeFile(globalsPath, content)
|
|
110
|
+
return true
|
|
111
|
+
}
|
|
112
|
+
const existing = await readFile(globalsPath, 'utf8')
|
|
113
|
+
const content = injectGlobals(existing)
|
|
114
|
+
if (content === existing) return false
|
|
115
|
+
if (!dryRun) await writeFile(globalsPath, content)
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Seed a storefront-owned app-root file once, like layout.tsx/globals.css: when
|
|
120
|
+
// the target is absent it is written from the template; an existing file (a
|
|
121
|
+
// store's customization) is never overwritten. Returns whether it wrote.
|
|
122
|
+
async function ensureSeedFile(targetDir, relFromApp, subs, dryRun) {
|
|
123
|
+
const destPath = path.join(targetDir, 'app', relFromApp)
|
|
124
|
+
if (existsSync(destPath)) return false
|
|
125
|
+
if (dryRun) return true
|
|
126
|
+
const raw = await readFile(path.join(templatesRoot, 'app', 'app', relFromApp), 'utf8')
|
|
127
|
+
await mkdir(path.dirname(destPath), { recursive: true })
|
|
128
|
+
await writeFile(destPath, replacePlaceholders(raw, subs))
|
|
129
|
+
return true
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function pruneEmptyDirs(dir) {
|
|
133
|
+
if (!existsSync(dir)) return
|
|
134
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
135
|
+
if (entry.isDirectory()) await pruneEmptyDirs(path.join(dir, entry.name))
|
|
136
|
+
}
|
|
137
|
+
if ((await readdir(dir)).length === 0) await rm(dir, { recursive: true, force: true })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Remove a view's files when the view leaves the manifest. Nothing the user
|
|
141
|
+
// could have authored is silently deleted: a user-owned file that differs from
|
|
142
|
+
// the pristine template (i.e. was customized) and any file with no shipped
|
|
143
|
+
// template (unknown) are preserved, and their locations are returned so the
|
|
144
|
+
// caller can report them. A pristine template file is removed so a pure
|
|
145
|
+
// add/remove round-trip restores the tree exactly.
|
|
146
|
+
export async function removeViewFiles(
|
|
147
|
+
projectDir,
|
|
148
|
+
manifest,
|
|
149
|
+
viewName,
|
|
150
|
+
{ dryRun = false, legacyDerivedFiles = null } = {},
|
|
151
|
+
) {
|
|
152
|
+
const name = manifest.name
|
|
153
|
+
const viewDir = path.join(projectDir, 'lib', name, 'views', viewName)
|
|
154
|
+
if (!existsSync(viewDir)) return { removed: [], preserved: [] }
|
|
155
|
+
const subs = {
|
|
156
|
+
name,
|
|
157
|
+
siteName: manifest.siteName ?? name,
|
|
158
|
+
mode: manifest.theme?.mode ?? DEFAULT_THEME_MODE,
|
|
159
|
+
}
|
|
160
|
+
const templateDir = viewTemplateDir(viewName)
|
|
161
|
+
// A retired view's template no longer exists, so its old derived files would
|
|
162
|
+
// otherwise be treated as unknown and preserved. An explicit bounded list
|
|
163
|
+
// marks exactly those derived files as known (and therefore removable); a
|
|
164
|
+
// user-owned or unknown file is still preserved by the class check below.
|
|
165
|
+
const legacySet = new Set(Array.isArray(legacyDerivedFiles) ? legacyDerivedFiles : [])
|
|
166
|
+
const removed = []
|
|
167
|
+
const preserved = []
|
|
168
|
+
for (const rel of await walkFiles(viewDir)) {
|
|
169
|
+
const abs = path.join(viewDir, rel)
|
|
170
|
+
const posixRel = rel.split(path.sep).join('/')
|
|
171
|
+
const fileClass = classifyFile(path.posix.join('views', viewName, posixRel))
|
|
172
|
+
const templatePath = templateDir ? path.join(templateDir, rel) : null
|
|
173
|
+
const known = Boolean((templatePath && existsSync(templatePath)) || legacySet.has(posixRel))
|
|
174
|
+
const maybeAuthored = !known || fileClass === 'user-owned'
|
|
175
|
+
if (maybeAuthored) {
|
|
176
|
+
let pristine = false
|
|
177
|
+
if (known && fileClass === 'user-owned') {
|
|
178
|
+
const rendered = replacePlaceholders(await readFile(templatePath, 'utf8'), subs)
|
|
179
|
+
pristine = (await readFile(abs, 'utf8')) === rendered
|
|
180
|
+
}
|
|
181
|
+
if (!pristine) {
|
|
182
|
+
preserved.push(abs)
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (!dryRun) await rm(abs, { force: true })
|
|
187
|
+
removed.push(abs)
|
|
188
|
+
}
|
|
189
|
+
if (!dryRun) await pruneEmptyDirs(viewDir)
|
|
190
|
+
const toProjectRel = (abs) => path.relative(projectDir, abs)
|
|
191
|
+
return { removed: removed.map(toProjectRel), preserved: preserved.map(toProjectRel) }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Read-only: the user-owned files of a view that differ from their pristine
|
|
195
|
+
// template render (i.e. were customized), as project-relative paths. A feature
|
|
196
|
+
// instance mount claims a slot of its host view; removing the feature does not
|
|
197
|
+
// remove the host view, so the slot's user-owned content stays on disk. This
|
|
198
|
+
// lets the caller report where that content remains (E1 task 3.4 / CU-06)
|
|
199
|
+
// without removing or rewriting anything.
|
|
200
|
+
export async function customizedViewFiles(projectDir, manifest, viewName) {
|
|
201
|
+
const name = manifest.name
|
|
202
|
+
const viewDir = path.join(projectDir, 'lib', name, 'views', viewName)
|
|
203
|
+
if (!existsSync(viewDir)) return []
|
|
204
|
+
const subs = {
|
|
205
|
+
name,
|
|
206
|
+
siteName: manifest.siteName ?? name,
|
|
207
|
+
mode: manifest.theme?.mode ?? DEFAULT_THEME_MODE,
|
|
208
|
+
}
|
|
209
|
+
const templateDir = viewTemplateDir(viewName)
|
|
210
|
+
const customized = []
|
|
211
|
+
for (const rel of await walkFiles(viewDir)) {
|
|
212
|
+
const posixRel = rel.split(path.sep).join('/')
|
|
213
|
+
if (classifyFile(path.posix.join('views', viewName, posixRel)) !== 'user-owned') continue
|
|
214
|
+
const abs = path.join(viewDir, rel)
|
|
215
|
+
const templatePath = templateDir ? path.join(templateDir, rel) : null
|
|
216
|
+
if (!templatePath || !existsSync(templatePath)) {
|
|
217
|
+
customized.push(path.relative(projectDir, abs))
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
const rendered = replacePlaceholders(await readFile(templatePath, 'utf8'), subs)
|
|
221
|
+
if ((await readFile(abs, 'utf8')) !== rendered) customized.push(path.relative(projectDir, abs))
|
|
222
|
+
}
|
|
223
|
+
return customized
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Remove a feature's files when the feature leaves the manifest. A file that
|
|
227
|
+
// does not match a shipped template (customized or unknown) is preserved and
|
|
228
|
+
// its location returned; pristine template files and the generated adapter are
|
|
229
|
+
// removed so a pure add/remove round-trip restores the tree exactly.
|
|
230
|
+
export async function removeFeatureFiles(projectDir, manifest, featureName, { dryRun = false } = {}) {
|
|
231
|
+
const name = manifest.name
|
|
232
|
+
const featureDir = path.join(projectDir, 'lib', name, 'features', featureName)
|
|
233
|
+
if (!existsSync(featureDir)) return { removed: [], preserved: [] }
|
|
234
|
+
const subs = {
|
|
235
|
+
name,
|
|
236
|
+
siteName: manifest.siteName ?? name,
|
|
237
|
+
mode: manifest.theme?.mode ?? DEFAULT_THEME_MODE,
|
|
238
|
+
}
|
|
239
|
+
const templateDir = path.join(templatesRoot, 'features', featureName)
|
|
240
|
+
const removed = []
|
|
241
|
+
const preserved = []
|
|
242
|
+
for (const rel of await walkFiles(featureDir)) {
|
|
243
|
+
const abs = path.join(featureDir, rel)
|
|
244
|
+
const posixRel = rel.split(path.sep).join('/')
|
|
245
|
+
if (posixRel === 'adapter.ts') {
|
|
246
|
+
if (!dryRun) await rm(abs, { force: true })
|
|
247
|
+
removed.push(abs)
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
const templatePath = path.join(templateDir, rel)
|
|
251
|
+
let pristine = false
|
|
252
|
+
if (existsSync(templatePath)) {
|
|
253
|
+
const rendered = replacePlaceholders(await readFile(templatePath, 'utf8'), subs)
|
|
254
|
+
pristine = (await readFile(abs, 'utf8')) === rendered
|
|
255
|
+
}
|
|
256
|
+
if (!pristine) {
|
|
257
|
+
preserved.push(abs)
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
if (!dryRun) await rm(abs, { force: true })
|
|
261
|
+
removed.push(abs)
|
|
262
|
+
}
|
|
263
|
+
if (!dryRun) await pruneEmptyDirs(featureDir)
|
|
264
|
+
const toProjectRel = (abs) => path.relative(projectDir, abs)
|
|
265
|
+
return { removed: removed.map(toProjectRel), preserved: preserved.map(toProjectRel) }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Reconcile a storefront project to its manifest: regenerate every derived
|
|
269
|
+
// structural file from the versioned templates, seed any missing user-owned
|
|
270
|
+
// file, and leave existing user-owned files untouched. Idempotent by design.
|
|
271
|
+
export async function reconcile(projectDir, manifest, { dryRun = false } = {}) {
|
|
272
|
+
// A manifest that is not a supported composition is never reconciled, not
|
|
273
|
+
// even in dry-run: report it and write nothing so a diagnostic cannot
|
|
274
|
+
// silently proceed as if the input were valid.
|
|
275
|
+
const preflight = preflightManifest(manifest)
|
|
276
|
+
if (!preflight.ok) {
|
|
277
|
+
return { ok: false, errors: preflight.errors, views: [], changed: [] }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const name = manifest.name
|
|
281
|
+
const siteName = manifest.siteName ?? manifest.name
|
|
282
|
+
const mode = manifest.theme?.mode ?? DEFAULT_THEME_MODE
|
|
283
|
+
const subs = { name, siteName, mode }
|
|
284
|
+
const viewsDir = path.join(projectDir, 'lib', name, 'views')
|
|
285
|
+
const changed = []
|
|
286
|
+
const preserved = []
|
|
287
|
+
|
|
288
|
+
// Resolve every assembled view's explicit slot selection from the manifest
|
|
289
|
+
// before any write, so a conflicting or non-executable selection fails the
|
|
290
|
+
// whole reconcile instead of rendering part of it. A view without an assembly
|
|
291
|
+
// has no fragments to select. Feature instance mounts claim a host slot and
|
|
292
|
+
// override its presentation from the feature's config; the view's own stored
|
|
293
|
+
// selection is preserved dormant (never written) and restored on removal.
|
|
294
|
+
const mountOverrides = resolveFeatureMountOverrides(manifest)
|
|
295
|
+
if (!mountOverrides.ok) {
|
|
296
|
+
return { ok: false, errors: mountOverrides.errors, views: [], changed: [] }
|
|
297
|
+
}
|
|
298
|
+
const viewSelections = new Map()
|
|
299
|
+
for (const view of manifest.views) {
|
|
300
|
+
const src = viewTemplateDir(view.name)
|
|
301
|
+
if (!src || !hasViewAssembly(src)) continue
|
|
302
|
+
const resolved = resolveViewGenerationSelection(view, mountOverrides.byView)
|
|
303
|
+
if (!resolved.ok) {
|
|
304
|
+
return { ok: false, errors: resolved.errors, views: [], changed: [] }
|
|
305
|
+
}
|
|
306
|
+
viewSelections.set(view.name, resolved)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
await renderDerivedTree(
|
|
310
|
+
path.join(templatesRoot, 'app', 'app', '[[...slug]]'),
|
|
311
|
+
path.join(projectDir, 'app', '[[...slug]]'),
|
|
312
|
+
subs,
|
|
313
|
+
(rel) => classifyFile(path.posix.join('app/app/[[...slug]]', rel)),
|
|
314
|
+
changed,
|
|
315
|
+
undefined,
|
|
316
|
+
dryRun,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
await renderDerivedTree(
|
|
320
|
+
path.join(templatesRoot, 'app', 'lib'),
|
|
321
|
+
path.join(projectDir, 'lib'),
|
|
322
|
+
subs,
|
|
323
|
+
(rel) => classifyFile(path.posix.join('app/lib', rel)),
|
|
324
|
+
changed,
|
|
325
|
+
(rel) => {
|
|
326
|
+
const normalized = rel.replaceAll('\\', '/')
|
|
327
|
+
// The barrel and the composers are generated, not copied.
|
|
328
|
+
return (
|
|
329
|
+
normalized.endsWith('views/index.ts') ||
|
|
330
|
+
normalized === '__STOREFRONT__/state.ts' ||
|
|
331
|
+
normalized === '__STOREFRONT__/handlers.ts' ||
|
|
332
|
+
normalized === '__STOREFRONT__/composition.ts'
|
|
333
|
+
)
|
|
334
|
+
},
|
|
335
|
+
dryRun,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
const featuresDir = path.join(projectDir, 'lib', name, 'features')
|
|
339
|
+
const featureNames = manifest.features.map((feature) => feature.name).sort()
|
|
340
|
+
if (!dryRun) await mkdir(featuresDir, { recursive: true })
|
|
341
|
+
for (const featureName of featureNames) {
|
|
342
|
+
await renderDerivedTree(
|
|
343
|
+
path.join(templatesRoot, 'features', featureName),
|
|
344
|
+
path.join(featuresDir, featureName),
|
|
345
|
+
subs,
|
|
346
|
+
(rel) => classifyFile(path.posix.join('features', featureName, rel)),
|
|
347
|
+
changed,
|
|
348
|
+
undefined,
|
|
349
|
+
dryRun,
|
|
350
|
+
)
|
|
351
|
+
// Adapter needs are declared by the feature recipe, not templated, so the
|
|
352
|
+
// generated file always matches the registry the CLI validates against. A
|
|
353
|
+
// feature that declares no adapter needs (a pure editorial/mount feature)
|
|
354
|
+
// generates no runtime file at all — there is nothing for it to import.
|
|
355
|
+
const adapterNeeds = getFeature(featureName)?.adapter?.needs ?? []
|
|
356
|
+
if (adapterNeeds.length > 0) {
|
|
357
|
+
const adapterPath = path.join(featuresDir, featureName, 'adapter.ts')
|
|
358
|
+
const adapterContent = buildFeatureAdapter(featureName, adapterNeeds)
|
|
359
|
+
const prevAdapter = existsSync(adapterPath) ? await readFile(adapterPath, 'utf8') : null
|
|
360
|
+
if (prevAdapter !== adapterContent) {
|
|
361
|
+
if (!dryRun) {
|
|
362
|
+
await mkdir(path.dirname(adapterPath), { recursive: true })
|
|
363
|
+
await writeFile(adapterPath, adapterContent)
|
|
364
|
+
}
|
|
365
|
+
changed.push(adapterPath)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// Feature folders no longer in the manifest are removed, but a customized or
|
|
370
|
+
// unknown file inside one is preserved (never silently deleted) and reported
|
|
371
|
+
// back to the caller, exactly like a removed view's user-owned files.
|
|
372
|
+
if (existsSync(featuresDir)) {
|
|
373
|
+
const installed = new Set(featureNames)
|
|
374
|
+
for (const entry of await readdir(featuresDir, { withFileTypes: true })) {
|
|
375
|
+
if (!entry.isDirectory() || installed.has(entry.name)) continue
|
|
376
|
+
const removal = await removeFeatureFiles(projectDir, manifest, entry.name, { dryRun })
|
|
377
|
+
for (const rel of removal.removed) changed.push(path.join(projectDir, rel))
|
|
378
|
+
preserved.push(...removal.preserved)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (!dryRun) await mkdir(viewsDir, { recursive: true })
|
|
383
|
+
for (const view of manifest.views) {
|
|
384
|
+
const src = viewTemplateDir(view.name)
|
|
385
|
+
if (!src) continue
|
|
386
|
+
// A view with an assembly composes its derived index.tsx/registry.tsx from
|
|
387
|
+
// the selected per-slot fragments; the assembly sources are never copied and
|
|
388
|
+
// the assembled files are written below instead of copied verbatim.
|
|
389
|
+
const assembled = hasViewAssembly(src)
|
|
390
|
+
const skip = (rel) => {
|
|
391
|
+
const normalized = rel.split(path.sep).join('/')
|
|
392
|
+
if (isAssemblyRel(rel)) return true
|
|
393
|
+
return assembled && (normalized === 'index.tsx' || normalized === 'registry.tsx')
|
|
394
|
+
}
|
|
395
|
+
await renderDerivedTree(
|
|
396
|
+
src,
|
|
397
|
+
path.join(viewsDir, view.name),
|
|
398
|
+
subs,
|
|
399
|
+
(rel) => classifyFile(path.posix.join('views', view.name, rel)),
|
|
400
|
+
changed,
|
|
401
|
+
skip,
|
|
402
|
+
dryRun,
|
|
403
|
+
)
|
|
404
|
+
if (assembled) {
|
|
405
|
+
const resolved = viewSelections.get(view.name)
|
|
406
|
+
const written = await writeViewAssembly({
|
|
407
|
+
viewDir: path.join(viewsDir, view.name),
|
|
408
|
+
templateDir: src,
|
|
409
|
+
selection: resolved?.selection ?? {},
|
|
410
|
+
sections: resolved?.sections ?? null,
|
|
411
|
+
render: (source) => replacePlaceholders(source, subs),
|
|
412
|
+
dryRun,
|
|
413
|
+
})
|
|
414
|
+
for (const abs of written) changed.push(abs)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// View directories no longer in the manifest are removed, exactly like the
|
|
419
|
+
// feature folders below. Without this, an abrupt apply kill after the derived
|
|
420
|
+
// writes but before the manifest leaves an orphan derived view on disk that a
|
|
421
|
+
// later `sync` regenerates the barrels around but never deletes, so the
|
|
422
|
+
// divergence would survive silently (E1 task 4.8). Only registered view names
|
|
423
|
+
// are considered, so a shared directory such as `views/sections` (rendered
|
|
424
|
+
// from the app/lib template tree, not a view) is never touched. A pristine
|
|
425
|
+
// template file is removed; a customized or unknown user-owned file is
|
|
426
|
+
// preserved and reported.
|
|
427
|
+
const installedViews = new Set(manifest.views.map((view) => view.name))
|
|
428
|
+
const registeredViews = new Set(allowedViewNames())
|
|
429
|
+
if (existsSync(viewsDir)) {
|
|
430
|
+
for (const entry of await readdir(viewsDir, { withFileTypes: true })) {
|
|
431
|
+
if (!entry.isDirectory() || installedViews.has(entry.name)) continue
|
|
432
|
+
if (!registeredViews.has(entry.name)) continue
|
|
433
|
+
const removal = await removeViewFiles(projectDir, manifest, entry.name, { dryRun })
|
|
434
|
+
for (const rel of removal.removed) changed.push(path.join(projectDir, rel))
|
|
435
|
+
preserved.push(...removal.preserved)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// The barrel is generated from the manifest's installed views — never by
|
|
440
|
+
// scanning leftover view directories, so a view removed from the manifest
|
|
441
|
+
// cannot reappear in the wiring.
|
|
442
|
+
const viewNames = manifest.views.map((view) => view.name)
|
|
443
|
+
const barrelPath = path.join(viewsDir, 'index.ts')
|
|
444
|
+
const prevBarrel = existsSync(barrelPath) ? await readFile(barrelPath, 'utf8') : null
|
|
445
|
+
const nextBarrel = buildBarrel(viewNames)
|
|
446
|
+
if (prevBarrel !== nextBarrel) {
|
|
447
|
+
if (!dryRun) await writeBarrel(viewsDir, viewNames)
|
|
448
|
+
changed.push(barrelPath)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// The client view-registry barrel imports only each view's client mapping, so
|
|
452
|
+
// the generated shell registry never pulls the server loaders into the client
|
|
453
|
+
// graph. Generated from the manifest's views, like the server barrel.
|
|
454
|
+
const clientBarrelPath = path.join(viewsDir, 'registry.ts')
|
|
455
|
+
const prevClientBarrel = existsSync(clientBarrelPath) ? await readFile(clientBarrelPath, 'utf8') : null
|
|
456
|
+
const nextClientBarrel = buildClientRegistryBarrel(viewNames)
|
|
457
|
+
if (prevClientBarrel !== nextClientBarrel) {
|
|
458
|
+
if (!dryRun) await writeClientRegistryBarrel(viewsDir, viewNames)
|
|
459
|
+
changed.push(clientBarrelPath)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const layoutResult = await ensureLayoutAppend(projectDir, name, siteName, mode, dryRun)
|
|
463
|
+
if (layoutResult.conflict) {
|
|
464
|
+
return { ok: false, errors: [layoutResult.conflict], views: [], changed: [] }
|
|
465
|
+
}
|
|
466
|
+
if (layoutResult.changed) {
|
|
467
|
+
changed.push(path.join(projectDir, 'app', 'layout.tsx'))
|
|
468
|
+
}
|
|
469
|
+
if (await ensureGlobalsAppend(projectDir, name, siteName, dryRun)) {
|
|
470
|
+
changed.push(path.join(projectDir, 'app', 'globals.css'))
|
|
471
|
+
}
|
|
472
|
+
for (const seoFile of ['sitemap.ts', 'robots.ts']) {
|
|
473
|
+
if (await ensureSeedFile(projectDir, seoFile, subs, dryRun)) {
|
|
474
|
+
changed.push(path.join(projectDir, 'app', seoFile))
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// The managed root guidance file is derived: rendered from the package
|
|
479
|
+
// template with the project placeholders, refreshed by sync, and reported as
|
|
480
|
+
// drift by doctor's dry-run. It is never seeded-and-frozen (not user-owned),
|
|
481
|
+
// so the rules a store enforces always match the installed CLI generation.
|
|
482
|
+
if (classifyFile('AGENTS.md') === 'derived') {
|
|
483
|
+
const agentsTemplate = path.join(templatesRoot, 'AGENTS.md')
|
|
484
|
+
if (existsSync(agentsTemplate)) {
|
|
485
|
+
const agentsPath = path.join(projectDir, 'AGENTS.md')
|
|
486
|
+
const nextAgents = replacePlaceholders(await readFile(agentsTemplate, 'utf8'), subs)
|
|
487
|
+
const prevAgents = existsSync(agentsPath) ? await readFile(agentsPath, 'utf8') : null
|
|
488
|
+
if (prevAgents !== nextAgents) {
|
|
489
|
+
if (!dryRun) await writeFile(agentsPath, nextAgents)
|
|
490
|
+
changed.push(agentsPath)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Composer files are generated from the installed features, not templated.
|
|
496
|
+
// A feature contributes to a composer only when the registry declares it owns
|
|
497
|
+
// state/handlers for it; a pure editorial feature (no state, no handlers)
|
|
498
|
+
// contributes nothing and its missing fragment is never imported.
|
|
499
|
+
const stateFeatures = featureNames.filter((n) => (getFeature(n)?.state?.length ?? 0) > 0)
|
|
500
|
+
const handlerFeatures = featureNames.filter((n) => (getFeature(n)?.handlers?.length ?? 0) > 0)
|
|
501
|
+
for (const [composerPath, content] of [
|
|
502
|
+
[path.join(projectDir, 'lib', name, 'state.ts'), buildStateComposer(stateFeatures)],
|
|
503
|
+
[path.join(projectDir, 'lib', name, 'handlers.ts'), buildHandlersComposer(handlerFeatures)],
|
|
504
|
+
[path.join(projectDir, 'lib', name, 'composition.ts'), buildCompositionComposer(manifest.features, manifest.views)],
|
|
505
|
+
]) {
|
|
506
|
+
const prev = existsSync(composerPath) ? await readFile(composerPath, 'utf8') : null
|
|
507
|
+
if (prev === content) continue
|
|
508
|
+
if (!dryRun) {
|
|
509
|
+
await mkdir(path.dirname(composerPath), { recursive: true })
|
|
510
|
+
await writeFile(composerPath, content)
|
|
511
|
+
}
|
|
512
|
+
changed.push(composerPath)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
ok: true,
|
|
517
|
+
views: manifest.views.map((view) => view.name),
|
|
518
|
+
changed: changed.map((abs) => path.relative(projectDir, abs)),
|
|
519
|
+
preserved,
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function main() {
|
|
524
|
+
const projectDir = process.argv[2]
|
|
525
|
+
if (!projectDir) {
|
|
526
|
+
console.error('Usage: node scripts/reconcile.mjs <project-dir>')
|
|
527
|
+
return 1
|
|
528
|
+
}
|
|
529
|
+
const { readManifest } = await import('./manifest.mjs')
|
|
530
|
+
const manifest = await readManifest(projectDir)
|
|
531
|
+
if (!manifest) {
|
|
532
|
+
console.error('No storefront.config.json found in ' + projectDir)
|
|
533
|
+
return 1
|
|
534
|
+
}
|
|
535
|
+
const result = await reconcile(projectDir, manifest)
|
|
536
|
+
if (!result.ok) {
|
|
537
|
+
console.error('Refusing to reconcile: the composition is invalid.')
|
|
538
|
+
for (const error of result.errors) console.error(` ${error}`)
|
|
539
|
+
return 1
|
|
540
|
+
}
|
|
541
|
+
console.log('Reconciled ' + projectDir)
|
|
542
|
+
return 0
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
546
|
+
main().then((code) => {
|
|
547
|
+
process.exitCode = code
|
|
548
|
+
})
|
|
549
|
+
}
|