@arroyavecommerce/cms-storefront 0.2.0

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/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@arroyavecommerce/cms-storefront",
3
+ "version": "0.2.0",
4
+ "description": "Host storefront del CMS de ArroyaveCommerce: contrato defineTemplate y primitivas de render/tema.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./fetchers": "./src/fetchers.ts",
12
+ "./studio": "./src/studio.ts",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "typesVersions": {
16
+ "*": {
17
+ "fetchers": [
18
+ "./src/fetchers.ts"
19
+ ],
20
+ "studio": [
21
+ "./src/studio.ts"
22
+ ]
23
+ }
24
+ },
25
+ "peerDependencies": {
26
+ "@arroyavecommerce/cms-core": ">=0.1.0",
27
+ "@arroyavecommerce/cms-studio": ">=0.1.0",
28
+ "@puckeditor/core": ">=0.22",
29
+ "react": ">=19",
30
+ "react-dom": ">=19",
31
+ "zod": ">=4.0"
32
+ },
33
+ "devDependencies": {
34
+ "@arroyavecommerce/cms-core": "0.2.0",
35
+ "@arroyavecommerce/cms-studio": "0.2.0",
36
+ "@puckeditor/core": "^0.22.0",
37
+ "react": "^19.0.0",
38
+ "react-dom": "^19.0.0",
39
+ "zod": "^4.0.0"
40
+ }
41
+ }
@@ -0,0 +1,11 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { cmsPageDataSchema } from "@arroyavecommerce/cms-core"
3
+ import { templateDemo } from "./template-demo"
4
+
5
+ describe("templateDemo (fixture)", () => {
6
+ it("declara los dos bloques y un home por defecto válido", () => {
7
+ expect(Object.keys(templateDemo.blocks)).toEqual(["heroDemo", "avisoDemo"])
8
+ const parsed = cmsPageDataSchema.safeParse(templateDemo.defaultHomePage)
9
+ expect(parsed.success).toBe(true)
10
+ })
11
+ })
@@ -0,0 +1,56 @@
1
+ import { z } from "zod"
2
+ import { defineBlocks, type BlockDefinition } from "@arroyavecommerce/cms-core"
3
+ import { defineTemplate, type Template } from "../template"
4
+
5
+ export type DemoCtx = { destacado: string }
6
+
7
+ const heroDemo: BlockDefinition<{ titulo: string }, DemoCtx> = {
8
+ label: "Hero demo",
9
+ schema: z.object({ titulo: z.string().min(1) }),
10
+ defaults: { titulo: "Bienvenido" },
11
+ Component: ({ props, ctx }) => (
12
+ <section>
13
+ <h1>{props.titulo}</h1>
14
+ <p>{ctx.destacado}</p>
15
+ </section>
16
+ ),
17
+ }
18
+
19
+ const avisoDemo: BlockDefinition<{ texto: string }, DemoCtx> = {
20
+ label: "Aviso demo",
21
+ schema: z.object({ texto: z.string().min(1) }),
22
+ defaults: { texto: "Envíos a todo el país" },
23
+ Component: ({ props }) => <aside>{props.texto}</aside>,
24
+ }
25
+
26
+ const blocks = defineBlocks<DemoCtx>()({ heroDemo, avisoDemo })
27
+
28
+ export const templateDemo: Template<DemoCtx> = defineTemplate<DemoCtx>()({
29
+ meta: { id: "demo", nombre: "Demo", industria: "test" },
30
+ blocks,
31
+ puckConfig: { components: {} },
32
+ theme: {
33
+ definition: {
34
+ tokens: {
35
+ "color-primario": {
36
+ label: "Primario",
37
+ tipo: "color",
38
+ default: "#111111",
39
+ cssVars: ["--cms-primario"],
40
+ },
41
+ },
42
+ fuentes: {},
43
+ },
44
+ defaults: { "color-primario": "#111111" },
45
+ marca: "Demo",
46
+ },
47
+ defaultHomePage: {
48
+ version: 1,
49
+ blocks: [
50
+ { id: "b1", type: "heroDemo", props: blocks.heroDemo.defaults },
51
+ { id: "b2", type: "avisoDemo", props: blocks.avisoDemo.defaults },
52
+ ],
53
+ },
54
+ reservados: ["store", "checkout"],
55
+ basePath: "/p",
56
+ })
@@ -0,0 +1,2 @@
1
+ export { createCmsFetchers } from "@arroyavecommerce/cms-core/fetchers"
2
+ export type { CmsFetchersConfig } from "@arroyavecommerce/cms-core/fetchers"
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { facetSeleccionados, construirUrlFiltro } from "./filter-url"
3
+
4
+ describe("facetSeleccionados", () => {
5
+ it("normaliza un array existente", () => {
6
+ expect(facetSeleccionados({ talla: ["S", "M"] }, "talla")).toEqual(["S", "M"])
7
+ })
8
+ it("devuelve [] si la faceta está ausente", () => {
9
+ expect(facetSeleccionados({}, "talla")).toEqual([])
10
+ })
11
+ it("normaliza un string suelto a array de un elemento", () => {
12
+ expect(facetSeleccionados({ c: "x" }, "c")).toEqual(["x"])
13
+ })
14
+ })
15
+
16
+ describe("construirUrlFiltro", () => {
17
+ it("agrega el valor cuando la faceta está vacía", () => {
18
+ const url = construirUrlFiltro({}, { id: "talla", control: "pill" }, "S")
19
+ expect(url).toContain("talla=S")
20
+ })
21
+
22
+ it("toggle multi (pill): quita el valor si ya estaba seleccionado", () => {
23
+ const url = construirUrlFiltro({ talla: "S" }, { id: "talla", control: "pill" }, "S")
24
+ expect(url).not.toContain("talla=S")
25
+ })
26
+
27
+ it("toggle multi (checkbox): agrega el valor conservando el existente", () => {
28
+ const url = construirUrlFiltro({ talla: "S" }, { id: "talla", control: "checkbox" }, "M")
29
+ expect(url).toContain("talla=S")
30
+ expect(url).toContain("talla=M")
31
+ })
32
+
33
+ it("single (dropdown): reemplaza el valor existente", () => {
34
+ const url = construirUrlFiltro({ orden: "a" }, { id: "orden", control: "dropdown" }, "b")
35
+ expect(url).toContain("orden=b")
36
+ expect(url).not.toContain("orden=a")
37
+ })
38
+
39
+ it("range: reemplaza el rango existente", () => {
40
+ const url = construirUrlFiltro({ precio: "0-100" }, { id: "precio", control: "range" }, "50-200")
41
+ expect(url).toContain("precio=50-200")
42
+ expect(url).not.toContain("precio=0-100")
43
+ })
44
+
45
+ it("preserva otras keys del query", () => {
46
+ const url = construirUrlFiltro({ color: "negro", talla: "S" }, { id: "talla", control: "pill" }, "M")
47
+ expect(url).toContain("color=negro")
48
+ })
49
+
50
+ it("al togglear el último valor de una faceta, la key desaparece (no queda vacía)", () => {
51
+ const url = construirUrlFiltro({ talla: "S" }, { id: "talla", control: "pill" }, "S")
52
+ expect(url).not.toMatch(/talla=(&|$)/)
53
+ expect(url).not.toContain("talla=")
54
+ })
55
+ })
@@ -0,0 +1,36 @@
1
+ import type { Facet } from "@arroyavecommerce/cms-core"
2
+
3
+ export function facetSeleccionados(query: Record<string, string | string[]>, facetId: string): string[] {
4
+ const v = query[facetId]
5
+ if (v === undefined) return []
6
+ return Array.isArray(v) ? v : [v]
7
+ }
8
+
9
+ export function construirUrlFiltro(
10
+ query: Record<string, string | string[]>,
11
+ facet: Pick<Facet, "id" | "control">,
12
+ valor: string,
13
+ ): string {
14
+ const params = new URLSearchParams()
15
+ for (const [key, v] of Object.entries(query)) {
16
+ if (key === facet.id) continue
17
+ for (const item of Array.isArray(v) ? v : [v]) {
18
+ params.append(key, item)
19
+ }
20
+ }
21
+
22
+ const esMulti = facet.control === "pill" || facet.control === "checkbox"
23
+ if (esMulti) {
24
+ const actuales = facetSeleccionados(query, facet.id)
25
+ const nuevos = actuales.includes(valor) ? actuales.filter((x) => x !== valor) : [...actuales, valor]
26
+ for (const item of nuevos) {
27
+ params.append(facet.id, item)
28
+ }
29
+ } else {
30
+ // single: dropdown y range/price reemplazan el valor de la faceta
31
+ params.append(facet.id, valor)
32
+ }
33
+
34
+ const qs = params.toString()
35
+ return qs ? `?${qs}` : ""
36
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import type { CmsFiltersConfig } from "@arroyavecommerce/cms-core"
3
+ import { resolveFilters } from "./filters"
4
+
5
+ const config: CmsFiltersConfig = {
6
+ version: 1,
7
+ facets: [
8
+ { id: "rareza", label: "Rareza", control: "pill", source: { type: "tag", tagIds: ["t1", "t2"] }, order: 1 },
9
+ { id: "cat", label: "Categoría", control: "dropdown", source: { type: "category", parentId: "pcat" }, order: 2 },
10
+ { id: "precio", label: "Precio", control: "range", source: { type: "price" }, order: 3 },
11
+ // faceta inválida (price con pill): debe omitirse
12
+ { id: "malo", label: "Malo", control: "pill", source: { type: "price" }, order: 4 },
13
+ ],
14
+ }
15
+
16
+ describe("resolveFilters", () => {
17
+ it("mapea tag/category a params de Store API (OR dentro de la faceta)", () => {
18
+ const r = resolveFilters(config, { rareza: ["t1", "t2"], cat: "c9" })
19
+ expect(r.params.tag_id).toEqual(["t1", "t2"])
20
+ expect(r.params.category_id).toEqual(["c9"])
21
+ })
22
+ it("descarta valores de tag que no están en el bucket", () => {
23
+ const r = resolveFilters(config, { rareza: ["t1", "ajeno"] })
24
+ expect(r.params.tag_id).toEqual(["t1"])
25
+ })
26
+ it("parsea el rango de precio y omite la faceta inválida", () => {
27
+ const r = resolveFilters(config, { precio: "10-50", malo: "x" })
28
+ expect(r.price).toEqual({ min: 10, max: 50 })
29
+ // 'malo' (price+pill) se omite: no rompe ni agrega params
30
+ expect(r.params.tag_id).toBeUndefined()
31
+ })
32
+ it("ignora keys sin faceta y no incluye arrays vacíos", () => {
33
+ const r = resolveFilters(config, { inexistente: "z" })
34
+ expect(r.params).toEqual({})
35
+ expect(r.price).toBeUndefined()
36
+ })
37
+ it("mapea source.type collection a collection_id", () => {
38
+ const cfg: CmsFiltersConfig = {
39
+ version: 1,
40
+ facets: [
41
+ { id: "col", label: "Colección", control: "pill", source: { type: "collection" }, order: 1 },
42
+ ],
43
+ }
44
+ const r = resolveFilters(cfg, { col: ["cx", "cy"] })
45
+ expect(r.params.collection_id).toEqual(["cx", "cy"])
46
+ })
47
+ it("parsea rangos de precio parciales (solo min o solo max)", () => {
48
+ const cfg: CmsFiltersConfig = {
49
+ version: 1,
50
+ facets: [
51
+ { id: "precio", label: "Precio", control: "range", source: { type: "price" }, order: 1 },
52
+ ],
53
+ }
54
+ const r1 = resolveFilters(cfg, { precio: "-50" })
55
+ expect(r1.price).toEqual({ max: 50 })
56
+ const r2 = resolveFilters(cfg, { precio: "10-" })
57
+ expect(r2.price).toEqual({ min: 10 })
58
+ })
59
+ it("fusiona múltiples facetas tag del mismo source.type y deduplica", () => {
60
+ const cfg: CmsFiltersConfig = {
61
+ version: 1,
62
+ facets: [
63
+ { id: "a", label: "Tags A", control: "pill", source: { type: "tag", tagIds: ["t1", "t2"] }, order: 1 },
64
+ { id: "b", label: "Tags B", control: "pill", source: { type: "tag", tagIds: ["t2", "t3"] }, order: 2 },
65
+ ],
66
+ }
67
+ const r = resolveFilters(cfg, { a: ["t1", "t2"], b: ["t2", "t3"] })
68
+ expect(r.params.tag_id).toHaveLength(3)
69
+ expect(r.params.tag_id).toEqual(expect.arrayContaining(["t1", "t2", "t3"]))
70
+ })
71
+ })
package/src/filters.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { facetControlEsValido, type CmsFiltersConfig } from "@arroyavecommerce/cms-core"
2
+
3
+ export type ResolvedFilters = {
4
+ params: {
5
+ category_id?: string[]
6
+ collection_id?: string[]
7
+ tag_id?: string[]
8
+ }
9
+ price?: { min?: number; max?: number }
10
+ }
11
+
12
+ function comoArray(v: string | string[] | undefined): string[] {
13
+ if (v === undefined) return []
14
+ return Array.isArray(v) ? v : [v]
15
+ }
16
+
17
+ function unicos(xs: string[]): string[] {
18
+ return [...new Set(xs)]
19
+ }
20
+
21
+ function parsearPrecio(valor: string): { min?: number; max?: number } | null {
22
+ const m = /^(\d*)-(\d*)$/.exec(valor.trim())
23
+ if (!m) return null
24
+ const out: { min?: number; max?: number } = {}
25
+ if (m[1] !== "") out.min = Number(m[1])
26
+ if (m[2] !== "") out.max = Number(m[2])
27
+ return "min" in out || "max" in out ? out : null
28
+ }
29
+
30
+ /**
31
+ * Cruza el config publicado con los parámetros de URL y produce los params de
32
+ * la Store API de Medusa + el rango de precio (aparte, porque la Store API no
33
+ * filtra por precio). Tolerante: facetas que violan la matriz control×fuente
34
+ * se omiten; valores de tag fuera del bucket se descartan; keys sin faceta se
35
+ * ignoran.
36
+ */
37
+ export function resolveFilters(
38
+ config: CmsFiltersConfig,
39
+ query: Record<string, string | string[]>
40
+ ): ResolvedFilters {
41
+ const params: ResolvedFilters["params"] = {}
42
+ let price: ResolvedFilters["price"] | undefined
43
+
44
+ const push = (key: "category_id" | "collection_id" | "tag_id", vals: string[]) => {
45
+ if (!vals.length) return
46
+ params[key] = unicos([...(params[key] ?? []), ...vals])
47
+ }
48
+
49
+ for (const facet of config.facets) {
50
+ if (!facetControlEsValido(facet)) continue
51
+ const seleccion = comoArray(query[facet.id])
52
+ if (facet.source.type === "price") {
53
+ const val = seleccion[0]
54
+ if (val) {
55
+ const r = parsearPrecio(val)
56
+ if (r) price = { ...price, ...r }
57
+ }
58
+ continue
59
+ }
60
+ if (!seleccion.length) continue
61
+ if (facet.source.type === "category") push("category_id", seleccion)
62
+ else if (facet.source.type === "collection") push("collection_id", seleccion)
63
+ else if (facet.source.type === "tag") {
64
+ const permitidos = new Set(facet.source.tagIds)
65
+ push("tag_id", seleccion.filter((v) => permitidos.has(v)))
66
+ }
67
+ }
68
+
69
+ return price ? { params, price } : { params }
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export { defineTemplate } from "./template"
2
+ export type { Template, TemplateTheme, CatalogRegistry } from "./template"
3
+ export { renderTemplatePage, templateThemeCss } from "./render"
4
+ export { assertTemplateDefaults } from "./validate"
5
+ export { resolveFilters } from "./filters"
6
+ export type { ResolvedFilters } from "./filters"
7
+ export { resolvePageConfig } from "./pageconfig"
8
+ export type { PageConfigSetting } from "@arroyavecommerce/cms-core"
9
+ export { facetSeleccionados, construirUrlFiltro } from "./filter-url"
@@ -0,0 +1,14 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { resolvePageConfig } from "./pageconfig"
3
+
4
+ const cfg = { version: 1 as const, selections: { "plp.filtros": "sidebar" } }
5
+
6
+ describe("resolvePageConfig", () => {
7
+ it("devuelve la selección si existe", () => {
8
+ expect(resolvePageConfig(cfg, "plp.filtros", "pills")).toBe("sidebar")
9
+ })
10
+ it("devuelve el fallback si falta o config es null", () => {
11
+ expect(resolvePageConfig(cfg, "pdp.galeria", "grid")).toBe("grid")
12
+ expect(resolvePageConfig(null, "plp.filtros", "pills")).toBe("pills")
13
+ })
14
+ })
@@ -0,0 +1,10 @@
1
+ import type { CmsPageConfig } from "@arroyavecommerce/cms-core"
2
+
3
+ export function resolvePageConfig(
4
+ config: CmsPageConfig | null,
5
+ key: string,
6
+ fallback: string
7
+ ): string {
8
+ const v = config?.selections?.[key]
9
+ return typeof v === "string" && v.length > 0 ? v : fallback
10
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { renderToStaticMarkup } from "react-dom/server"
3
+ import { templateDemo } from "./__fixtures__/template-demo"
4
+ import { renderTemplatePage } from "./render"
5
+
6
+ describe("renderTemplatePage", () => {
7
+ it("renderiza los bloques del home con el ctx dado", () => {
8
+ const html = renderToStaticMarkup(
9
+ renderTemplatePage({
10
+ template: templateDemo,
11
+ data: templateDemo.defaultHomePage,
12
+ ctx: { destacado: "Nuevo drop" },
13
+ })
14
+ )
15
+ expect(html).toContain("Bienvenido")
16
+ expect(html).toContain("Nuevo drop")
17
+ expect(html).toContain("Envíos a todo el país")
18
+ })
19
+
20
+ it("omite bloques de tipo desconocido sin romper", () => {
21
+ const html = renderToStaticMarkup(
22
+ renderTemplatePage({
23
+ template: templateDemo,
24
+ data: { version: 1, blocks: [{ id: "x", type: "inexistente", props: {} }] },
25
+ ctx: { destacado: "" },
26
+ })
27
+ )
28
+ expect(html).not.toContain("inexistente")
29
+ })
30
+ })
package/src/render.tsx ADDED
@@ -0,0 +1,31 @@
1
+ import { CmsRender, type CmsPageData } from "@arroyavecommerce/cms-core"
2
+ import { themeToCss } from "@arroyavecommerce/cms-core/theme"
3
+ import type { Template } from "./template"
4
+
5
+ /**
6
+ * Render genérico de una página CMS con los bloques del template activo.
7
+ * Tolerante por herencia de `CmsRender`: bloque desconocido o props
8
+ * inválidas se omiten con warning; la página nunca rompe.
9
+ */
10
+ export function renderTemplatePage<Ctx>({
11
+ template,
12
+ data,
13
+ ctx,
14
+ }: {
15
+ template: Template<Ctx>
16
+ data: CmsPageData
17
+ ctx: Ctx
18
+ }) {
19
+ return <CmsRender data={data} blocks={template.blocks} ctx={ctx} />
20
+ }
21
+
22
+ /**
23
+ * Tokens publicados → declaraciones CSS para :root, usando la declaración de
24
+ * tema del template. Silencia warnings (delega en la tolerancia de themeToCss).
25
+ */
26
+ export function templateThemeCss<Ctx>(
27
+ template: Template<Ctx>,
28
+ tokens: Record<string, string>
29
+ ): string {
30
+ return themeToCss(tokens, template.theme.definition, () => {})
31
+ }
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import * as host from "./index"
3
+
4
+ describe("cms-storefront paquete", () => {
5
+ it("exporta un barrel importable", () => {
6
+ expect(typeof host).toBe("object")
7
+ })
8
+ })
@@ -0,0 +1,50 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest"
2
+ import { createCmsStudio } from "./studio"
3
+ import { templateDemo } from "./__fixtures__/template-demo"
4
+
5
+ // Las factories del studio importan estos módulos de Next a nivel de módulo.
6
+ vi.mock("next/headers", () => ({ cookies: vi.fn() }))
7
+ vi.mock("next/navigation", () => ({
8
+ redirect: vi.fn(() => {
9
+ throw new Error("NEXT_REDIRECT")
10
+ }),
11
+ }))
12
+ vi.mock("next/cache", () => ({ revalidateTag: vi.fn() }))
13
+
14
+ const env = {
15
+ backendUrl: "http://back",
16
+ publishableKey: "pk_test",
17
+ }
18
+
19
+ afterEach(() => {
20
+ vi.unstubAllGlobals()
21
+ })
22
+
23
+ describe("createCmsStudio", () => {
24
+ it("expone los cinco grupos de actions con sus funciones", () => {
25
+ const studio = createCmsStudio(templateDemo, env)
26
+ expect(typeof studio.auth.loginAction).toBe("function")
27
+ expect(typeof studio.auth.getStudioToken).toBe("function")
28
+ expect(typeof studio.auth.getStudioUser).toBe("function")
29
+ expect(typeof studio.cms.listPagesAction).toBe("function")
30
+ expect(typeof studio.cms.createHomePageAction).toBe("function")
31
+ expect(typeof studio.theme.getThemeDraftAction).toBe("function")
32
+ expect(typeof studio.zones.createZoneAction).toBe("function")
33
+ expect(typeof studio.pages.createPageAction).toBe("function")
34
+ })
35
+
36
+ it("cablea env.backendUrl en las actions (verificado vía verifyAdminToken)", async () => {
37
+ const fetchFn = vi.fn().mockResolvedValue({
38
+ ok: true,
39
+ json: async () => ({ user: { id: "u1", email: "a@b.c" } }),
40
+ })
41
+ vi.stubGlobal("fetch", fetchFn)
42
+ const studio = createCmsStudio(templateDemo, env)
43
+ const user = await studio.auth.verifyAdminToken("tok")
44
+ expect(user).toEqual({ id: "u1", email: "a@b.c" })
45
+ expect(fetchFn).toHaveBeenCalledWith(
46
+ "http://back/admin/users/me",
47
+ expect.objectContaining({ headers: { authorization: "Bearer tok" } })
48
+ )
49
+ })
50
+ })
package/src/studio.ts ADDED
@@ -0,0 +1,71 @@
1
+ import {
2
+ makeAuthActions,
3
+ makeCmsActions,
4
+ makeThemeActions,
5
+ makeZoneActions,
6
+ makePageManagerActions,
7
+ } from "@arroyavecommerce/cms-studio/actions"
8
+ import type { Template } from "./template"
9
+
10
+ /** Valores de entorno/infra de la instancia (no del template). */
11
+ export type StudioEnv = {
12
+ backendUrl: string
13
+ publishableKey: string
14
+ cookieName?: string
15
+ studioPath?: string
16
+ }
17
+
18
+ /**
19
+ * Instancia todas las server-action factories del studio desde un Template y
20
+ * el entorno, con un único `getStudioToken` compartido. Reemplaza el cuerpo
21
+ * repetido de los archivos de wiring de la tienda.
22
+ *
23
+ * SEGURIDAD: este módulo NO lleva "use server". Devuelve
24
+ * `auth.getStudioToken`/`auth.getStudioUser` (helpers server-only); la tienda
25
+ * debe extraerlos SOLO en un módulo `server-only` (p. ej. su session.ts) y
26
+ * NUNCA re-exportarlos desde un módulo `"use server"`.
27
+ *
28
+ * Uso típico en la tienda (session.ts, server-only):
29
+ * const studio = createCmsStudio(miTemplate, { backendUrl, publishableKey })
30
+ * export const getStudioToken = studio.auth.getStudioToken
31
+ * y en cada shim "use server":
32
+ * export const { listPagesAction, saveDraftAction } = studio.cms
33
+ */
34
+ export function createCmsStudio<Ctx>(template: Template<Ctx>, env: StudioEnv) {
35
+ const auth = makeAuthActions({
36
+ backendUrl: env.backendUrl,
37
+ cookieName: env.cookieName,
38
+ studioPath: env.studioPath,
39
+ })
40
+ const getStudioToken = auth.getStudioToken
41
+
42
+ const cms = makeCmsActions({
43
+ backendUrl: env.backendUrl,
44
+ getStudioToken,
45
+ defaultHomePage: template.defaultHomePage,
46
+ })
47
+
48
+ const theme = makeThemeActions({
49
+ backendUrl: env.backendUrl,
50
+ getStudioToken,
51
+ theme: {
52
+ defaults: template.theme.defaults,
53
+ tokens: template.theme.definition.tokens,
54
+ fuentes: template.theme.definition.fuentes,
55
+ },
56
+ })
57
+
58
+ const zones = makeZoneActions({
59
+ backendUrl: env.backendUrl,
60
+ publishableKey: env.publishableKey,
61
+ getStudioToken,
62
+ })
63
+
64
+ const pages = makePageManagerActions({
65
+ backendUrl: env.backendUrl,
66
+ getStudioToken,
67
+ reservados: template.reservados,
68
+ })
69
+
70
+ return { auth, cms, theme, zones, pages }
71
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { z } from "zod"
3
+ import { defineBlocks, type BlockDefinition } from "@arroyavecommerce/cms-core"
4
+ import { defineTemplate, type Template } from "./template"
5
+
6
+ type Ctx = { saludo: string }
7
+
8
+ const holaBlock: BlockDefinition<{ texto: string }, Ctx> = {
9
+ label: "Hola",
10
+ schema: z.object({ texto: z.string().min(1) }),
11
+ defaults: { texto: "Hola" },
12
+ Component: ({ props }) => <p>{props.texto}</p>,
13
+ }
14
+
15
+ describe("defineTemplate", () => {
16
+ it("devuelve el mismo objeto (identidad) y preserva su forma", () => {
17
+ const blocks = defineBlocks<Ctx>()({ hola: holaBlock })
18
+ const input = {
19
+ meta: { id: "demo", nombre: "Demo", industria: "test" },
20
+ blocks,
21
+ puckConfig: { components: {} },
22
+ theme: { definition: { tokens: {}, fuentes: {} }, defaults: {} },
23
+ defaultHomePage: { version: 1, blocks: [] },
24
+ reservados: ["store"],
25
+ basePath: "/p",
26
+ } satisfies Template<Ctx>
27
+ const template = defineTemplate<Ctx>()(input)
28
+ // identidad: devuelve exactamente el mismo objeto (por referencia)
29
+ expect(template).toBe(input)
30
+ expect(template.meta.id).toBe("demo")
31
+ expect(template.blocks.hola.label).toBe("Hola")
32
+ // el tipo se preserva: acceso tipado a una clave concreta del registro
33
+ const _t: Template<Ctx> = template
34
+ expect(_t.reservados).toEqual(["store"])
35
+ })
36
+ })
@@ -0,0 +1,56 @@
1
+ import type { ComponentType } from "react"
2
+ import type { Config } from "@puckeditor/core"
3
+ import type {
4
+ BlockRegistry,
5
+ ChromeRegistry,
6
+ CmsPageConfig,
7
+ CmsPageData,
8
+ PageConfigSetting,
9
+ } from "@arroyavecommerce/cms-core"
10
+ import type { ThemeDefinition } from "@arroyavecommerce/cms-core/theme"
11
+
12
+ /** Contrato de tema de un template: declaración + defaults + texto de muestra. */
13
+ export type TemplateTheme = {
14
+ definition: ThemeDefinition
15
+ defaults: Record<string, string>
16
+ marca?: string
17
+ }
18
+
19
+ /**
20
+ * Contrato del catálogo (PLP/PDP) de un template: los componentes que
21
+ * renderizan listado/detalle (ya resolviendo su propia config internamente)
22
+ * más el descriptor de settings de página (`pageConfigSettings`) que el
23
+ * editor de config de página usa para construir su UI.
24
+ */
25
+ export type CatalogRegistry<Ctx> = {
26
+ plp: ComponentType<{ ctx: Ctx; pageConfig: CmsPageConfig | null }>
27
+ pdp: ComponentType<{ ctx: Ctx; pageConfig: CmsPageConfig | null }>
28
+ pageConfigSettings: { plp: PageConfigSetting[]; pdp: PageConfigSetting[] }
29
+ }
30
+
31
+ /**
32
+ * Un template-paquete auto-contenido. Agrupa todo lo que hoy cada tienda
33
+ * cablea a mano, para que el host lo monte con solo registrarlo.
34
+ * Ctx es el contexto de datos de industria (lo compone el template desde
35
+ * las primitivas del host — ver sub-proyecto 1b).
36
+ */
37
+ export type Template<Ctx> = {
38
+ meta: { id: string; nombre: string; industria: string }
39
+ blocks: BlockRegistry<Ctx>
40
+ puckConfig: Config
41
+ theme: TemplateTheme
42
+ chrome?: ChromeRegistry<Ctx>
43
+ defaultHomePage: CmsPageData
44
+ reservados: string[]
45
+ basePath: string
46
+ catalogo?: CatalogRegistry<Ctx>
47
+ }
48
+
49
+ /**
50
+ * Helper de identidad *curried*: fija Ctx y deja que TypeScript infiera la
51
+ * forma exacta del template (mismo truco que `defineBlocks`).
52
+ * `defineTemplate<MiCtx>()({ ... })`.
53
+ */
54
+ export function defineTemplate<Ctx>() {
55
+ return <T extends Template<Ctx>>(template: T): T => template
56
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { templateDemo } from "./__fixtures__/template-demo"
3
+ import { templateThemeCss } from "./render"
4
+
5
+ describe("templateThemeCss", () => {
6
+ it("emite la variable CSS del token publicado", () => {
7
+ const css = templateThemeCss(templateDemo, { "color-primario": "#ff0000" })
8
+ expect(css).toContain("--cms-primario: #ff0000")
9
+ })
10
+
11
+ it("omite tokens desconocidos sin romper (tolerante)", () => {
12
+ const css = templateThemeCss(templateDemo, { "token-inexistente": "#fff" })
13
+ expect(css).toBe("")
14
+ expect(css).not.toContain("token-inexistente")
15
+ })
16
+ })
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { z } from "zod"
3
+ import {
4
+ defineBlocks,
5
+ defineChrome,
6
+ type BlockDefinition,
7
+ type ChromeVariantDef,
8
+ } from "@arroyavecommerce/cms-core"
9
+ import { type Template } from "./template"
10
+ import { templateDemo } from "./__fixtures__/template-demo"
11
+ import { assertTemplateDefaults } from "./validate"
12
+
13
+ describe("assertTemplateDefaults", () => {
14
+ it("no reporta problemas cuando todos los defaults pasan su schema", () => {
15
+ expect(assertTemplateDefaults(templateDemo)).toEqual([])
16
+ })
17
+
18
+ it("reporta el type del bloque cuyo default no pasa su schema", () => {
19
+ const malo: BlockDefinition<{ n: number }, unknown> = {
20
+ label: "Malo",
21
+ schema: z.object({ n: z.number() }),
22
+ // @ts-expect-error default inválido a propósito
23
+ defaults: { n: "no-es-numero" },
24
+ Component: () => null,
25
+ }
26
+ // @ts-expect-error spreading templateDemo with different context type
27
+ const t: Template<unknown> = {
28
+ ...templateDemo,
29
+ blocks: defineBlocks<unknown>()({ malo }),
30
+ }
31
+ expect(assertTemplateDefaults(t)).toEqual(["malo"])
32
+ })
33
+
34
+ it("reporta chrome:header:<key> y chrome:footer:<key> cuando sus defaults no pasan su schema", () => {
35
+ const headerMalo: ChromeVariantDef<{ n: number }, unknown> = {
36
+ label: "Header malo",
37
+ schema: z.object({ n: z.number() }),
38
+ // @ts-expect-error default inválido a propósito
39
+ defaults: { n: "no-es-numero" },
40
+ Component: () => null,
41
+ }
42
+ const footerMalo: ChromeVariantDef<{ n: number }, unknown> = {
43
+ label: "Footer malo",
44
+ schema: z.object({ n: z.number() }),
45
+ // @ts-expect-error default inválido a propósito
46
+ defaults: { n: "no-es-numero" },
47
+ Component: () => null,
48
+ }
49
+ const chrome = defineChrome<unknown>()({
50
+ headers: { malo: headerMalo },
51
+ footers: { malo: footerMalo },
52
+ })
53
+ // @ts-expect-error spreading templateDemo with different context type
54
+ const t: Template<unknown> = { ...templateDemo, chrome }
55
+ expect(assertTemplateDefaults(t)).toEqual(["chrome:header:malo", "chrome:footer:malo"])
56
+ })
57
+ })
@@ -0,0 +1,30 @@
1
+ import type { Template } from "./template"
2
+
3
+ /**
4
+ * Verifica el contrato de un template: cada bloque (y, si existe, cada
5
+ * variante de chrome) debe tener `defaults` que pasen su propio `schema`.
6
+ * Devuelve las claves que fallan (vacío = sano) — `type` para bloques,
7
+ * `chrome:header:<key>`/`chrome:footer:<key>` para chrome. Útil como test
8
+ * de contrato de cada template-paquete.
9
+ */
10
+ export function assertTemplateDefaults<Ctx>(template: Template<Ctx>): string[] {
11
+ const fallidos: string[] = []
12
+ for (const [type, def] of Object.entries(template.blocks)) {
13
+ if (!def.schema.safeParse(def.defaults).success) {
14
+ fallidos.push(type)
15
+ }
16
+ }
17
+ if (template.chrome) {
18
+ for (const [key, def] of Object.entries(template.chrome.headers)) {
19
+ if (!def.schema.safeParse(def.defaults).success) {
20
+ fallidos.push(`chrome:header:${key}`)
21
+ }
22
+ }
23
+ for (const [key, def] of Object.entries(template.chrome.footers)) {
24
+ if (!def.schema.safeParse(def.defaults).success) {
25
+ fallidos.push(`chrome:footer:${key}`)
26
+ }
27
+ }
28
+ }
29
+ return fallidos
30
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": ["src"]
4
+ }