@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,120 @@
|
|
|
1
|
+
// Thor Commerce shell content: brand accents, surface classes, and footer copy.
|
|
2
|
+
//
|
|
3
|
+
// User-owned shell content — reconcile seeds this file once and never rewrites
|
|
4
|
+
// it, so your edits survive `storefront sync`. Plain data only: no React and no
|
|
5
|
+
// server imports.
|
|
6
|
+
//
|
|
7
|
+
// Neutral defaults only: no delivery, shipping, or promo promise is made here,
|
|
8
|
+
// and footer links point only at routes the generated storefront actually
|
|
9
|
+
// serves (`/`, `/categories`, `/checkout`, and the static pages `/contact`,
|
|
10
|
+
// `/about`, `/terms`, `/privacy`). Brand colors and copy belong
|
|
11
|
+
// in your own content, never in the package.
|
|
12
|
+
//
|
|
13
|
+
// The scaffold ships no newsletter submit callback, so the footer newsletter is
|
|
14
|
+
// omitted by default. A content-only `newsletter` is swallowed by the block
|
|
15
|
+
// (no form renders); wiring a real host callback to enable it is an external
|
|
16
|
+
// integration, out of scope for the scaffold.
|
|
17
|
+
|
|
18
|
+
export interface ShellAccent {
|
|
19
|
+
/** Trailing segment of the site-name wordmark to render with the accent. */
|
|
20
|
+
text: string
|
|
21
|
+
/** Consumer-supplied classes for the accent span (e.g. a brand color token). */
|
|
22
|
+
className?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ShellLink {
|
|
26
|
+
label: string
|
|
27
|
+
/** A route the storefront actually serves. Omit for a non-link label. */
|
|
28
|
+
href?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ShellLinkColumn {
|
|
32
|
+
title: string
|
|
33
|
+
links: ShellLink[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ShellNewsletter {
|
|
37
|
+
placeholder?: string
|
|
38
|
+
buttonLabel: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Optional trailing accent for the navbar wordmark, e.g. the "Cuba" in
|
|
43
|
+
* "ElectroCuba". Leave `undefined` to render the wordmark in one colour. The
|
|
44
|
+
* accent only applies when `text` is a trailing segment of the site name.
|
|
45
|
+
*/
|
|
46
|
+
export const logoAccent: ShellAccent | undefined = undefined
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Optional trailing accent for the footer brand name. Same trailing-segment
|
|
50
|
+
* rule as `logoAccent`.
|
|
51
|
+
*/
|
|
52
|
+
export const brandAccent: ShellAccent | undefined = undefined
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Optional classes merged onto the navbar's outer surface (e.g.
|
|
56
|
+
* `bg-brand-nav text-brand-nav-foreground`). Leave `undefined` for the default.
|
|
57
|
+
*/
|
|
58
|
+
export const navbarClassName: string | undefined = undefined
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Optional classes merged onto the footer's outer surface. Leave `undefined`
|
|
62
|
+
* for the default.
|
|
63
|
+
*/
|
|
64
|
+
export const footerClassName: string | undefined = undefined
|
|
65
|
+
|
|
66
|
+
/** Neutral footer brand description. No delivery/shipping promise. */
|
|
67
|
+
export const brandDescription: string =
|
|
68
|
+
'Catálogo en línea de productos para tu hogar y tu negocio.'
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Optional navbar utility-strip note (e.g. `'Nuevo horario de atención de 9 a
|
|
72
|
+
* 18 h'`). Empty by default: while it is empty and there are no utility links,
|
|
73
|
+
* the strip is not rendered, so a fresh storefront shows no note. Set a real
|
|
74
|
+
* note here to display it.
|
|
75
|
+
*/
|
|
76
|
+
export const shippingNote: string = ''
|
|
77
|
+
|
|
78
|
+
/** Neutral footer links — only routes the storefront serves; no dead `#` links. */
|
|
79
|
+
export const linkColumns: ShellLinkColumn[] = [
|
|
80
|
+
{
|
|
81
|
+
title: 'Comprar',
|
|
82
|
+
links: [
|
|
83
|
+
{ label: 'Inicio', href: '/' },
|
|
84
|
+
{ label: 'Todas las categorías', href: '/categories' },
|
|
85
|
+
{ label: 'Finalizar compra', href: '/checkout' },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
title: 'Información',
|
|
90
|
+
links: [
|
|
91
|
+
{ label: 'Contacto', href: '/contact' },
|
|
92
|
+
{ label: 'Sobre nosotros', href: '/about' },
|
|
93
|
+
{ label: 'Términos y condiciones', href: '/terms' },
|
|
94
|
+
{ label: 'Política de privacidad', href: '/privacy' },
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Footer newsletter content. The scaffold has no working submit handler, so it
|
|
101
|
+
* is omitted by default (`false`). Setting it without a host-provided callback
|
|
102
|
+
* renders no form — never a fake one.
|
|
103
|
+
*/
|
|
104
|
+
export const newsletter: false | ShellNewsletter = false
|
|
105
|
+
|
|
106
|
+
// Primary destinations for the mobile bottom navigation. Only routes the
|
|
107
|
+
// storefront actually serves; the icons come from the shared category icon set.
|
|
108
|
+
export interface ShellBottomNavItem {
|
|
109
|
+
id?: string
|
|
110
|
+
label: string
|
|
111
|
+
href: string
|
|
112
|
+
icon?: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const mobileBottomNavItems: ShellBottomNavItem[] = [
|
|
116
|
+
{ id: 'home', label: 'Inicio', href: '/', icon: 'home' },
|
|
117
|
+
{ id: 'categories', label: 'Categorías', href: '/categories', icon: 'store' },
|
|
118
|
+
{ id: 'cart', label: 'Carrito', href: '/cart', icon: 'boxes' },
|
|
119
|
+
{ id: 'account', label: 'Cuenta', href: '/account', icon: 'shield' },
|
|
120
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Thor Commerce shell props: the shell chrome element props composed from the
|
|
2
|
+
// user-owned shell content. Reconcile seeds this file once and never rewrites
|
|
3
|
+
// it; the derived spec (../spec/index.ts) imports these objects and binds them
|
|
4
|
+
// into the shell element tree, so edits here survive `storefront sync`.
|
|
5
|
+
// Plain data only: no React and no server imports.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
brandAccent,
|
|
9
|
+
brandDescription,
|
|
10
|
+
footerClassName,
|
|
11
|
+
linkColumns,
|
|
12
|
+
logoAccent,
|
|
13
|
+
navbarClassName,
|
|
14
|
+
newsletter,
|
|
15
|
+
shippingNote,
|
|
16
|
+
} from './content'
|
|
17
|
+
|
|
18
|
+
export const navbarShellProps = {
|
|
19
|
+
logoAccent,
|
|
20
|
+
className: navbarClassName,
|
|
21
|
+
// Optional utility-strip note. Empty by default, so the strip renders only
|
|
22
|
+
// once a real note (or a utility link) is configured.
|
|
23
|
+
shippingNote,
|
|
24
|
+
// Global navigation search is held until a verified backend search contract
|
|
25
|
+
// exists (E2 task 5.4), so the generated storefront presents NO search
|
|
26
|
+
// control. A merchant may set this to `true` to opt in once the contract is
|
|
27
|
+
// verified; the `Navbar4` block still defaults to visible for legacy
|
|
28
|
+
// consumers that do not set this prop.
|
|
29
|
+
showSearch: false,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const footerShellProps = {
|
|
33
|
+
brandAccent,
|
|
34
|
+
brandDescription,
|
|
35
|
+
linkColumns,
|
|
36
|
+
className: footerClassName,
|
|
37
|
+
...(newsletter ? { newsletter } : {}),
|
|
38
|
+
}
|
|
@@ -3,23 +3,133 @@
|
|
|
3
3
|
// Routes are derived from the view barrel; adding a view never edits this file.
|
|
4
4
|
|
|
5
5
|
import type { NextAppSpec } from '@json-render/next'
|
|
6
|
+
import { storefrontInitialState } from '../state'
|
|
6
7
|
import { views } from '../views'
|
|
8
|
+
import { footerShellProps, navbarShellProps } from '../shell/props'
|
|
9
|
+
import { mobileBottomNavItems } from '../shell/content'
|
|
10
|
+
import { mountedFeatureConfigs, mountedFeatureNames } from '../composition'
|
|
7
11
|
import type { StorefrontElement } from './types'
|
|
8
12
|
|
|
9
13
|
export type { StorefrontElement }
|
|
10
14
|
|
|
15
|
+
// The spec's state is a plain record; spreading the typed initial state keeps
|
|
16
|
+
// the single source of truth in ../state while satisfying the index signature.
|
|
17
|
+
const initialState: Record<string, unknown> = { ...storefrontInitialState }
|
|
18
|
+
|
|
11
19
|
const routes = Object.fromEntries(
|
|
12
|
-
views.
|
|
13
|
-
view
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
views.flatMap((view) => {
|
|
21
|
+
// A view declares one route or several; normalise to an array and flatten so
|
|
22
|
+
// a multi-route view (content-driven static pages) contributes one page per
|
|
23
|
+
// declared route while a single-route view is unchanged.
|
|
24
|
+
const viewRoutes = Array.isArray(view.route) ? view.route : [view.route]
|
|
25
|
+
return viewRoutes.map((route) => [
|
|
26
|
+
route.path,
|
|
27
|
+
{
|
|
28
|
+
layout: route.layout,
|
|
29
|
+
loader: route.loaderName,
|
|
30
|
+
metadata: route.metadata,
|
|
31
|
+
...route.entry,
|
|
32
|
+
},
|
|
33
|
+
])
|
|
34
|
+
}),
|
|
21
35
|
)
|
|
22
36
|
|
|
37
|
+
// The default layout is the storefront chrome shared by every page: the
|
|
38
|
+
// system notice region sits in the notices slot (always first, above the
|
|
39
|
+
// header), the announcement banner and navbar live in the header slot, and the
|
|
40
|
+
// footer in the footer slot. The cart is a lateral panel owned by the navbar
|
|
41
|
+
// (opened by the navbar icon or a successful add), never an inline element of
|
|
42
|
+
// the shell, so page content is never followed by a duplicated cart. This
|
|
43
|
+
// mirrors the reference app so a fresh scaffold ships a real storefront, not a
|
|
44
|
+
// bare page.
|
|
45
|
+
// The navigability feature contributes its chrome only when it is installed.
|
|
46
|
+
// `mountedFeatureNames` is the general presence seam (unfiltered by the catalog
|
|
47
|
+
// boundary), so a removed feature leaves no dangling skip link, back-to-top, or
|
|
48
|
+
// bottom navigation behind.
|
|
49
|
+
const hasNavigability = mountedFeatureNames.includes('navigability')
|
|
50
|
+
const navigabilityConfig = (mountedFeatureConfigs['navigability'] ?? {}) as {
|
|
51
|
+
showBackToTop?: boolean
|
|
52
|
+
}
|
|
53
|
+
const showBackToTop = navigabilityConfig.showBackToTop !== false
|
|
54
|
+
|
|
55
|
+
const shellElements: Record<string, StorefrontElement> = {
|
|
56
|
+
// Navigability chrome, present only when the feature is installed. The skip
|
|
57
|
+
// link is the first focusable element (rendered before the notices); the
|
|
58
|
+
// back-to-top and mobile bottom navigation are fixed-position aids.
|
|
59
|
+
...(hasNavigability
|
|
60
|
+
? {
|
|
61
|
+
skipToContent: {
|
|
62
|
+
type: 'SkipToContent',
|
|
63
|
+
props: { targetId: 'main-content' },
|
|
64
|
+
},
|
|
65
|
+
...(showBackToTop
|
|
66
|
+
? {
|
|
67
|
+
backToTop: {
|
|
68
|
+
type: 'BackToTop',
|
|
69
|
+
props: { label: 'Volver arriba' },
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
: {}),
|
|
73
|
+
mobileBottomNav: {
|
|
74
|
+
type: 'MobileBottomNav',
|
|
75
|
+
props: {
|
|
76
|
+
items: mobileBottomNavItems,
|
|
77
|
+
ariaLabel: 'Navegación principal',
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
: {}),
|
|
82
|
+
// Reserved top region for system/status messages. Only the shell layout
|
|
83
|
+
// references it, so it renders once and always as the first element.
|
|
84
|
+
notice: {
|
|
85
|
+
type: 'SystemNotice',
|
|
86
|
+
props: {},
|
|
87
|
+
},
|
|
88
|
+
banner: {
|
|
89
|
+
type: 'Banner',
|
|
90
|
+
props: {
|
|
91
|
+
badge: 'Novedades',
|
|
92
|
+
message: 'Descubre el catálogo de __SITE_NAME__.',
|
|
93
|
+
href: '/categories',
|
|
94
|
+
linkLabel: 'Ver catálogo',
|
|
95
|
+
},
|
|
96
|
+
visible: { $state: '/bannerDismissed', not: true },
|
|
97
|
+
on: { dismissBanner: { action: 'dismissBanner' } },
|
|
98
|
+
},
|
|
99
|
+
navbar: {
|
|
100
|
+
type: 'StorefrontNavbar',
|
|
101
|
+
props: {
|
|
102
|
+
storeCategories: { $state: '/navCategories' },
|
|
103
|
+
// No shipping/delivery/COD note is shipped by default; the note comes
|
|
104
|
+
// from the user-owned shell seam (`...navbarShellProps` below) and is
|
|
105
|
+
// empty unless the merchant sets one, so no placeholder strip renders.
|
|
106
|
+
utilityLinks: [],
|
|
107
|
+
logoText: '__SITE_NAME__',
|
|
108
|
+
logoHref: '/',
|
|
109
|
+
// Optional wordmark accent and surface classes, configured in the
|
|
110
|
+
// user-owned shell seam. Spread last so merchant edits win.
|
|
111
|
+
...navbarShellProps,
|
|
112
|
+
},
|
|
113
|
+
on: {
|
|
114
|
+
openCart: { action: 'openCart' },
|
|
115
|
+
goToCart: { action: 'goToCart' },
|
|
116
|
+
goToCheckout: { action: 'goToCheckout' },
|
|
117
|
+
applyQuantityDelta: { action: 'applyQuantityDelta' },
|
|
118
|
+
removeFromCart: { action: 'removeFromCart' },
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
footer: {
|
|
122
|
+
type: 'Footer',
|
|
123
|
+
props: {
|
|
124
|
+
brandName: '__SITE_NAME__',
|
|
125
|
+
// Footer brand description, links, accent, and surface classes all come
|
|
126
|
+
// from the user-owned shell seam. Spread last so merchant edits win; no
|
|
127
|
+
// delivery/shipping promise and no dead `#` link is shipped by default.
|
|
128
|
+
...footerShellProps,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
|
|
23
133
|
export const __STOREFRONT_CAMEL__Spec: NextAppSpec = {
|
|
24
134
|
metadata: {
|
|
25
135
|
title: {
|
|
@@ -28,7 +138,7 @@ export const __STOREFRONT_CAMEL__Spec: NextAppSpec = {
|
|
|
28
138
|
},
|
|
29
139
|
description: 'Tienda online de __SITE_NAME__.',
|
|
30
140
|
},
|
|
31
|
-
state:
|
|
141
|
+
state: initialState,
|
|
32
142
|
layouts: {
|
|
33
143
|
default: {
|
|
34
144
|
root: 'layout',
|
|
@@ -36,11 +146,20 @@ export const __STOREFRONT_CAMEL__Spec: NextAppSpec = {
|
|
|
36
146
|
layout: {
|
|
37
147
|
type: 'Layout',
|
|
38
148
|
props: {},
|
|
149
|
+
slots: {
|
|
150
|
+
...(hasNavigability
|
|
151
|
+
? { chrome: ['skipToContent', 'backToTop', 'mobileBottomNav'] }
|
|
152
|
+
: {}),
|
|
153
|
+
notices: ['notice'],
|
|
154
|
+
header: ['banner', 'navbar'],
|
|
155
|
+
footer: ['footer'],
|
|
156
|
+
},
|
|
39
157
|
children: ['slot'],
|
|
40
158
|
},
|
|
41
159
|
// The page content is injected at the Slot element by @json-render/next.
|
|
42
160
|
// Without it the rendered layout drops the page and the store looks empty.
|
|
43
161
|
slot: { type: 'Slot', props: {}, children: [] },
|
|
162
|
+
...shellElements,
|
|
44
163
|
},
|
|
45
164
|
},
|
|
46
165
|
},
|
|
@@ -19,7 +19,11 @@ export const pageRoot = (
|
|
|
19
19
|
page: {
|
|
20
20
|
root: id,
|
|
21
21
|
elements: {
|
|
22
|
-
|
|
22
|
+
// `Page` is a neutral container, not the shell `Layout`: the shell is
|
|
23
|
+
// already rendered by the route's layoutSpec, so nesting another Layout
|
|
24
|
+
// here would duplicate chrome (header/footer/main) and any shell-only
|
|
25
|
+
// notices.
|
|
26
|
+
[id]: { type: 'Page', props: {}, children },
|
|
23
27
|
...elements,
|
|
24
28
|
},
|
|
25
29
|
},
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Generated by Thor Commerce storefront (template: assets/templates/app/lib/__STOREFRONT__/spec/seo.ts)
|
|
2
|
+
// Pure SEO helpers shared by the seeded-once `app/sitemap.ts` and `app/robots.ts`
|
|
3
|
+
// host files. Kept free of Next.js, React, and env imports so the logic is
|
|
4
|
+
// testable at the source and the host files stay thin.
|
|
5
|
+
|
|
6
|
+
import type { EntitySeo } from '@thorprovider/runtime/contracts'
|
|
7
|
+
import {
|
|
8
|
+
mapCategoryEntityToSeo,
|
|
9
|
+
mapProductEntityToSeo,
|
|
10
|
+
} from '@thorprovider/runtime/contracts'
|
|
11
|
+
|
|
12
|
+
/** The routes the storefront can serve as fixed pages (no dynamic `[` segment). */
|
|
13
|
+
export function fixedRoutePaths(routes: Record<string, unknown>): string[] {
|
|
14
|
+
return Object.keys(routes).filter((path) => !path.includes('['))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Absolute sitemap URLs for the fixed routes. Emits nothing when no site URL is
|
|
19
|
+
* configured, so no canonical URL is invented.
|
|
20
|
+
*/
|
|
21
|
+
export function sitemapUrls(
|
|
22
|
+
routes: Record<string, unknown>,
|
|
23
|
+
siteUrl: string | undefined,
|
|
24
|
+
): string[] {
|
|
25
|
+
if (!siteUrl) return []
|
|
26
|
+
return fixedRoutePaths(routes).map((path) => new URL(path, siteUrl).toString())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Absolute URL of the sitemap for `robots.txt`, or undefined without a site URL. */
|
|
30
|
+
export function robotsSitemapUrl(siteUrl: string | undefined): string | undefined {
|
|
31
|
+
return siteUrl ? new URL('/sitemap.xml', siteUrl).toString() : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Surfaces that are never public: they require a shopper session or hold
|
|
36
|
+
* checkout state and must not be indexed or listed in the sitemap.
|
|
37
|
+
*/
|
|
38
|
+
export const PRIVATE_ROUTE_PREFIXES = ['/account', '/checkout'] as const
|
|
39
|
+
|
|
40
|
+
/** True when a path is (or is nested under) a private surface. */
|
|
41
|
+
export function isPrivatePath(path: string): boolean {
|
|
42
|
+
return PRIVATE_ROUTE_PREFIXES.some(
|
|
43
|
+
(prefix) => path === prefix || path.startsWith(`${prefix}/`),
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The paths `robots.txt` disallows: the fixed private surfaces plus any private
|
|
49
|
+
* path the known route set declares. Public routes are never returned.
|
|
50
|
+
*/
|
|
51
|
+
export function robotsDisallowPaths(routes: Record<string, unknown>): string[] {
|
|
52
|
+
const paths = new Set<string>(PRIVATE_ROUTE_PREFIXES)
|
|
53
|
+
for (const path of Object.keys(routes)) {
|
|
54
|
+
if (isPrivatePath(path)) paths.add(path)
|
|
55
|
+
}
|
|
56
|
+
return [...paths]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Per-route entity-SEO extractors keyed by the route's loader name. Each runs
|
|
61
|
+
* after the same request-scoped loader as the page and maps the real loaded
|
|
62
|
+
* entity through the pure `@thorprovider/runtime/contracts` mappers. The route
|
|
63
|
+
* prefix is supplied here because the runtime stays route-agnostic.
|
|
64
|
+
*/
|
|
65
|
+
export const entitySeoExtractors: Record<
|
|
66
|
+
string,
|
|
67
|
+
(data: Record<string, unknown>) => EntitySeo | undefined
|
|
68
|
+
> = {
|
|
69
|
+
product: (data) =>
|
|
70
|
+
mapProductEntityToSeo(data.selectedProduct, { pathPrefix: '/products' }),
|
|
71
|
+
category: (data) =>
|
|
72
|
+
mapCategoryEntityToSeo(data.categoryEntity, { pathPrefix: '/categories' }),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A public catalog entity referenced by its stable id and public path. */
|
|
76
|
+
export interface PublicEntityRef {
|
|
77
|
+
id: string
|
|
78
|
+
path: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One page of public entities plus the cursor for the next page, if any. */
|
|
82
|
+
export interface PublicEntityPage {
|
|
83
|
+
items: PublicEntityRef[]
|
|
84
|
+
nextCursor?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A page-fetching callback for the bounded entity enumeration. */
|
|
88
|
+
export type PublicEntityPageFetcher = (
|
|
89
|
+
cursor: string | undefined,
|
|
90
|
+
) => Promise<PublicEntityPage>
|
|
91
|
+
|
|
92
|
+
/** Explicit reason an enumeration did not complete. */
|
|
93
|
+
export type SitemapEnumerationFailure =
|
|
94
|
+
| { kind: 'enumeration'; message: string }
|
|
95
|
+
| { kind: 'bound'; maxPages: number }
|
|
96
|
+
| { kind: 'cycle'; id: string }
|
|
97
|
+
|
|
98
|
+
export type SitemapEnumerationResult =
|
|
99
|
+
| { ok: true; entities: PublicEntityRef[] }
|
|
100
|
+
| { ok: false; failure: SitemapEnumerationFailure }
|
|
101
|
+
|
|
102
|
+
/** The traversal bound: an enumeration that exceeds it fails, never truncates. */
|
|
103
|
+
export const DEFAULT_SITEMAP_MAX_PAGES = 100
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Enumerate public entities across pagination, de-duplicated by id, with a
|
|
107
|
+
* visited-id cycle guard and a maximum-page bound. A fetch failure, a bound hit,
|
|
108
|
+
* or a repeated id/cursor returns an explicit typed failure instead of an
|
|
109
|
+
* apparently-complete or truncated list.
|
|
110
|
+
*/
|
|
111
|
+
export async function enumeratePublicEntities(
|
|
112
|
+
fetchPage: PublicEntityPageFetcher,
|
|
113
|
+
options: { maxPages?: number } = {},
|
|
114
|
+
): Promise<SitemapEnumerationResult> {
|
|
115
|
+
const maxPages = options.maxPages ?? DEFAULT_SITEMAP_MAX_PAGES
|
|
116
|
+
const seenIds = new Set<string>()
|
|
117
|
+
const seenCursors = new Set<string>()
|
|
118
|
+
const entities: PublicEntityRef[] = []
|
|
119
|
+
let cursor: string | undefined
|
|
120
|
+
|
|
121
|
+
for (let pages = 0; ; pages += 1) {
|
|
122
|
+
if (pages >= maxPages) {
|
|
123
|
+
return { ok: false, failure: { kind: 'bound', maxPages } }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let page: PublicEntityPage
|
|
127
|
+
try {
|
|
128
|
+
page = await fetchPage(cursor)
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
failure: {
|
|
133
|
+
kind: 'enumeration',
|
|
134
|
+
message: error instanceof Error ? error.message : String(error),
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const item of page.items) {
|
|
140
|
+
if (seenIds.has(item.id)) {
|
|
141
|
+
return { ok: false, failure: { kind: 'cycle', id: item.id } }
|
|
142
|
+
}
|
|
143
|
+
seenIds.add(item.id)
|
|
144
|
+
entities.push(item)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const next = page.nextCursor
|
|
148
|
+
if (next === undefined || next === '') {
|
|
149
|
+
return { ok: true, entities }
|
|
150
|
+
}
|
|
151
|
+
if (seenCursors.has(next)) {
|
|
152
|
+
return { ok: false, failure: { kind: 'cycle', id: next } }
|
|
153
|
+
}
|
|
154
|
+
seenCursors.add(next)
|
|
155
|
+
cursor = next
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Raised by `app/sitemap.ts` when enumeration fails, so the route responds with
|
|
161
|
+
* a non-success instead of an apparently-complete sitemap.
|
|
162
|
+
*/
|
|
163
|
+
export class SitemapEnumerationError extends Error {
|
|
164
|
+
readonly failure: SitemapEnumerationFailure
|
|
165
|
+
constructor(failure: SitemapEnumerationFailure) {
|
|
166
|
+
super(`Sitemap enumeration failed (${failure.kind})`)
|
|
167
|
+
this.name = 'SitemapEnumerationError'
|
|
168
|
+
this.failure = failure
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Combine the fixed public routes with the enumerated public entities into
|
|
174
|
+
* de-duplicated absolute sitemap entries. Private surfaces are never listed.
|
|
175
|
+
*/
|
|
176
|
+
export function buildSitemapEntries(input: {
|
|
177
|
+
routes: Record<string, unknown>
|
|
178
|
+
siteUrl: string
|
|
179
|
+
entities: readonly PublicEntityRef[]
|
|
180
|
+
}): { url: string }[] {
|
|
181
|
+
const urls = new Set<string>()
|
|
182
|
+
for (const path of fixedRoutePaths(input.routes)) {
|
|
183
|
+
if (!isPrivatePath(path)) urls.add(new URL(path, input.siteUrl).toString())
|
|
184
|
+
}
|
|
185
|
+
for (const entity of input.entities) {
|
|
186
|
+
if (!isPrivatePath(entity.path)) {
|
|
187
|
+
urls.add(new URL(entity.path, input.siteUrl).toString())
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return [...urls].map((url) => ({ url }))
|
|
191
|
+
}
|