@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,185 @@
|
|
|
1
|
+
// Color parsing, conversion and contrast for the theme controls.
|
|
2
|
+
//
|
|
3
|
+
// Dependency rationale (design decision 3): no CSS/color parser is present in
|
|
4
|
+
// this package or the workspace, and the storefront CLI pins an exact candidate
|
|
5
|
+
// toolset for generated consumers. Adding a color dependency would churn that
|
|
6
|
+
// pinned candidate for a boundary the CLI already owns, so the small, fully
|
|
7
|
+
// specified OKLCH <-> sRGB math lives here instead. It is deterministic,
|
|
8
|
+
// network-free and has no runtime dependencies.
|
|
9
|
+
//
|
|
10
|
+
// Conversions follow the standard OKLab formulation (Björn Ottosson) with the
|
|
11
|
+
// sRGB transfer function, so a value round-trips and contrast ratios match what
|
|
12
|
+
// a browser computes.
|
|
13
|
+
|
|
14
|
+
const clamp01 = (value) => Math.min(1, Math.max(0, value))
|
|
15
|
+
const toDeg = (rad) => (rad * 180) / Math.PI
|
|
16
|
+
const toRad = (deg) => (deg * Math.PI) / 180
|
|
17
|
+
|
|
18
|
+
// sRGB transfer functions (gamma <-> linear).
|
|
19
|
+
function srgbToLinear(channel) {
|
|
20
|
+
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
|
|
21
|
+
}
|
|
22
|
+
function linearToSrgb(channel) {
|
|
23
|
+
return channel <= 0.0031308 ? channel * 12.92 : 1.055 * channel ** (1 / 2.4) - 0.055
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// OKLCH -> linear sRGB (unclamped; out-of-gamut channels are reported as-is).
|
|
27
|
+
export function oklchToLinearSrgb({ l, c, h }) {
|
|
28
|
+
const hRad = toRad(h)
|
|
29
|
+
const a = c * Math.cos(hRad)
|
|
30
|
+
const b = c * Math.sin(hRad)
|
|
31
|
+
const l_ = l + 0.3963377774 * a + 0.2158037573 * b
|
|
32
|
+
const m_ = l - 0.1055613458 * a - 0.0638541728 * b
|
|
33
|
+
const s_ = l - 0.0894841775 * a - 1.291485548 * b
|
|
34
|
+
const lc = l_ ** 3
|
|
35
|
+
const mc = m_ ** 3
|
|
36
|
+
const sc = s_ ** 3
|
|
37
|
+
return {
|
|
38
|
+
r: 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
|
|
39
|
+
g: -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
|
|
40
|
+
b: -0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function linearSrgbToOklch({ r, g, b }) {
|
|
45
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
|
|
46
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
|
|
47
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
|
|
48
|
+
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s
|
|
49
|
+
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s
|
|
50
|
+
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
|
|
51
|
+
const c = Math.sqrt(A * A + B * B)
|
|
52
|
+
let h = toDeg(Math.atan2(B, A))
|
|
53
|
+
if (h < 0) h += 360
|
|
54
|
+
return { l: L, c, h }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function oklchToSrgb({ l, c, h }) {
|
|
58
|
+
const lin = oklchToLinearSrgb({ l, c, h })
|
|
59
|
+
return { r: linearToSrgb(lin.r), g: linearToSrgb(lin.g), b: linearToSrgb(lin.b) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function srgbToOklch({ r, g, b }) {
|
|
63
|
+
return linearSrgbToOklch({ r: srgbToLinear(r), g: srgbToLinear(g), b: srgbToLinear(b) })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Relative luminance (WCAG) from an OKLCH color, computed in linear sRGB.
|
|
67
|
+
export function relativeLuminance({ l, c, h }) {
|
|
68
|
+
const lin = oklchToLinearSrgb({ l, c, h })
|
|
69
|
+
return 0.2126 * clamp01(lin.r) + 0.7152 * clamp01(lin.g) + 0.0722 * clamp01(lin.b)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// WCAG contrast ratio between two OKLCH colors (>= 1).
|
|
73
|
+
export function contrastRatio(a, b) {
|
|
74
|
+
const la = relativeLuminance(a)
|
|
75
|
+
const lb = relativeLuminance(b)
|
|
76
|
+
const hi = Math.max(la, lb)
|
|
77
|
+
const lo = Math.min(la, lb)
|
|
78
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const WHITE = { l: 1, c: 0, h: 0 }
|
|
82
|
+
const BLACK = { l: 0, c: 0, h: 0 }
|
|
83
|
+
|
|
84
|
+
// Pick the readable foreground (black or white) for a background by measured
|
|
85
|
+
// contrast rather than an OKLCH lightness threshold. Returns the chosen color
|
|
86
|
+
// and the ratio it achieves.
|
|
87
|
+
export function bestForeground(background) {
|
|
88
|
+
const onWhite = contrastRatio(background, WHITE)
|
|
89
|
+
const onBlack = contrastRatio(background, BLACK)
|
|
90
|
+
return onWhite >= onBlack
|
|
91
|
+
? { color: { ...WHITE }, ratio: onWhite }
|
|
92
|
+
: { color: { ...BLACK }, ratio: onBlack }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A slightly darker (light mode) / lighter (dark mode) variant for hover states.
|
|
96
|
+
export function shiftLightness({ l, c, h }, delta) {
|
|
97
|
+
return { l: clamp01(l + delta), c, h }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function round(value, digits) {
|
|
101
|
+
const factor = 10 ** digits
|
|
102
|
+
return Math.round(value * factor) / factor
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Canonical CSS output. L keeps 4 significant decimals, C 3, H 2 (no trailing
|
|
106
|
+
// zeros beyond the rounding), matching the hand-authored token style.
|
|
107
|
+
export function formatOklch({ l, c, h }) {
|
|
108
|
+
const L = round(clamp01(l), 4)
|
|
109
|
+
const C = round(Math.max(0, c), 4)
|
|
110
|
+
let H = round(((h % 360) + 360) % 360, 2)
|
|
111
|
+
if (H === 360) H = 0
|
|
112
|
+
return `oklch(${L} ${C} ${H})`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Parse `oklch(L C H [/ A])`. Accepts L as a fraction (0-1) or a percentage.
|
|
116
|
+
export function parseOklch(input) {
|
|
117
|
+
const match = /^oklch\(\s*([\d.]+%?)\s+([\d.]+%?)\s+([\d.]+)(?:deg)?\s*(?:\/\s*([\d.]+%?))?\s*\)$/i.exec(
|
|
118
|
+
input.trim(),
|
|
119
|
+
)
|
|
120
|
+
if (!match) return null
|
|
121
|
+
const parseL = (token) => (token.endsWith('%') ? parseFloat(token) / 100 : parseFloat(token))
|
|
122
|
+
const l = parseL(match[1])
|
|
123
|
+
const c = match[2].endsWith('%') ? (parseFloat(match[2]) / 100) * 0.4 : parseFloat(match[2])
|
|
124
|
+
const h = parseFloat(match[3])
|
|
125
|
+
if ([l, c, h].some((n) => Number.isNaN(n))) return null
|
|
126
|
+
return { l, c, h }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseHex(input) {
|
|
130
|
+
const match = /^#([0-9a-f]{3,8})$/i.exec(input.trim())
|
|
131
|
+
if (!match) return null
|
|
132
|
+
let hex = match[1]
|
|
133
|
+
if (hex.length === 3 || hex.length === 4) {
|
|
134
|
+
hex = hex
|
|
135
|
+
.split('')
|
|
136
|
+
.map((ch) => ch + ch)
|
|
137
|
+
.join('')
|
|
138
|
+
}
|
|
139
|
+
if (hex.length !== 6 && hex.length !== 8) return null
|
|
140
|
+
const r = parseInt(hex.slice(0, 2), 16) / 255
|
|
141
|
+
const g = parseInt(hex.slice(2, 4), 16) / 255
|
|
142
|
+
const b = parseInt(hex.slice(4, 6), 16) / 255
|
|
143
|
+
return srgbToOklch({ r, g, b })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Named brand colors resolve to a vivid, mid-lightness OKLCH value so a request
|
|
147
|
+
// for "orange" yields a genuinely chromatic primary even when the preset token
|
|
148
|
+
// was achromatic (chroma 0). Hues mirror the runtime ACCENT_OKLCH palette.
|
|
149
|
+
export const NAMED_COLORS = {
|
|
150
|
+
red: { l: 0.58, c: 0.22, h: 27 },
|
|
151
|
+
orange: { l: 0.62, c: 0.19, h: 55 },
|
|
152
|
+
amber: { l: 0.7, c: 0.17, h: 75 },
|
|
153
|
+
yellow: { l: 0.8, c: 0.16, h: 95 },
|
|
154
|
+
lime: { l: 0.72, c: 0.19, h: 130 },
|
|
155
|
+
green: { l: 0.6, c: 0.17, h: 155 },
|
|
156
|
+
teal: { l: 0.6, c: 0.13, h: 185 },
|
|
157
|
+
cyan: { l: 0.62, c: 0.13, h: 210 },
|
|
158
|
+
blue: { l: 0.58, c: 0.2, h: 232 },
|
|
159
|
+
indigo: { l: 0.55, c: 0.22, h: 264 },
|
|
160
|
+
violet: { l: 0.55, c: 0.22, h: 290 },
|
|
161
|
+
purple: { l: 0.55, c: 0.22, h: 310 },
|
|
162
|
+
pink: { l: 0.62, c: 0.2, h: 350 },
|
|
163
|
+
slate: { l: 0.45, c: 0.04, h: 240 },
|
|
164
|
+
gray: { l: 0.55, c: 0.02, h: 240 },
|
|
165
|
+
neutral: { l: 0.55, c: 0.0, h: 0 },
|
|
166
|
+
stone: { l: 0.5, c: 0.02, h: 60 },
|
|
167
|
+
zinc: { l: 0.5, c: 0.01, h: 240 },
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Parse any accepted color input into an OKLCH object, or null when unsupported.
|
|
171
|
+
export function parseColor(input) {
|
|
172
|
+
if (typeof input !== 'string') return null
|
|
173
|
+
const value = input.trim()
|
|
174
|
+
if (value.startsWith('#') || value.startsWith('#')) return parseHex(value)
|
|
175
|
+
if (/^oklch\(/i.test(value)) return parseOklch(value)
|
|
176
|
+
const named = NAMED_COLORS[value.toLowerCase()]
|
|
177
|
+
if (named) return { ...named }
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function isNamedColor(input) {
|
|
182
|
+
return typeof input === 'string' && Object.prototype.hasOwnProperty.call(NAMED_COLORS, input.trim().toLowerCase())
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export const NAMED_COLOR_NAMES = Object.keys(NAMED_COLORS)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Bounded CSS theme extraction and merge.
|
|
2
|
+
//
|
|
3
|
+
// The storefront's visual tokens live in `:root` (light) and `.dark` blocks in
|
|
4
|
+
// app/globals.css. This module reads those declarations and writes new values
|
|
5
|
+
// back *in place*, preserving every unrelated byte — the Tailwind imports, the
|
|
6
|
+
// `@theme inline` bridge, comments, and any token the CLI does not manage. It
|
|
7
|
+
// never rewrites the whole stylesheet and never flattens `var()` aliases.
|
|
8
|
+
|
|
9
|
+
// Scan `css` from the `{` at/after `from` to its matching `}`, ignoring braces
|
|
10
|
+
// inside strings and comments. Returns the index just past the closing brace.
|
|
11
|
+
function matchBrace(css, from) {
|
|
12
|
+
let depth = 0
|
|
13
|
+
let quote = null
|
|
14
|
+
for (let i = from; i < css.length; i++) {
|
|
15
|
+
const ch = css[i]
|
|
16
|
+
if (quote) {
|
|
17
|
+
if (ch === '\\') i++
|
|
18
|
+
else if (ch === quote) quote = null
|
|
19
|
+
continue
|
|
20
|
+
}
|
|
21
|
+
if (ch === '/' && css[i + 1] === '*') {
|
|
22
|
+
const end = css.indexOf('*/', i + 2)
|
|
23
|
+
i = end === -1 ? css.length : end + 1
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
if (ch === '"' || ch === "'") {
|
|
27
|
+
quote = ch
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
if (ch === '{') depth++
|
|
31
|
+
else if (ch === '}') {
|
|
32
|
+
depth--
|
|
33
|
+
if (depth === 0) return i + 1
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return -1
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Find the first top-level block whose selector is exactly `selector`. A block is
|
|
40
|
+
// `selector { ... }`; we require the selector to be immediately followed (after
|
|
41
|
+
// whitespace) by `{` so `.dark-something` never matches `.dark`.
|
|
42
|
+
export function findBlock(css, selector) {
|
|
43
|
+
const re = new RegExp(`(^|[}\\s;])${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{`, 'g')
|
|
44
|
+
let match
|
|
45
|
+
while ((match = re.exec(css)) !== null) {
|
|
46
|
+
const braceIdx = css.indexOf('{', match.index)
|
|
47
|
+
const end = matchBrace(css, braceIdx)
|
|
48
|
+
if (end === -1) continue
|
|
49
|
+
const bodyStart = braceIdx + 1
|
|
50
|
+
return { start: match.index + (match[1] ? match[1].length : 0), bodyStart, bodyEnd: end - 1, end }
|
|
51
|
+
}
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Split a block body into `--name: value` declarations, tracking their exact
|
|
56
|
+
// offsets so callers can splice a single value without disturbing the rest.
|
|
57
|
+
export function parseDeclarations(body, baseOffset = 0) {
|
|
58
|
+
const decls = []
|
|
59
|
+
let i = 0
|
|
60
|
+
while (i < body.length) {
|
|
61
|
+
const semi = indexOfTopLevel(body, ';', i)
|
|
62
|
+
const chunkEnd = semi === -1 ? body.length : semi
|
|
63
|
+
const chunk = body.slice(i, chunkEnd)
|
|
64
|
+
const colon = chunk.indexOf(':')
|
|
65
|
+
if (colon !== -1) {
|
|
66
|
+
const name = chunk.slice(0, colon).trim()
|
|
67
|
+
if (name.startsWith('--')) {
|
|
68
|
+
const valueStartInChunk = chunk.indexOf(':', colon) + 1
|
|
69
|
+
const rawValue = chunk.slice(valueStartInChunk)
|
|
70
|
+
const leading = rawValue.length - rawValue.trimStart().length
|
|
71
|
+
const trailing = rawValue.length - rawValue.trimEnd().length
|
|
72
|
+
const valueStart = i + valueStartInChunk + leading
|
|
73
|
+
const valueEnd = chunkEnd - trailing
|
|
74
|
+
decls.push({ name, value: body.slice(valueStart, valueEnd), valueStart: baseOffset + valueStart, valueEnd: baseOffset + valueEnd })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (semi === -1) break
|
|
78
|
+
i = semi + 1
|
|
79
|
+
}
|
|
80
|
+
return decls
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Index of `ch` at paren/quote depth zero, or -1.
|
|
84
|
+
function indexOfTopLevel(text, ch, from) {
|
|
85
|
+
let depth = 0
|
|
86
|
+
let quote = null
|
|
87
|
+
for (let i = from; i < text.length; i++) {
|
|
88
|
+
const c = text[i]
|
|
89
|
+
if (quote) {
|
|
90
|
+
if (c === '\\') i++
|
|
91
|
+
else if (c === quote) quote = null
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (c === '"' || c === "'") quote = c
|
|
95
|
+
else if (c === '(') depth++
|
|
96
|
+
else if (c === ')') depth--
|
|
97
|
+
else if (c === ch && depth === 0) return i
|
|
98
|
+
}
|
|
99
|
+
return -1
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Read the theme declarations of a stylesheet into light/dark maps.
|
|
103
|
+
export function readThemeCss(css) {
|
|
104
|
+
const read = (selector) => {
|
|
105
|
+
const block = findBlock(css, selector)
|
|
106
|
+
if (!block) return {}
|
|
107
|
+
const body = css.slice(block.bodyStart, block.bodyEnd)
|
|
108
|
+
const map = {}
|
|
109
|
+
for (const decl of parseDeclarations(body)) map[decl.name] = decl.value
|
|
110
|
+
return map
|
|
111
|
+
}
|
|
112
|
+
return { light: read(':root'), dark: read('.dark') }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Apply a list of `{ scope, name, value }` edits, replacing existing
|
|
116
|
+
// declarations in place and appending missing ones to the end of their block.
|
|
117
|
+
// `scope` is 'light' (→ :root) or 'dark' (→ .dark). Returns the new CSS.
|
|
118
|
+
export function applyDeclarations(css, edits) {
|
|
119
|
+
const groups = { light: [], dark: [] }
|
|
120
|
+
for (const edit of edits) {
|
|
121
|
+
if (!groups[edit.scope]) throw new Error(`Unknown theme scope: ${edit.scope}`)
|
|
122
|
+
groups[edit.scope].push(edit)
|
|
123
|
+
}
|
|
124
|
+
let out = css
|
|
125
|
+
for (const scope of ['light', 'dark']) {
|
|
126
|
+
if (groups[scope].length === 0) continue
|
|
127
|
+
out = applyBlockDeclarations(out, scope === 'light' ? ':root' : '.dark', groups[scope])
|
|
128
|
+
}
|
|
129
|
+
return out
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Apply `{ name, value }` edits to any block by selector, replacing existing
|
|
133
|
+
// declarations in place and appending missing ones before the closing brace.
|
|
134
|
+
// When the block does not exist it is created at the end of the file. Used for
|
|
135
|
+
// the `@theme inline` font mapping, which is not a `:root` token block.
|
|
136
|
+
export function applyBlockDeclarations(css, selector, edits) {
|
|
137
|
+
let block = findBlock(css, selector)
|
|
138
|
+
if (!block) {
|
|
139
|
+
// No block yet: create one at the end of the file, preserving everything.
|
|
140
|
+
const body = edits.map((e) => ` ${e.name}: ${e.value};`).join('\n')
|
|
141
|
+
const prefix = css.replace(/\s*$/, '')
|
|
142
|
+
return `${prefix}\n\n${selector} {\n${body}\n}\n`
|
|
143
|
+
}
|
|
144
|
+
const body = css.slice(block.bodyStart, block.bodyEnd)
|
|
145
|
+
const decls = parseDeclarations(body, block.bodyStart)
|
|
146
|
+
const byName = new Map(decls.map((d) => [d.name, d]))
|
|
147
|
+
// Collapse multiple edits to the same declaration to the last one, so
|
|
148
|
+
// precedence is "later wins" and each name is spliced exactly once.
|
|
149
|
+
const collapsed = []
|
|
150
|
+
const indexByName = new Map()
|
|
151
|
+
for (const edit of edits) {
|
|
152
|
+
if (indexByName.has(edit.name)) collapsed[indexByName.get(edit.name)] = edit
|
|
153
|
+
else {
|
|
154
|
+
indexByName.set(edit.name, collapsed.length)
|
|
155
|
+
collapsed.push(edit)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const inserts = []
|
|
159
|
+
// Apply from the end so earlier offsets stay valid as we splice.
|
|
160
|
+
const replacements = []
|
|
161
|
+
for (const edit of collapsed) {
|
|
162
|
+
const existing = byName.get(edit.name)
|
|
163
|
+
if (existing) replacements.push({ start: existing.valueStart, end: existing.valueEnd, value: edit.value })
|
|
164
|
+
else inserts.push(edit)
|
|
165
|
+
}
|
|
166
|
+
replacements.sort((a, b) => b.start - a.start)
|
|
167
|
+
let next = css
|
|
168
|
+
for (const rep of replacements) next = next.slice(0, rep.start) + rep.value + next.slice(rep.end)
|
|
169
|
+
if (inserts.length > 0) {
|
|
170
|
+
// Re-locate after replacements, then insert before the block's closing brace.
|
|
171
|
+
block = findBlock(next, selector)
|
|
172
|
+
const insertText = '\n' + inserts.map((e) => ` ${e.name}: ${e.value};`).join('\n')
|
|
173
|
+
next = next.slice(0, block.bodyEnd) + insertText + next.slice(block.bodyEnd)
|
|
174
|
+
}
|
|
175
|
+
return next
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Fill only the named tokens that are absent from each scope, leaving any value
|
|
179
|
+
// a developer already set untouched. `defaults` is `{ light, dark }`.
|
|
180
|
+
export function fillMissingTokens(css, tokens, defaults) {
|
|
181
|
+
const current = readThemeCss(css)
|
|
182
|
+
const edits = []
|
|
183
|
+
for (const scope of ['light', 'dark']) {
|
|
184
|
+
for (const token of tokens) {
|
|
185
|
+
const name = `--${token}`
|
|
186
|
+
if (current[scope][name] === undefined && defaults[scope][token] !== undefined) {
|
|
187
|
+
edits.push({ scope, name, value: defaults[scope][token] })
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return applyDeclarations(css, edits)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Collect `var(--x)` references in a declaration value.
|
|
195
|
+
export function varReferences(value) {
|
|
196
|
+
const refs = []
|
|
197
|
+
const re = /var\(\s*(--[\w-]+)/g
|
|
198
|
+
let match
|
|
199
|
+
while ((match = re.exec(value)) !== null) refs.push(match[1])
|
|
200
|
+
return refs
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Detect references to undefined theme variables and reference cycles among the
|
|
204
|
+
// declarations of a stylesheet. Returns `{ undefined: [...], cycles: [[...]] }`.
|
|
205
|
+
export function validateVarReferences(css) {
|
|
206
|
+
const { light, dark } = readThemeCss(css)
|
|
207
|
+
const defined = new Set([...Object.keys(light), ...Object.keys(dark)])
|
|
208
|
+
const undefinedRefs = new Set()
|
|
209
|
+
const graph = new Map()
|
|
210
|
+
for (const scope of [light, dark]) {
|
|
211
|
+
for (const [name, value] of Object.entries(scope)) {
|
|
212
|
+
const refs = varReferences(value)
|
|
213
|
+
graph.set(name, refs)
|
|
214
|
+
for (const ref of refs) if (!defined.has(ref)) undefinedRefs.add(ref)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const cycles = []
|
|
218
|
+
const visiting = new Set()
|
|
219
|
+
const visited = new Set()
|
|
220
|
+
const walk = (node, path) => {
|
|
221
|
+
if (visiting.has(node)) {
|
|
222
|
+
cycles.push([...path.slice(path.indexOf(node)), node])
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
if (visited.has(node)) return
|
|
226
|
+
visiting.add(node)
|
|
227
|
+
for (const next of graph.get(node) ?? []) if (graph.has(next)) walk(next, [...path, node])
|
|
228
|
+
visiting.delete(node)
|
|
229
|
+
visited.add(node)
|
|
230
|
+
}
|
|
231
|
+
for (const node of graph.keys()) walk(node, [])
|
|
232
|
+
return { undefined: [...undefinedRefs], cycles }
|
|
233
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Font wiring for the bounded external layout (task 3.3).
|
|
2
|
+
//
|
|
3
|
+
// A font selection is either a hosted Google family (`google:<Family>`, loaded
|
|
4
|
+
// with `next/font/google`) or a CSS system stack (`system:<stack>`, no load).
|
|
5
|
+
// Hosted fonts get a real `next/font` import + variable + `<html>` class so the
|
|
6
|
+
// glyphs actually load; a bare family name is never treated as loaded. Only the
|
|
7
|
+
// selected slots are touched, so an unselected local font wiring is preserved.
|
|
8
|
+
|
|
9
|
+
function slug(family) {
|
|
10
|
+
return family
|
|
11
|
+
.trim()
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
14
|
+
.replace(/^-+|-+$/g, '')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function camel(family) {
|
|
18
|
+
return family
|
|
19
|
+
.trim()
|
|
20
|
+
.replace(/[^A-Za-z0-9]+(.)/g, (_, c) => c.toUpperCase())
|
|
21
|
+
.replace(/^./, (c) => c.toLowerCase())
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The identifier `next/font/google` exports for a family: each word capitalized
|
|
25
|
+
// and joined with `_` (`Open Sans` -> `Open_Sans`, `inter` -> `Inter`). A family
|
|
26
|
+
// stored as a lowercase slug (`inter`) still yields the correct export name.
|
|
27
|
+
function importName(family) {
|
|
28
|
+
return family
|
|
29
|
+
.trim()
|
|
30
|
+
.split(/[^A-Za-z0-9]+/)
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
33
|
+
.join('_')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const FONT_IMPORT_RE = /^import\s*\{([^}]*)\}\s*from\s*'next\/font\/google'\s*$/m
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Apply hosted font selections to app/layout.tsx. `fonts` is
|
|
40
|
+
* `{ sans?, mono?, serif? }` where each value is `{ kind: 'google'|'system',
|
|
41
|
+
* value }` or null. Returns `{ content, loaderVars, changed }`; `loaderVars`
|
|
42
|
+
* maps slot → the CSS variable next/font will inject (`--font-<slug>-<slot>`),
|
|
43
|
+
* so the stylesheet can point the Tailwind token at it.
|
|
44
|
+
*/
|
|
45
|
+
export function applyLayoutFonts(content, fonts = {}) {
|
|
46
|
+
const loaderVars = {}
|
|
47
|
+
let next = content
|
|
48
|
+
let changed = false
|
|
49
|
+
|
|
50
|
+
for (const slot of ['sans', 'mono', 'serif']) {
|
|
51
|
+
const selection = fonts[slot]
|
|
52
|
+
if (!selection || selection.kind !== 'google') continue
|
|
53
|
+
const family = selection.value
|
|
54
|
+
const varName = `--font-${slug(family)}-${slot}`
|
|
55
|
+
const binding = camel(family)
|
|
56
|
+
const exported = importName(family)
|
|
57
|
+
loaderVars[slot] = varName
|
|
58
|
+
|
|
59
|
+
// 1. Ensure the named import from next/font/google.
|
|
60
|
+
const existingImport = next.match(FONT_IMPORT_RE)
|
|
61
|
+
if (existingImport) {
|
|
62
|
+
const names = existingImport[1]
|
|
63
|
+
.split(',')
|
|
64
|
+
.map((s) => s.trim())
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
if (!names.includes(exported)) {
|
|
67
|
+
names.push(exported)
|
|
68
|
+
next = next.replace(FONT_IMPORT_RE, `import { ${names.join(', ')} } from 'next/font/google'`)
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
const anchor = next.match(/^import[^\n]*$/m)
|
|
72
|
+
const importLine = `import { ${exported} } from 'next/font/google'`
|
|
73
|
+
next = anchor ? next.replace(anchor[0], `${anchor[0]}\n${importLine}`) : `${importLine}\n${next}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 2. Ensure the loader const.
|
|
77
|
+
const constRe = new RegExp(`const\\s+${binding}\\s*=\\s*${exported}\\(`)
|
|
78
|
+
if (!constRe.test(next)) {
|
|
79
|
+
const loader = `const ${binding} = ${exported}({ variable: '${varName}', subsets: ['latin'] })`
|
|
80
|
+
const lines = next.split('\n')
|
|
81
|
+
let lastImport = -1
|
|
82
|
+
for (let i = 0; i < lines.length; i++) if (lines[i].trimStart().startsWith('import ')) lastImport = i
|
|
83
|
+
if (lastImport >= 0) lines.splice(lastImport + 1, 0, '', loader)
|
|
84
|
+
else lines.unshift(loader, '')
|
|
85
|
+
next = lines.join('\n')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 3. Ensure the variable is on the <html> className.
|
|
89
|
+
const htmlMatch = next.match(/<html\b([^>]*)>/)
|
|
90
|
+
if (htmlMatch) {
|
|
91
|
+
const attrs = htmlMatch[1]
|
|
92
|
+
if (!attrs.includes(`${binding}.variable`)) {
|
|
93
|
+
const classMatch = attrs.match(/className=\{([^}]*)\}/)
|
|
94
|
+
let newAttrs
|
|
95
|
+
if (classMatch) {
|
|
96
|
+
newAttrs = attrs.replace(classMatch[0], `className={[${classMatch[1]}, ${binding}.variable].join(' ')}`)
|
|
97
|
+
} else {
|
|
98
|
+
newAttrs = `${attrs} className={${binding}.variable}`
|
|
99
|
+
}
|
|
100
|
+
next = next.replace(htmlMatch[0], `<html${newAttrs}>`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
changed = next !== content
|
|
106
|
+
return { content: next, loaderVars, changed }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The stylesheet declarations a font selection implies. Hosted fonts map the
|
|
111
|
+
* Tailwind token to the variable next/font injects; system stacks are written
|
|
112
|
+
* directly. Returns a map of `--font-<slot>` → value.
|
|
113
|
+
*/
|
|
114
|
+
export function fontCssVars(fonts = {}, loaderVars = {}) {
|
|
115
|
+
const out = {}
|
|
116
|
+
for (const slot of ['sans', 'mono', 'serif']) {
|
|
117
|
+
const selection = fonts[slot]
|
|
118
|
+
if (!selection) continue
|
|
119
|
+
if (selection.kind === 'google' && loaderVars[slot]) out[`--font-${slot}`] = `var(${loaderVars[slot]})`
|
|
120
|
+
else if (selection.kind === 'system') out[`--font-${slot}`] = selection.value
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
123
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Portable theme import/export (tasks 4.2, 4.3).
|
|
2
|
+
//
|
|
3
|
+
// External theme sources are normalized through one data-only boundary:
|
|
4
|
+
// - a shadcn `registry:theme` JSON (`{ cssVars: { theme, light, dark } }`)
|
|
5
|
+
// - a local CSS export (a `:root` / `.dark` snippet from a generator)
|
|
6
|
+
// - a versioned storefront export written by `theme --export`
|
|
7
|
+
// The normalized result is always `{ tokens: { light, dark }, fonts? }`, never a
|
|
8
|
+
// second project config and never executable payloads.
|
|
9
|
+
|
|
10
|
+
import { readThemeCss } from './css-theme.mjs'
|
|
11
|
+
import { THEME_COLOR_TOKENS, FONT_VARS } from './tokens.mjs'
|
|
12
|
+
|
|
13
|
+
export const EXPORT_FORMAT = 'storefront-theme'
|
|
14
|
+
export const EXPORT_VERSION = 1
|
|
15
|
+
|
|
16
|
+
function fail(code, message, extra = {}) {
|
|
17
|
+
return { ok: false, diagnostics: [{ code, severity: 'error', message, ...extra }] }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const THEME_VAR = /^--(?:[a-z0-9-]+)$/
|
|
21
|
+
|
|
22
|
+
// Accept only theme-token and font declarations; drop anything else (component
|
|
23
|
+
// files, scripts, arbitrary payloads) with a diagnostic rather than applying it.
|
|
24
|
+
function filterDeclarations(raw) {
|
|
25
|
+
const kept = {}
|
|
26
|
+
const dropped = []
|
|
27
|
+
for (const [name, value] of Object.entries(raw ?? {})) {
|
|
28
|
+
if (typeof value !== 'string') {
|
|
29
|
+
dropped.push(name)
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
const key = name.replace(/^--/, '')
|
|
33
|
+
if (THEME_COLOR_TOKENS.includes(key) || FONT_VARS.includes(name)) kept[name] = value
|
|
34
|
+
else dropped.push(name)
|
|
35
|
+
}
|
|
36
|
+
return { kept, dropped }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A shadcn `registry:theme` item: cssVars.theme (shared) + cssVars.light/.dark.
|
|
40
|
+
export function parseRegistryTheme(item) {
|
|
41
|
+
if (!item || typeof item !== 'object' || typeof item.cssVars !== 'object' || item.cssVars === null) {
|
|
42
|
+
return fail('E_IMPORT_SHAPE', 'registry:theme JSON must have a `cssVars` object')
|
|
43
|
+
}
|
|
44
|
+
const { theme = {}, light = {}, dark = {} } = item.cssVars
|
|
45
|
+
const dropped = []
|
|
46
|
+
const collect = (scope, raw) => {
|
|
47
|
+
const { kept, dropped: d } = filterDeclarations(raw)
|
|
48
|
+
dropped.push(...d)
|
|
49
|
+
return kept
|
|
50
|
+
}
|
|
51
|
+
const shared = collect('theme', theme)
|
|
52
|
+
const merged = { light: { ...shared, ...collect('light', light) }, dark: { ...shared, ...collect('dark', dark) } }
|
|
53
|
+
if (dropped.length) {
|
|
54
|
+
// Non-theme declarations are ignored, not an error: the theme tokens still apply.
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, tokens: merged, fonts: {}, dropped }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A local CSS export: a plain stylesheet snippet with `:root` / `.dark` blocks.
|
|
60
|
+
export function parseCssExport(css) {
|
|
61
|
+
if (typeof css !== 'string' || !css.trim()) return fail('E_IMPORT_SHAPE', 'CSS theme export is empty')
|
|
62
|
+
const tokens = readThemeCss(css)
|
|
63
|
+
if (!Object.keys(tokens.light).length && !Object.keys(tokens.dark).length) {
|
|
64
|
+
return fail('E_IMPORT_SHAPE', 'CSS theme export has no `:root` or `.dark` token block')
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, tokens, fonts: {} }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Versioned storefront export written by `theme --export`.
|
|
70
|
+
export function parseThemeExport(json) {
|
|
71
|
+
let parsed
|
|
72
|
+
try {
|
|
73
|
+
parsed = typeof json === 'string' ? JSON.parse(json) : json
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return fail('E_IMPORT_JSON', `theme export is not valid JSON: ${error.message}`)
|
|
76
|
+
}
|
|
77
|
+
if (!parsed || typeof parsed !== 'object') return fail('E_IMPORT_SHAPE', 'theme export must be an object')
|
|
78
|
+
if (parsed.format !== EXPORT_FORMAT) {
|
|
79
|
+
return fail('E_IMPORT_FORMAT', `expected format "${EXPORT_FORMAT}" (got "${parsed.format}")`)
|
|
80
|
+
}
|
|
81
|
+
if (parsed.version !== EXPORT_VERSION) {
|
|
82
|
+
return fail('E_IMPORT_VERSION', `unsupported theme export version ${parsed.version} (supported: ${EXPORT_VERSION})`)
|
|
83
|
+
}
|
|
84
|
+
const tokens = parsed.tokens ?? {}
|
|
85
|
+
if (typeof tokens.light !== 'object' || typeof tokens.dark !== 'object') {
|
|
86
|
+
return fail('E_IMPORT_SHAPE', 'theme export must carry `tokens.light` and `tokens.dark`')
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, tokens: { light: tokens.light, dark: tokens.dark }, fonts: parsed.fonts ?? {}, mode: parsed.mode ?? null }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Build a portable, versioned export from the resolved palette. Declarative
|
|
92
|
+
// font intent only (kind + value); no machine-specific module paths, secrets or
|
|
93
|
+
// executable code.
|
|
94
|
+
export function buildThemeExport({ tokens, mode, fonts = {} }) {
|
|
95
|
+
const fontSelections = {}
|
|
96
|
+
for (const slot of ['sans', 'mono', 'serif']) {
|
|
97
|
+
if (fonts[slot]) fontSelections[slot] = { kind: fonts[slot].kind, value: fonts[slot].value }
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
format: EXPORT_FORMAT,
|
|
101
|
+
version: EXPORT_VERSION,
|
|
102
|
+
mode: mode ?? null,
|
|
103
|
+
tokens: { light: tokens.light ?? {}, dark: tokens.dark ?? {} },
|
|
104
|
+
fonts: fontSelections,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Detect the concrete input type of an import source so a URL/format mismatch
|
|
109
|
+
// produces an actionable diagnostic instead of a confusing parse failure.
|
|
110
|
+
export function classifyImportSource(raw) {
|
|
111
|
+
const text = String(raw).trim()
|
|
112
|
+
if (/^https?:\/\//.test(text)) {
|
|
113
|
+
if (/\/r\/|registry|\.json(\?|$)/.test(text) && !/ui\.shadcn\.com\/create/.test(text)) {
|
|
114
|
+
return 'registry-url'
|
|
115
|
+
}
|
|
116
|
+
return 'preset-url'
|
|
117
|
+
}
|
|
118
|
+
if (text.startsWith('{')) return 'json'
|
|
119
|
+
if (text.includes('{')) return 'css'
|
|
120
|
+
// A path is classified by extension so a stylesheet that has no block on the
|
|
121
|
+
// command line (the common case: `--import ./brand.css`) still resolves.
|
|
122
|
+
if (/\.json(\?|$)/i.test(text)) return 'json'
|
|
123
|
+
if (/\.css(\?|$)/i.test(text)) return 'css'
|
|
124
|
+
return 'unknown'
|
|
125
|
+
}
|