@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,298 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Composition manifest for a generated storefront.
|
|
3
|
+
//
|
|
4
|
+
// The manifest (`storefront.config.json` at the project root) is the single
|
|
5
|
+
// source of truth for what a storefront is composed of: its theme mode, the
|
|
6
|
+
// installed views, the installed features, and the selected intent for each
|
|
7
|
+
// block slot. Structural files are derived from it; user-owned files are not.
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
import { validateAgainstSchema } from './schema-validate.mjs'
|
|
13
|
+
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
// The published schema is the single source of truth for the manifest shape;
|
|
16
|
+
// registry semantics (known units, intents, variants, dependencies) are checked
|
|
17
|
+
// separately by the shared preflight.
|
|
18
|
+
const manifestSchema = JSON.parse(
|
|
19
|
+
readFileSync(join(here, '..', 'recipes', 'manifest.schema.json'), 'utf8')
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
export const MANIFEST_FILE = 'storefront.config.json'
|
|
23
|
+
export const MANIFEST_VERSION = '1.0.0'
|
|
24
|
+
export const MANIFEST_SCHEMA_ID = 'https://thorcommerce.dev/storefront/manifest.schema.json'
|
|
25
|
+
export const THEME_MODES = ['light', 'dark', 'system']
|
|
26
|
+
export const DEFAULT_THEME_MODE = 'system'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalize a view entry to its canonical manifest shape.
|
|
30
|
+
* Accepts a bare view name or a partial object.
|
|
31
|
+
*/
|
|
32
|
+
export function normalizeView(view) {
|
|
33
|
+
if (typeof view === 'string') {
|
|
34
|
+
return { name: view, intents: {}, variants: {}, sections: [] }
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
name: view.name,
|
|
38
|
+
intents: { ...(view.intents ?? {}) },
|
|
39
|
+
variants: { ...(view.variants ?? {}) },
|
|
40
|
+
sections: (view.sections ?? []).map((section) =>
|
|
41
|
+
typeof section === 'string' ? { name: section } : { ...section }
|
|
42
|
+
),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalize a feature entry to its canonical manifest shape.
|
|
48
|
+
* Accepts a bare feature name or a partial object.
|
|
49
|
+
*/
|
|
50
|
+
export function normalizeFeature(feature) {
|
|
51
|
+
if (typeof feature === 'string') {
|
|
52
|
+
return { name: feature, config: {} }
|
|
53
|
+
}
|
|
54
|
+
return { name: feature.name, config: { ...(feature.config ?? {}) } }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build a canonical manifest from scaffold inputs.
|
|
59
|
+
*/
|
|
60
|
+
export function createManifest({
|
|
61
|
+
name,
|
|
62
|
+
siteName,
|
|
63
|
+
mode = DEFAULT_THEME_MODE,
|
|
64
|
+
views = [],
|
|
65
|
+
features = [],
|
|
66
|
+
} = {}) {
|
|
67
|
+
return {
|
|
68
|
+
$schema: MANIFEST_SCHEMA_ID,
|
|
69
|
+
version: MANIFEST_VERSION,
|
|
70
|
+
name,
|
|
71
|
+
siteName: siteName ?? name,
|
|
72
|
+
theme: { mode },
|
|
73
|
+
views: views.map(normalizeView),
|
|
74
|
+
features: features.map(normalizeFeature),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function serializeManifest(manifest) {
|
|
79
|
+
return `${JSON.stringify(manifest, null, 2)}\n`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseManifest(text) {
|
|
83
|
+
return JSON.parse(text)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate a manifest's structural shape against the published schema, plus the
|
|
88
|
+
* uniqueness invariant the schema cannot express. Registry semantics (known
|
|
89
|
+
* units, intents, variants, slots, dependencies) are checked by the shared
|
|
90
|
+
* preflight, not here, so a shape-valid manifest stays readable on its own.
|
|
91
|
+
* Returns { ok, errors }.
|
|
92
|
+
*/
|
|
93
|
+
export function validateManifest(manifest) {
|
|
94
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
95
|
+
return { ok: false, errors: ['Manifest must be a JSON object.'] }
|
|
96
|
+
}
|
|
97
|
+
const errors = validateAgainstSchema(manifest, manifestSchema)
|
|
98
|
+
errors.push(...duplicateNameErrors(manifest.views, 'view'))
|
|
99
|
+
errors.push(...duplicateNameErrors(manifest.features, 'feature'))
|
|
100
|
+
errors.push(...sectionEntryErrors(manifest.views))
|
|
101
|
+
return { ok: errors.length === 0, errors }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function duplicateNameErrors(entries, kind) {
|
|
105
|
+
const errors = []
|
|
106
|
+
if (!Array.isArray(entries)) return errors
|
|
107
|
+
const seen = new Set()
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (!entry || typeof entry !== 'object') continue
|
|
110
|
+
if (typeof entry.name !== 'string' || !entry.name) continue
|
|
111
|
+
if (seen.has(entry.name)) errors.push(`Duplicate ${kind} "${entry.name}".`)
|
|
112
|
+
else seen.add(entry.name)
|
|
113
|
+
}
|
|
114
|
+
return errors
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The published schema can express each `sections[]` entry's shape but not the
|
|
118
|
+
// cross-entry invariants: legacy-vs-new discrimination, the reserved
|
|
119
|
+
// `id === "slot:" + type` form, and id uniqueness within a view. Those are
|
|
120
|
+
// enforced here so an invalid or dangling entry is refused before any derived
|
|
121
|
+
// file is written. Legacy `{ name, variant }` entries stay accepted until the
|
|
122
|
+
// deterministic migration (E3 task 1.5) rewrites them.
|
|
123
|
+
function sectionEntryErrors(views) {
|
|
124
|
+
const errors = []
|
|
125
|
+
if (!Array.isArray(views)) return errors
|
|
126
|
+
for (const view of views) {
|
|
127
|
+
if (!view || typeof view !== 'object' || Array.isArray(view)) continue
|
|
128
|
+
if (!Array.isArray(view.sections)) continue
|
|
129
|
+
const seen = new Set()
|
|
130
|
+
view.sections.forEach((section, index) => {
|
|
131
|
+
const label = `views["${view.name}"].sections[${index}]`
|
|
132
|
+
if (!section || typeof section !== 'object' || Array.isArray(section)) {
|
|
133
|
+
errors.push(`${label} must be an object.`)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
const hasName = typeof section.name === 'string' && section.name.length > 0
|
|
137
|
+
const hasId = typeof section.id === 'string' && section.id.length > 0
|
|
138
|
+
const hasType = typeof section.type === 'string' && section.type.length > 0
|
|
139
|
+
|
|
140
|
+
if (hasId || hasType) {
|
|
141
|
+
if (!hasId || !hasType) {
|
|
142
|
+
errors.push(`${label} must declare both "id" and "type" (or a legacy "name").`)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if (hasName) {
|
|
146
|
+
errors.push(`${label} cannot mix the legacy "name" field with "id"/"type".`)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (section.id.startsWith('slot:') && section.id !== `slot:${section.type}`) {
|
|
150
|
+
errors.push(
|
|
151
|
+
`${label} reserved slot id "${section.id}" must equal "slot:" + type ("slot:${section.type}").`
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
if (seen.has(section.id)) errors.push(`${label} duplicate section id "${section.id}".`)
|
|
155
|
+
else seen.add(section.id)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (hasName) {
|
|
160
|
+
if (seen.has(section.name)) errors.push(`${label} duplicate section name "${section.name}".`)
|
|
161
|
+
else seen.add(section.name)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
errors.push(`${label} is neither a legacy { name } entry nor an { id, type } entry.`)
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
return errors
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function manifestPath(projectDir) {
|
|
172
|
+
return join(projectDir, MANIFEST_FILE)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function hasManifest(projectDir) {
|
|
176
|
+
return existsSync(manifestPath(projectDir))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Read the manifest at a project root, or null when it does not exist.
|
|
181
|
+
* Malformed JSON throws, so callers can surface a precise error.
|
|
182
|
+
*/
|
|
183
|
+
export function readManifest(projectDir) {
|
|
184
|
+
if (!hasManifest(projectDir)) return null
|
|
185
|
+
return parseManifest(readFileSync(manifestPath(projectDir), 'utf8'))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The manifest is the composition source of truth, so the writer refuses to
|
|
189
|
+
// persist a document that does not satisfy the published schema. This makes the
|
|
190
|
+
// shape contract unbypassable from any caller: full registry/dependency
|
|
191
|
+
// semantics are additionally enforced by `preflightManifest` at every CLI write
|
|
192
|
+
// boundary and by `reconcile` before it renders.
|
|
193
|
+
export function writeManifest(projectDir, manifest) {
|
|
194
|
+
const shape = validateManifest(manifest)
|
|
195
|
+
if (!shape.ok) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Refusing to write an invalid manifest:\n${shape.errors.map((error) => ` ${error}`).join('\n')}`
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
writeFileSync(manifestPath(projectDir), serializeManifest(manifest))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Whether a directory looks like a storefront project even without a manifest
|
|
205
|
+
* (a `lib/<name>/` module holding a `views/` directory or a `registry.tsx`).
|
|
206
|
+
* Used so migration and reconcile can run on a pre-manifest storefront.
|
|
207
|
+
*/
|
|
208
|
+
export function looksLikeStorefront(projectDir) {
|
|
209
|
+
const libDir = join(projectDir, 'lib')
|
|
210
|
+
if (!existsSync(libDir)) return false
|
|
211
|
+
return readdirSync(libDir, { withFileTypes: true })
|
|
212
|
+
.filter((entry) => entry.isDirectory())
|
|
213
|
+
.some(
|
|
214
|
+
(entry) =>
|
|
215
|
+
existsSync(join(libDir, entry.name, 'views')) ||
|
|
216
|
+
existsSync(join(libDir, entry.name, 'registry.tsx'))
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Search upward from startDir for the storefront project root (the nearest
|
|
222
|
+
* ancestor holding a manifest, or a storefront-shaped project). Returns the
|
|
223
|
+
* directory or null.
|
|
224
|
+
*/
|
|
225
|
+
export function findProjectRoot(startDir) {
|
|
226
|
+
let current = startDir
|
|
227
|
+
while (true) {
|
|
228
|
+
if (hasManifest(current) || looksLikeStorefront(current)) return current
|
|
229
|
+
const parent = dirname(current)
|
|
230
|
+
if (parent === current) return null
|
|
231
|
+
current = parent
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function titleCase(value) {
|
|
236
|
+
return value
|
|
237
|
+
.split(/[-_\s]+/)
|
|
238
|
+
.filter(Boolean)
|
|
239
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
240
|
+
.join(' ')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function detectStorefrontName(projectDir) {
|
|
244
|
+
const libDir = join(projectDir, 'lib')
|
|
245
|
+
if (!existsSync(libDir)) return null
|
|
246
|
+
const candidates = readdirSync(libDir, { withFileTypes: true })
|
|
247
|
+
.filter((entry) => entry.isDirectory())
|
|
248
|
+
.filter(
|
|
249
|
+
(entry) =>
|
|
250
|
+
existsSync(join(libDir, entry.name, 'views')) ||
|
|
251
|
+
existsSync(join(libDir, entry.name, 'registry.tsx'))
|
|
252
|
+
)
|
|
253
|
+
.map((entry) => entry.name)
|
|
254
|
+
.sort()
|
|
255
|
+
return candidates[0] ?? null
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function detectViews(projectDir, name) {
|
|
259
|
+
const viewsDir = join(projectDir, 'lib', name, 'views')
|
|
260
|
+
if (!existsSync(viewsDir)) return []
|
|
261
|
+
return readdirSync(viewsDir, { withFileTypes: true })
|
|
262
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(viewsDir, entry.name, 'index.tsx')))
|
|
263
|
+
.map((entry) => entry.name)
|
|
264
|
+
.sort()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function detectThemeMode(projectDir) {
|
|
268
|
+
const layoutPath = join(projectDir, 'app', 'layout.tsx')
|
|
269
|
+
if (!existsSync(layoutPath)) return DEFAULT_THEME_MODE
|
|
270
|
+
const match = readFileSync(layoutPath, 'utf8').match(/defaultTheme=["']([^"']+)["']/)
|
|
271
|
+
return match && THEME_MODES.includes(match[1]) ? match[1] : DEFAULT_THEME_MODE
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function detectSiteName(projectDir, name) {
|
|
275
|
+
const layoutPath = join(projectDir, 'app', 'layout.tsx')
|
|
276
|
+
if (existsSync(layoutPath)) {
|
|
277
|
+
const match = readFileSync(layoutPath, 'utf8').match(/title:\s*['"`]([^'"`]+)['"`]/)
|
|
278
|
+
if (match && match[1]) return match[1]
|
|
279
|
+
}
|
|
280
|
+
return titleCase(name)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Derive an initial manifest from an existing (pre-manifest) storefront
|
|
285
|
+
* composition so `sync` can migrate a project in place. Returns null when no
|
|
286
|
+
* storefront composition can be found.
|
|
287
|
+
*/
|
|
288
|
+
export function deriveManifest(projectDir) {
|
|
289
|
+
const name = detectStorefrontName(projectDir)
|
|
290
|
+
if (!name) return null
|
|
291
|
+
return createManifest({
|
|
292
|
+
name,
|
|
293
|
+
siteName: detectSiteName(projectDir, name),
|
|
294
|
+
mode: detectThemeMode(projectDir),
|
|
295
|
+
views: detectViews(projectDir, name),
|
|
296
|
+
features: [],
|
|
297
|
+
})
|
|
298
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Opt-in migration for the retired Spanish route literals in an existing
|
|
3
|
+
// consumer's USER-OWNED content files (change: storefront-english-routes,
|
|
4
|
+
// task 3.4 / AC-EN-5.4).
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS EXISTS: `storefront sync` never rewrites a user-owned file, so a
|
|
7
|
+
// consumer created before the English-route change keeps Spanish links in its
|
|
8
|
+
// own `lib/<name>/shell/content.ts` (and the user-owned static-page `path`
|
|
9
|
+
// declarations in `lib/<name>/views/static/content.ts`). Changing the package
|
|
10
|
+
// default cannot change an existing consumer's links, so this script is the
|
|
11
|
+
// documented, CLI-bounded, opt-in transition. It:
|
|
12
|
+
// * rewrites ONLY the retired route literal at the START of an `href`/`path`
|
|
13
|
+
// string property value (root-relative), preserving the backend handle and
|
|
14
|
+
// any `?query`/`#hash`,
|
|
15
|
+
// * ONLY inside the two user-owned content files on the allow-list,
|
|
16
|
+
// * ONLY when run with `--apply` (dry-run by default),
|
|
17
|
+
// * keeps a no-clobber backup of each file it rewrites, and writes
|
|
18
|
+
// transactionally (all files or none),
|
|
19
|
+
// * and preserves unrelated prose, comments, absolute URLs, and nested
|
|
20
|
+
// `/api/...` paths — it never re-parses the whole file as a grammar.
|
|
21
|
+
//
|
|
22
|
+
// Usage:
|
|
23
|
+
// node scripts/migrate-english-routes.mjs <project-dir> # dry run
|
|
24
|
+
// node scripts/migrate-english-routes.mjs <project-dir> --apply # write
|
|
25
|
+
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
|
27
|
+
import { resolve, join, relative, dirname, isAbsolute, sep } from 'node:path'
|
|
28
|
+
import { fileURLToPath } from 'node:url'
|
|
29
|
+
import { readManifest } from './manifest.mjs'
|
|
30
|
+
import { assertInsideProject, atomicWrite, restoreSnapshot, takeSnapshot } from './plan-apply.mjs'
|
|
31
|
+
|
|
32
|
+
// Retired route literal -> English route literal. These are matched ONLY as the
|
|
33
|
+
// FIRST path segment of an `href`/`path` string property value.
|
|
34
|
+
export const RETIRED = [
|
|
35
|
+
['/categorias', '/categories'],
|
|
36
|
+
['/productos', '/products'],
|
|
37
|
+
['/cuenta', '/account'],
|
|
38
|
+
['/contacto', '/contact'],
|
|
39
|
+
['/terminos', '/terms'],
|
|
40
|
+
['/privacidad', '/privacy'],
|
|
41
|
+
['/sobre-nosotros', '/about'],
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
// A safe single-segment storefront name: no separators, no `..`, no leading dot.
|
|
45
|
+
const SAFE_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/
|
|
46
|
+
// The property keys whose string value is a route.
|
|
47
|
+
const ROUTE_PROP = /(?:^|[^A-Za-z0-9_$.-])(?:href|path)\s*:\s*$/
|
|
48
|
+
const QUOTES = new Set(["'", '"', '`'])
|
|
49
|
+
const BACKUP_DIR = join('.opencode', 'backups', 'english-routes')
|
|
50
|
+
|
|
51
|
+
/** The user-owned content files, relative to the project, that may carry a route literal. */
|
|
52
|
+
export function userOwnedRouteFiles(name) {
|
|
53
|
+
return [join('lib', name, 'shell', 'content.ts'), join('lib', name, 'views', 'static', 'content.ts')]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Rewrite a route value only when it is a retired root-relative route:
|
|
58
|
+
* `/productos/<handle>?q#h` -> `/products/<handle>?q#h` (handle/query/hash kept).
|
|
59
|
+
* Returns null when the value is not a retired route (absolute URL, `/api/...`,
|
|
60
|
+
* prose, an already-English route, or a different route).
|
|
61
|
+
*/
|
|
62
|
+
export function migrateRouteValue(value) {
|
|
63
|
+
for (const [from, to] of RETIRED) {
|
|
64
|
+
if (
|
|
65
|
+
value === from ||
|
|
66
|
+
value.startsWith(`${from}/`) ||
|
|
67
|
+
value.startsWith(`${from}?`) ||
|
|
68
|
+
value.startsWith(`${from}#`)
|
|
69
|
+
) {
|
|
70
|
+
return to + value.slice(from.length)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Split the source into code / string / comment tokens with a small scanner, so
|
|
77
|
+
// a retired literal inside a comment or prose is never touched and only a string
|
|
78
|
+
// token that directly follows an `href:`/`path:` property key is considered.
|
|
79
|
+
function scan(text) {
|
|
80
|
+
const tokens = []
|
|
81
|
+
const n = text.length
|
|
82
|
+
let i = 0
|
|
83
|
+
let codeStart = 0
|
|
84
|
+
const pushCode = (end) => {
|
|
85
|
+
if (end > codeStart) tokens.push({ type: 'code', text: text.slice(codeStart, end) })
|
|
86
|
+
}
|
|
87
|
+
while (i < n) {
|
|
88
|
+
const c = text[i]
|
|
89
|
+
if (c === '/' && text[i + 1] === '/') {
|
|
90
|
+
pushCode(i)
|
|
91
|
+
let j = text.indexOf('\n', i)
|
|
92
|
+
if (j === -1) j = n
|
|
93
|
+
tokens.push({ type: 'comment', text: text.slice(i, j) })
|
|
94
|
+
i = j
|
|
95
|
+
codeStart = i
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
if (c === '/' && text[i + 1] === '*') {
|
|
99
|
+
pushCode(i)
|
|
100
|
+
let j = text.indexOf('*/', i + 2)
|
|
101
|
+
j = j === -1 ? n : j + 2
|
|
102
|
+
tokens.push({ type: 'comment', text: text.slice(i, j) })
|
|
103
|
+
i = j
|
|
104
|
+
codeStart = i
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
if (QUOTES.has(c)) {
|
|
108
|
+
pushCode(i)
|
|
109
|
+
let j = i + 1
|
|
110
|
+
while (j < n) {
|
|
111
|
+
if (text[j] === '\\') {
|
|
112
|
+
j += 2
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
if (text[j] === c) {
|
|
116
|
+
j++
|
|
117
|
+
break
|
|
118
|
+
}
|
|
119
|
+
if (text[j] === '\n' && c !== '`') break
|
|
120
|
+
j++
|
|
121
|
+
}
|
|
122
|
+
tokens.push({ type: 'string', text: text.slice(i, j) })
|
|
123
|
+
i = j
|
|
124
|
+
codeStart = i
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
i++
|
|
128
|
+
}
|
|
129
|
+
pushCode(n)
|
|
130
|
+
return tokens
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Rewrite the retired route literals in an `href`/`path` value. Idempotent. */
|
|
134
|
+
export function migrateText(text) {
|
|
135
|
+
const tokens = scan(text)
|
|
136
|
+
for (let k = 1; k < tokens.length; k++) {
|
|
137
|
+
const token = tokens[k]
|
|
138
|
+
if (token.type !== 'string') continue
|
|
139
|
+
const prev = tokens[k - 1]
|
|
140
|
+
if (!prev || prev.type !== 'code' || !ROUTE_PROP.test(prev.text)) continue
|
|
141
|
+
if (token.text.length < 2 || token.text[0] !== token.text[token.text.length - 1]) continue
|
|
142
|
+
const quote = token.text[0]
|
|
143
|
+
const migrated = migrateRouteValue(token.text.slice(1, -1))
|
|
144
|
+
if (migrated === null) continue
|
|
145
|
+
tokens[k] = { type: 'string', text: quote + migrated + quote }
|
|
146
|
+
}
|
|
147
|
+
return tokens.map((token) => token.text).join('')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Containment: the resolved file must live under the project root, both
|
|
151
|
+
// lexically and (when it exists) through any symlinked ancestor.
|
|
152
|
+
function insideProject(projectDir, abs) {
|
|
153
|
+
const rel = relative(projectDir, abs)
|
|
154
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return false
|
|
155
|
+
if (!existsSync(abs)) return true
|
|
156
|
+
try {
|
|
157
|
+
const realRoot = realpathSync(projectDir)
|
|
158
|
+
const realAbs = realpathSync(abs)
|
|
159
|
+
return realAbs === realRoot || realAbs.startsWith(`${realRoot}${sep}`)
|
|
160
|
+
} catch {
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Plan the migration: the allow-listed files whose text would change. */
|
|
166
|
+
export async function planMigration(projectDir) {
|
|
167
|
+
const manifest = await readManifest(projectDir)
|
|
168
|
+
if (!manifest) return { ok: false, error: `no storefront.config.json found in ${projectDir}` }
|
|
169
|
+
const name = manifest.name
|
|
170
|
+
if (typeof name !== 'string' || !SAFE_NAME.test(name)) {
|
|
171
|
+
return { ok: false, error: `storefront name ${JSON.stringify(name)} is not a safe path segment` }
|
|
172
|
+
}
|
|
173
|
+
const root = resolve(projectDir)
|
|
174
|
+
const changes = []
|
|
175
|
+
for (const rel of userOwnedRouteFiles(name)) {
|
|
176
|
+
const abs = join(root, rel)
|
|
177
|
+
if (!insideProject(root, abs)) {
|
|
178
|
+
return { ok: false, error: `allow-listed path escapes the project: ${rel}` }
|
|
179
|
+
}
|
|
180
|
+
if (!existsSync(abs)) continue
|
|
181
|
+
const raw = readFileSync(abs, 'utf8')
|
|
182
|
+
const next = migrateText(raw)
|
|
183
|
+
if (next === raw) continue
|
|
184
|
+
changes.push({ file: rel, abs, raw, next })
|
|
185
|
+
}
|
|
186
|
+
return { ok: true, root, name, changes }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// No-clobber backup: create exclusively; an existing backup is reused only when
|
|
190
|
+
// it already holds this exact pre-migration content, otherwise the apply refuses.
|
|
191
|
+
function ensureBackup(root, rel, raw) {
|
|
192
|
+
const abs = join(root, BACKUP_DIR, rel)
|
|
193
|
+
// A symlinked `.opencode/backups` must never redirect the backup outside the
|
|
194
|
+
// project; the same containment guard the retirement backup uses.
|
|
195
|
+
assertInsideProject(root, abs)
|
|
196
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
197
|
+
if (existsSync(abs)) {
|
|
198
|
+
if (readFileSync(abs, 'utf8') !== raw) {
|
|
199
|
+
throw new Error(`refusing to overwrite an existing backup with different content: ${abs}`)
|
|
200
|
+
}
|
|
201
|
+
return { path: abs, created: false }
|
|
202
|
+
}
|
|
203
|
+
writeFileSync(abs, raw, { flag: 'wx' })
|
|
204
|
+
return { path: abs, created: true }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function migrateProject(projectDir, { apply = false } = {}) {
|
|
208
|
+
const plan = await planMigration(projectDir)
|
|
209
|
+
if (!plan.ok) return { ok: false, error: plan.error, changed: [] }
|
|
210
|
+
|
|
211
|
+
const planned = plan.changes.map((change) => ({ file: change.file, applied: false }))
|
|
212
|
+
if (!apply || plan.changes.length === 0) {
|
|
213
|
+
return { ok: true, apply, changed: planned, files: plan.changes.map((c) => c.abs), backups: [] }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const snapshot = takeSnapshot(plan.root, plan.changes.map((c) => c.abs))
|
|
217
|
+
const backups = []
|
|
218
|
+
const createdBackups = []
|
|
219
|
+
try {
|
|
220
|
+
for (const change of plan.changes) {
|
|
221
|
+
const backup = ensureBackup(plan.root, change.file, change.raw)
|
|
222
|
+
backups.push(backup.path)
|
|
223
|
+
if (backup.created) createdBackups.push(backup.path)
|
|
224
|
+
atomicWrite(change.abs, change.next)
|
|
225
|
+
}
|
|
226
|
+
} catch (error) {
|
|
227
|
+
restoreSnapshot(snapshot)
|
|
228
|
+
for (const backup of createdBackups) rmSync(backup, { force: true })
|
|
229
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error), changed: [] }
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
ok: true,
|
|
233
|
+
apply,
|
|
234
|
+
changed: plan.changes.map((change) => ({ file: change.file, applied: true })),
|
|
235
|
+
files: plan.changes.map((c) => c.abs),
|
|
236
|
+
backups,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function main() {
|
|
241
|
+
const args = process.argv.slice(2)
|
|
242
|
+
const projectDir = args.find((a) => !a.startsWith('-'))
|
|
243
|
+
const apply = args.includes('--apply')
|
|
244
|
+
if (!projectDir) {
|
|
245
|
+
console.error('Usage: node scripts/migrate-english-routes.mjs <project-dir> [--apply]')
|
|
246
|
+
return 1
|
|
247
|
+
}
|
|
248
|
+
const result = await migrateProject(resolve(projectDir), { apply })
|
|
249
|
+
if (!result.ok) {
|
|
250
|
+
console.error(result.error)
|
|
251
|
+
return 1
|
|
252
|
+
}
|
|
253
|
+
if (result.changed.length === 0) {
|
|
254
|
+
console.log('No retired Spanish route literal found in the user-owned content files — nothing to migrate.')
|
|
255
|
+
return 0
|
|
256
|
+
}
|
|
257
|
+
for (const entry of result.changed) {
|
|
258
|
+
console.log(` ${apply ? 'migrated' : 'would migrate'} ${entry.file}`)
|
|
259
|
+
}
|
|
260
|
+
for (const backup of result.backups) console.log(` backup ${backup}`)
|
|
261
|
+
if (!apply) {
|
|
262
|
+
console.log('Dry run — re-run with --apply to rewrite these user-owned files.')
|
|
263
|
+
}
|
|
264
|
+
return 0
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
268
|
+
main()
|
|
269
|
+
.then((code) => {
|
|
270
|
+
process.exitCode = code
|
|
271
|
+
})
|
|
272
|
+
.catch((err) => {
|
|
273
|
+
console.error(err)
|
|
274
|
+
process.exitCode = 1
|
|
275
|
+
})
|
|
276
|
+
}
|