create-email-renderer 0.1.1 → 0.3.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.
Files changed (49) hide show
  1. package/README.md +106 -2
  2. package/SKILL.md +217 -0
  3. package/dist/build.d.ts +52 -0
  4. package/dist/build.d.ts.map +1 -0
  5. package/dist/build.js +169 -0
  6. package/dist/build.js.map +1 -0
  7. package/dist/default-blocks.d.ts.map +1 -1
  8. package/dist/default-blocks.js +377 -5
  9. package/dist/default-blocks.js.map +1 -1
  10. package/dist/html-render.d.ts +4 -2
  11. package/dist/html-render.d.ts.map +1 -1
  12. package/dist/html-render.js +758 -57
  13. package/dist/html-render.js.map +1 -1
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/normalize.d.ts +10 -2
  19. package/dist/normalize.d.ts.map +1 -1
  20. package/dist/normalize.js +166 -38
  21. package/dist/normalize.js.map +1 -1
  22. package/dist/records.d.ts +13 -0
  23. package/dist/records.d.ts.map +1 -0
  24. package/dist/records.js +93 -0
  25. package/dist/records.js.map +1 -0
  26. package/dist/registry.d.ts +35 -0
  27. package/dist/registry.d.ts.map +1 -0
  28. package/dist/registry.js +31 -0
  29. package/dist/registry.js.map +1 -0
  30. package/dist/richtext.d.ts +6 -0
  31. package/dist/richtext.d.ts.map +1 -1
  32. package/dist/richtext.js +13 -0
  33. package/dist/richtext.js.map +1 -1
  34. package/dist/server.d.ts +4 -2
  35. package/dist/server.d.ts.map +1 -1
  36. package/dist/server.js +2 -1
  37. package/dist/server.js.map +1 -1
  38. package/dist/tracking.d.ts +12 -0
  39. package/dist/tracking.d.ts.map +1 -0
  40. package/dist/tracking.js +78 -0
  41. package/dist/tracking.js.map +1 -0
  42. package/dist/types.d.ts +407 -7
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/types.js +159 -0
  45. package/dist/types.js.map +1 -1
  46. package/docs/BLOCKS.md +530 -0
  47. package/docs/MCP.md +189 -0
  48. package/llms.txt +17 -0
  49. package/package.json +17 -4
package/docs/BLOCKS.md ADDED
@@ -0,0 +1,530 @@
1
+ # Catálogo de bloques — `create-email-renderer`
2
+
3
+ Referencia completa de los **26 bloques built-in**: qué hace cada uno, sus props con el default
4
+ real del esquema, sus variantes y un ejemplo copiable con `blockJson`. Si eres una IA leyendo
5
+ esto para generar un correo, empieza por [El payload](#el-payload) y luego baja al bloque que
6
+ necesites.
7
+
8
+ > Los defaults de abajo salen de `DEFAULT_BLOCK_LIBRARY` (`src/default-blocks.ts`), la misma
9
+ > fuente que usa el panel del editor. Lo que no pases queda con su default.
10
+
11
+ ---
12
+
13
+ ## El payload
14
+
15
+ Lo que se guarda y lo que se renderiza es:
16
+
17
+ ```ts
18
+ interface EmailTemplatePayload {
19
+ content: EmailBlock[]; // los bloques, en orden
20
+ settings: Partial<EmailSettings>; // ajustes de la tarjeta (opcional)
21
+ }
22
+
23
+ interface EmailBlock {
24
+ id: string; // generado por `blockJson` / normalizado si falta
25
+ type: EmailBlockType; // uno de los 26 de este documento
26
+ props: Record<string, unknown>; // props del bloque (ver cada sección)
27
+ }
28
+ ```
29
+
30
+ Para construirlo desde el back-end **no escribas el JSON a mano**: usa `blockJson` y
31
+ `templateJson` (rellenan defaults, coaccionan tipos y generan ids).
32
+
33
+ ```ts
34
+ import { blockJson, templateJson } from "create-email-renderer";
35
+ import { renderTemplateEmail } from "create-email-renderer/server";
36
+
37
+ const payload = templateJson(
38
+ [
39
+ blockJson("heading", { text: "Hola {firstName}" }),
40
+ blockJson("text", { text: "Tu pedido está listo." }),
41
+ blockJson("checkout", {
42
+ lines: [{ name: "Camiseta", quantity: "1", price: "$20" }],
43
+ }),
44
+ ],
45
+ { cardBorderWidth: 0, fontFamily: "Arial, 'Helvetica Neue', Helvetica, sans-serif" },
46
+ );
47
+
48
+ const { html, subject } = await renderTemplateEmail({
49
+ subject: "Tu pedido {orderId}",
50
+ payload,
51
+ context: { firstName: "Ana", orderId: "A-123" },
52
+ });
53
+ ```
54
+
55
+ ### Props comunes a (casi) todos los bloques
56
+
57
+ Todos heredan `BlockCommonProps`:
58
+
59
+ | Prop | Tipo | Default | Notas |
60
+ |---|---|---|---|
61
+ | `align` | `"left" \| "center" \| "right"` | según bloque | Alineación del contenido. |
62
+ | `paddingY` / `paddingX` | `number` (px) | según bloque | Padding vertical / horizontal del bloque. |
63
+ | `paddingTop` / `paddingRight` / `paddingBottom` / `paddingLeft` | `number` (px) | `undefined` | Overrides por lado; **ganan** sobre el shorthand. |
64
+ | `marginTop` / `marginBottom` | `number` (px) | `undefined` | Separación externa (se aplica a la tabla contenedora, no al `<td>`). |
65
+ | `gap` | `number` (px) | según bloque | Separación interna (gutter de columnas, filas de listas, celdas de galería). |
66
+
67
+ Excepciones: `spacer` tiene layout fijo y `footer` lo resuelve por variante.
68
+
69
+ ### Variables `{key}`
70
+
71
+ Cualquier campo de texto (y los `src`/`href`) acepta `{clave}` y se resuelve con el `context`:
72
+
73
+ ```ts
74
+ await renderTemplateEmail({
75
+ subject: "Hola {firstName}",
76
+ payload,
77
+ context: { firstName: "Ana", unsubscribeUrl: "https://…/baja" },
78
+ });
79
+ ```
80
+
81
+ Claves de ejemplo ya presentes en `SAMPLE_CONTEXT`: `name`, `firstName`, `lastName`, `email`,
82
+ `phone`, `role`, `companyName`, `unsubscribeUrl`, `link`, `date`, `year`, `siteUrl`,
83
+ `supportEmail`, `webinarName`, `webinarDate`, `slotsLeft`, `productName`, `amountVes`,
84
+ `paymentUrl`… Una `{clave}` desconocida **se deja tal cual** (no se vacía).
85
+
86
+ ### Ajustes (`settings`)
87
+
88
+ | Prop | Tipo | Default | Notas |
89
+ |---|---|---|---|
90
+ | `pageBackground` | `string` | `#f5f1e8` | Fondo de la página (fuera de la tarjeta). |
91
+ | `cardBorderWidth` | `number` (px) | `1` | Borde de la tarjeta del correo (0 = sin marco). |
92
+ | `cardBorderRadius` | `number` (px) | `4` | Redondeo de la tarjeta. |
93
+ | `fontFamily` | `string` | stack de sistema | Tipografía del correo; `""` = no emitir (hereda la del cliente). |
94
+ | `files` | `EmailFileAttachment[]` | `[]` | Archivos subidos (en el editor: **Ajustes**). Los lista el bloque `downloads` y tu backend los adjunta al enviar. |
95
+
96
+ Cada archivo de `settings.files`:
97
+
98
+ ```ts
99
+ interface EmailFileAttachment {
100
+ id: string;
101
+ url: string; // URL PÚBLICA (https/data). Obligatoria.
102
+ name: string; // "guia.pdf" (se deriva de la URL si falta)
103
+ size?: number; // bytes (informativo)
104
+ mimeType?: string; // "application/pdf" (elige el ícono)
105
+ attach?: boolean; // true = adjuntar al correo (lo hace tu ESP)
106
+ link?: boolean; // default true = listarlo en el bloque `downloads`
107
+ }
108
+ ```
109
+
110
+ **Contrato con tu backend/ESP** (los adjuntos no viajan en el HTML):
111
+
112
+ ```ts
113
+ const { content, settings } = parseTemplatePayload(payload); // normaliza y repara
114
+ const attachments = settings.files
115
+ .filter((f) => f.attach)
116
+ .map((f) => ({ filename: f.name, path: f.url })); // p. ej. Resend/SES
117
+ const { html, subject } = await renderTemplateEmail({ subject, payload, context });
118
+ ```
119
+
120
+ > Los esquemas permitidos son `http:`, `https:` y `data:`; cualquier otro (`javascript:`, `blob:`)
121
+ > se descarta al normalizar. Los `blob:` del navegador solo sirven para previsualizar.
122
+
123
+ ### Anidamiento
124
+
125
+ `container`, `columns`, `grid` y `footer` son **contenedores**: guardan hijos en
126
+ `blocks: EmailBlock[]` o `columns: ColumnDef[]` (`{ id, blocks, width? }`).
127
+ **Profundidad máxima = 1** (`MAX_BLOCK_DEPTH`): un bloque puede contener hijos, pero esos hijos
128
+ no pueden volver a contener bloques (se descartan al normalizar). Un `footer` **no** se puede
129
+ anidar.
130
+
131
+ ```ts
132
+ blockJson("grid", {
133
+ layout: "50-50",
134
+ columns: [
135
+ { width: 50, blocks: [blockJson("heading", { text: "Izquierda" })] },
136
+ { width: 50, blocks: [["image", { src: "https://…/foto.jpg" }]] },
137
+ ],
138
+ });
139
+ ```
140
+
141
+ ---
142
+
143
+ ## 1. `header` — Header / Marca
144
+
145
+ Cabecera con logo o nombre de marca y una línea de apoyo.
146
+
147
+ | Prop | Tipo | Default |
148
+ |---|---|---|
149
+ | `brandName` | `string` | `"Tu Marca"` |
150
+ | `tagline` | `string` | `"Breve descripción de tu marca"` |
151
+ | `logoUrl` | `string` | `""` (si falta, pinta el `brandName` en texto) |
152
+ | `backgroundColor` | `string` | `#0d0b08` |
153
+
154
+ ```ts
155
+ blockJson("header", {
156
+ brandName: "Tu Marca",
157
+ tagline: "Novedades de la semana",
158
+ logoUrl: "https://…/logo.png",
159
+ backgroundColor: "#0d0b08",
160
+ });
161
+ ```
162
+
163
+ ## 2. `hero` — Hero
164
+
165
+ Titular grande + subtítulo (y opcionalmente una imagen), centrado por defecto.
166
+
167
+ | Prop | Tipo | Default |
168
+ |---|---|---|
169
+ | `title` | `string` | `"¡Un gran titular aquí!"` |
170
+ | `subtitle` | `string` | `"Un subtítulo de apoyo para tu mensaje."` |
171
+ | `imageUrl` | `string` | `""` |
172
+
173
+ ```ts
174
+ blockJson("hero", { title: "Todo lo que llega este mes", subtitle: "Novedades y ofertas" });
175
+ ```
176
+
177
+ ## 3. `heading` — Título
178
+
179
+ Título de sección.
180
+
181
+ | Prop | Tipo | Default |
182
+ |---|---|---|
183
+ | `text` | `string` (richText) | `"Título de sección"` |
184
+ | `size` | `number` (px) | `22` |
185
+ | `color` | `string` | `#0d0b08` |
186
+
187
+ ## 4. `text` — Texto
188
+
189
+ Párrafo de texto (admite richText: `<strong>`, `<em>`, `<a>`… saneado con `sanitize-html`).
190
+
191
+ | Prop | Tipo | Default |
192
+ |---|---|---|
193
+ | `text` | `string` (richText) | `"Escribe aquí tu mensaje…"` |
194
+ | `size` | `number` (px) | `15` |
195
+ | `color` | `string` | `#221d15` |
196
+
197
+ ## 5. `list` — Lista (3 estilos)
198
+
199
+ | `variant` | Qué pinta |
200
+ |---|---|
201
+ | `bullets` (default) | viñetas con `icon` + `items: string[]` |
202
+ | `numbered` | badge circular numerado + `title` + `description` |
203
+ | `with-image` | imagen 40% + título + descripción + enlace (sin badge) |
204
+
205
+ | Prop | Tipo | Default | Aplica a |
206
+ |---|---|---|---|
207
+ | `items` | `string[]` | 3 beneficios | `bullets` |
208
+ | `icon` | `string` | `"✓"` | `bullets` |
209
+ | `entries` | `ListEntry[]` (`title`, `description`, `number`, `image`, `href`, `linkLabel`) | 3 filas | `numbered`, `with-image` |
210
+ | `accentColor` | `string` | `#d7b227` | badge y enlace |
211
+ | `radius` / `imageHeight` | `number` (px) | `4` / `168` | `with-image` |
212
+ | `gap` | `number` (px) | `8` (bullets) / `24` | separación entre filas |
213
+
214
+ ```ts
215
+ blockJson("list", {
216
+ variant: "numbered",
217
+ items: [],
218
+ entries: [
219
+ { title: "Envíos en 24 h", description: "Entrega exprés." },
220
+ { title: "Soporte ampliado", description: "También los domingos." },
221
+ ],
222
+ accentColor: "#4f46e5",
223
+ });
224
+ ```
225
+
226
+ ## 6. `button` — Botón CTA
227
+
228
+ | Prop | Tipo | Default |
229
+ |---|---|---|
230
+ | `label` | `string` | `"¡Quiero mi cupo!"` |
231
+ | `href` | `string` | `""` |
232
+ | `backgroundColor` | `string` | `#d7b227` |
233
+ | `color` | `string` | `#0d0b08` |
234
+ | `blockBackgroundColor` | `string` | `undefined` (fondo del bloque) |
235
+
236
+ > Si `href` está vacío el botón se pinta como texto (sin `<a>`), no navega.
237
+
238
+ ## 7. `image` — Imagen
239
+
240
+ | Prop | Tipo | Default |
241
+ |---|---|---|
242
+ | `src` | `string` | `""` (placeholder "(Selecciona una imagen)") |
243
+ | `alt` | `string` | `"Imagen"` |
244
+ | `width` | `number` (px) | `480` |
245
+ | `href` | `string` | `""` (opcional: envuelve la imagen en un enlace) |
246
+
247
+ ## 8. `quote` — Cita / Testimonio
248
+
249
+ | Prop | Tipo | Default |
250
+ |---|---|---|
251
+ | `text` | `string` (richText) | `"Esto cambió por completo mi manera de pensar."` |
252
+ | `author` | `string` | `"Nombre del participante"` |
253
+ | `borderColor` | `string` | `#d7b227` |
254
+
255
+ ## 9. `columns` — Columnas (contenedor)
256
+
257
+ Dos celdas con bloques hijos.
258
+
259
+ | Prop | Tipo | Default |
260
+ |---|---|---|
261
+ | `columns` | `ColumnDef[]` = `{ id, blocks: EmailBlock[], width? }` | 2 celdas con un `text` |
262
+ | `gap` | `number` (px) | `8` |
263
+
264
+ ## 10. `container` — Contenedor (contenedor)
265
+
266
+ Agrupa bloques en una columna (útil para mover un bloque como unidad).
267
+
268
+ | Prop | Tipo | Default |
269
+ |---|---|---|
270
+ | `blocks` | `EmailBlock[]` | `[]` |
271
+
272
+ ## 11. `grid` — Cuadrícula (contenedor)
273
+
274
+ Una **fila** de celdas con anchos configurables.
275
+
276
+ | Prop | Tipo | Default |
277
+ |---|---|---|
278
+ | `columns` | `ColumnDef[]` con `width` en % | 2 celdas |
279
+ | `layout` | `string` (preset: `1-2`, `1-3-2-3`, `2-3-1-3`, `1-3-1-3-1-3`, `1-4-1-4-1-4-1-4`) | `"1-2"` |
280
+ | `gap` | `number` (px) | `12` |
281
+
282
+ ## 12. `divider` — Divisor
283
+
284
+ | Prop | Tipo | Default |
285
+ |---|---|---|
286
+ | `color` | `string` | `#e3dccb` |
287
+
288
+ ## 13. `spacer` — Espaciador
289
+
290
+ | Prop | Tipo | Default |
291
+ |---|---|---|
292
+ | `height` | `number` (px) | `24` |
293
+
294
+ ## 14. `footer` — Footer (3 estilos, contenedor)
295
+
296
+ | `variant` | Qué pinta |
297
+ |---|---|
298
+ | `classic` (default) | barra oscura con `text` + `brandName` |
299
+ | `one-column` | una celda con bloques hijos (`columns[0]`) |
300
+ | `two-columns` | dos celdas con bloques hijos |
301
+
302
+ | Prop | Tipo | Default | Aplica a |
303
+ |---|---|---|---|
304
+ | `text` | `string` | `"Recibes este correo por estar registrado…"` | `classic` |
305
+ | `brandName` | `string` | `"Tu Marca"` | `classic` |
306
+ | `columns` | `ColumnDef[]` | `[]` | `one-column`, `two-columns` |
307
+ | `backgroundColor` | `string` | `#0d0b08` | todas |
308
+ | `gap` | `number` (px) | `12` | columnas |
309
+
310
+ > No puede anidarse dentro de otro contenedor.
311
+
312
+ ```ts
313
+ blockJson("footer", {
314
+ variant: "one-column",
315
+ columns: [{ blocks: [blockJson("text", { text: "Recibes este correo por…" })] }],
316
+ backgroundColor: "#f9fafb",
317
+ });
318
+ ```
319
+
320
+ ## 15. `social` — Redes sociales (2 modos)
321
+
322
+ | Prop | Tipo | Default |
323
+ |---|---|---|
324
+ | `mode` | `"text" \| "logo"` | `"text"` |
325
+ | `links` | `{ label, href, icon }[]` | 3 redes |
326
+ | `iconSize` | `number` (px) | `32` |
327
+ | `gap` | `number` (px) | `6` |
328
+ | `color` | `string` | `#221d15` (texto en modo `text`) |
329
+ | `accentColor` | `string` | `#d7b227` (medallón en modo `logo`) |
330
+
331
+ En modo `logo`, `icon` acepta un glifo (`SOCIAL_ICONS`) **o** una URL de imagen (`https:` o `data:`).
332
+
333
+ ## 16. `gallery` — Galería (4 diseños)
334
+
335
+ | `variant` | Qué pinta |
336
+ |---|---|
337
+ | `grid` (default) | cuadrícula de 2/3 columnas (`columns`) |
338
+ | `three-columns` | una fila de tres |
339
+ | `horizontal` | dos apiladas + una alta (cubre las dos + gutter) |
340
+ | `vertical` | una ancha arriba + dos abajo |
341
+
342
+ | Prop | Tipo | Default | Notas |
343
+ |---|---|---|---|
344
+ | `images` | `{ src, alt, href }[]` | 2 | |
345
+ | `columns` | `number` (2 \| 3) | `2` | solo `grid` |
346
+ | `radius` | `number` (px) | `6` | |
347
+ | `imageHeight` | `number` (px) | `0` = alto automático | con alto fijo añade `object-fit:cover` |
348
+ | `gap` | `number` (px) | `12` | gutter entre celdas |
349
+
350
+ > `object-fit:cover` no lo soporta Outlook escritorio: ahí las imágenes se estiran. Con
351
+ > `imageHeight: 0` el correo es "a prueba de balas".
352
+
353
+ ## 17. `stats` — Estadísticas
354
+
355
+ | Prop | Tipo | Default |
356
+ |---|---|---|
357
+ | `stats` | `{ value, label }[]` | 3 cifras |
358
+ | `accentColor` | `string` | `#d7b227` |
359
+
360
+ ## 18. `pricing` — Precio / Plan (3 estilos)
361
+
362
+ | `variant` | Qué pinta | Props que usa |
363
+ |---|---|---|
364
+ | `card` (default) | tarjeta de un plan | `title`, `price`, `period`, `bullets`, `ctaLabel`, `ctaHref` |
365
+ | `offer` | tabla de oferta con nota | + `eyebrow`, `description`, `note`, `note2` (botón full-width) |
366
+ | `two-tiers` | 2+ planes con uno destacado | `heading`, `subtitle`, `plans[]`, `footnote` |
367
+
368
+ `PricingPlan` = `{ title, price, period, description, features: string[], ctaLabel, ctaHref, highlighted }`.
369
+
370
+ | Prop | Tipo | Default |
371
+ |---|---|---|
372
+ | `plans` | `PricingPlan[]` | `[]` |
373
+ | `accentColor` | `string` | `#d7b227` |
374
+ | `textColor` | `string` | `#221d15` |
375
+
376
+ ```ts
377
+ blockJson("pricing", {
378
+ variant: "two-tiers",
379
+ heading: "Elige tu plan",
380
+ subtitle: "Sin permanencia.",
381
+ plans: [
382
+ { title: "Hobby", price: "$29", period: "/ mes", features: ["25 productos"], ctaLabel: "Empezar" },
383
+ { title: "Enterprise", price: "$99", period: "/ mes", features: ["Todo incluido"], ctaLabel: "Empezar", highlighted: true },
384
+ ],
385
+ footnote: "Precios sin impuestos.",
386
+ });
387
+ ```
388
+
389
+ ## 19. `product` — Producto (4 estilos)
390
+
391
+ | `variant` | Qué pinta |
392
+ |---|---|
393
+ | `card` (default) | imagen centrada + nombre + descripción + precio + CTA |
394
+ | `hero` | imagen ancha arriba (320) + `eyebrow` + título 36px + … |
395
+ | `image-left` | tabla 50/50: imagen izquierda, ficha derecha (botón 75%) |
396
+ | `grid` | encabezado opcional + tarjetas de `products[]` (`columns` 2/3/4; con 4 → 2×2 + `Hr`) |
397
+
398
+ | Prop | Tipo | Default | Notas |
399
+ |---|---|---|---|
400
+ | `imageUrl`, `name`, `description`, `price` | `string` | ver esquema | variantes de un producto |
401
+ | `eyebrow` | `string` | `""` | `hero` |
402
+ | `heading` / `subheading` | `string` | `""` | `grid` |
403
+ | `products` | `ProductItem[]` (`imageUrl`, `name`, `description`, `price`, `ctaLabel`, `ctaHref`) | 2 tarjetas | `grid` |
404
+ | `columns` | `number` (2 \| 3 \| 4) | `2` | `grid` |
405
+ | `radius` | `number` (px) | `8` (hero 12) | |
406
+ | `imageHeight` | `number` (px) | `0` = default del layout (hero 320 · grid 180/250) | |
407
+ | `accentColor` | `string` | `#d7b227` | eyebrow, precio y botón |
408
+
409
+ ## 20. `checkout` — Resumen de pedido
410
+
411
+ Carrito con líneas de producto y botón de compra full-width.
412
+
413
+ | Prop | Tipo | Default |
414
+ |---|---|---|
415
+ | `heading` | `string` | `"Tu carrito te espera"` |
416
+ | `lines` | `CheckoutLine[]` = `{ imageUrl, name, quantity, price }` | 2 líneas |
417
+ | `ctaLabel` / `ctaHref` | `string` | `"Finalizar compra"` / `""` |
418
+ | `imageHeight` | `number` (px) | `110` (miniatura cuadrada) |
419
+ | `radius` | `number` (px) | `8` |
420
+ | `accentColor` | `string` | `#4f46e5` (botón) |
421
+ | `borderColor` | `string` | `#e5e7eb` (caja y líneas de la tabla) |
422
+
423
+ ```ts
424
+ blockJson("checkout", {
425
+ heading: "Tu carrito te espera",
426
+ lines: [
427
+ { name: "Reloj clásico", imageUrl: "https://…/reloj.jpg", quantity: "1", price: "$210.00" },
428
+ { name: "Reloj de pared", quantity: "2", price: "$90.00" },
429
+ ],
430
+ ctaLabel: "Volver a mi carrito",
431
+ ctaHref: "https://tienda.com/carrito",
432
+ });
433
+ ```
434
+
435
+ ## 21. `testimonial` — Testimonio
436
+
437
+ | Prop | Tipo | Default |
438
+ |---|---|---|
439
+ | `avatarUrl` | `string` | `""` |
440
+ | `quote` | `string` (richText) | `"Cambió por completo la forma en que trabajamos."` |
441
+ | `name` / `role` | `string` | `"Nombre Apellido"` / `"Cargo en la empresa"` |
442
+ | `accentColor` | `string` | `#d7b227` |
443
+
444
+ ## 22. `features` — Características
445
+
446
+ | Prop | Tipo | Default |
447
+ |---|---|---|
448
+ | `features` | `{ icon, title, description }[]` | 2 filas |
449
+ | `columns` | `number` (2 \| 3) | `2` |
450
+ | `accentColor` | `string` | `#d7b227` (ícono) |
451
+
452
+ ## 23. `avatar` — Avatar
453
+
454
+ | Prop | Tipo | Default |
455
+ |---|---|---|
456
+ | `imageUrl` | `string` | `""` |
457
+ | `name` / `role` | `string` | `"Nombre Apellido"` / `"Cargo"` |
458
+ | `size` | `number` (px) | `96` |
459
+
460
+ ## 24. `code` — Código
461
+
462
+ | Prop | Tipo | Default |
463
+ |---|---|---|
464
+ | `code` | `string` | `"npm install create-email-template"` |
465
+ | `language` | `string` | `"bash"` |
466
+ | `backgroundColor` / `color` | `string` | `#0d0b08` / `#f5f1e8` |
467
+
468
+ Usa un stack monoespaciado propio (gana sobre el `fontFamily` del correo).
469
+
470
+ ## 25. `link` — Enlace
471
+
472
+ | Prop | Tipo | Default |
473
+ |---|---|---|
474
+ | `label` | `string` | `"Ver más"` |
475
+ | `href` | `string` | `""` |
476
+ | `color` | `string` | `#a98a1e` |
477
+
478
+ > Sin `href` se pinta como texto (sin `<a>`).
479
+
480
+ ## 26. `downloads` — Archivos / Descargas
481
+
482
+ Lista los archivos de **`settings.files`** (los que se suben en Ajustes) con su tipo, nombre,
483
+ tamaño y un enlace de descarga. **No lleva los archivos en las props**: el bloque solo pinta la
484
+ lista, así que subir un archivo en Ajustes lo actualiza en todos los correos que tengan el bloque.
485
+
486
+ | Prop | Tipo | Default |
487
+ |---|---|---|
488
+ | `heading` | `string` | `"Recursos descargables"` |
489
+ | `subheading` | `string` | `""` |
490
+ | `buttonLabel` | `string` | `"Descargar"` |
491
+ | `accentColor` | `string` | `#d7b227` (enlace y badge) |
492
+ | `borderColor` | `string` | `#e3dccb` (caja y filas) |
493
+ | `showIcon` / `showSize` | `boolean` | `true` / `true` |
494
+ | `radius` | `number` (px) | `8` |
495
+ | `emptyText` | `string` | `""` (sin archivos no pinta nada) |
496
+
497
+ Solo aparecen los archivos con `link !== false`; los marcados únicamente como adjunto
498
+ (`attach: true, link: false`) no salen en el HTML. Sin archivos y sin `emptyText` el bloque no
499
+ emite ni un byte (la salida es idéntica a la de una plantilla sin el bloque).
500
+
501
+ ```ts
502
+ templateJson(
503
+ [blockJson("downloads", { heading: "Recursos de la semana" })],
504
+ {
505
+ files: [
506
+ { id: "f1", url: "https://cdn.tu-sitio.com/guia.pdf", name: "Guía.pdf", size: 1536, mimeType: "application/pdf" },
507
+ { id: "f2", url: "https://cdn.tu-sitio.com/planilla.xlsx", name: "Planilla.xlsx", mimeType: "application/vnd.ms-excel" },
508
+ ],
509
+ },
510
+ );
511
+ ```
512
+
513
+ ---
514
+
515
+ ## Reglas prácticas al generar correos
516
+
517
+ 1. **Usa `blockJson` / `templateJson`**, no JSON a mano: los defaults y los ids salen solos.
518
+ 2. Los arrays de registros se llaman `images`, `links`, `stats`, `features`, `entries`, `plans`,
519
+ `products` y `lines`. **Nunca `items`** para registros: `items` es la lista de strings de
520
+ `list`.
521
+ 3. **Imágenes**: mejor URLs `https:` propias. Las `data:` (SVG inline) las descarta Gmail.
522
+ 4. **Alto fijo + `object-fit`** se ve bien en Gmail/Apple Mail/Outlook web, pero **no** en Outlook
523
+ escritorio (se estira). Para máxima compatibilidad: `imageHeight: 0`.
524
+ 5. **Enlaces**: si dejas `href: ""` el CTA se pinta como texto; nunca genera `href=""`.
525
+ 6. **Un solo nivel de anidamiento** y un `footer` por correo.
526
+ 7. **Peso**: mantén el HTML por debajo de ~102 KB o Gmail lo recortará ("Ver todo el mensaje").
527
+ 8. **Variables**: úsalas en textos y enlaces; no inventes claves fuera del `context` (se quedan
528
+ visibles como `{clave}`).
529
+ 9. **Archivos descargables**: van en `settings.files`, no en el bloque; usa URL públicas
530
+ (`https:`) y `attach: true` solo lo que quieras adjuntar de verdad (el HTML no adjunta nada).