npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

create-email-template

v0.3.0

Published

Reusable email template builder (blocks, canvas, options) with a zero-dependency React store and shadcn/ui

Readme

create-email-template

Editor visual de plantillas de email por bloques para React: palette, canvas con drag & drop, edición de texto inline enriquecida, panel de propiedades, autoguardado, deshacer y preview en móvil/escritorio.

El JSON que produce se renderiza a HTML email-safe con su compañero create-email-renderer (sin React, pensado para backend/edge). Ambas librerías comparten el mismo core, por lo que el preview del editor y el HTML final nunca divergen.

¿Programas con una IA o vas a integrarlo?

  • SKILL.md — orientación para agentes: provider, hooks, store, presets, CSS obligatorio y errores comunes.
  • llms.txt — índice corto.
  • Catálogo de bloques (core compartido): node_modules/create-email-renderer/docs/BLOCKS.md — props, defaults, variantes y ejemplos de los 25 bloques.
  • Guía de MCP: node_modules/create-email-renderer/docs/MCP.md.

Instalación

npm install create-email-template

Requiere React ^18 || ^19 como peer dependency (usa useSyncExternalStore, nativo desde React 18).

Uso mínimo

import { EmailBuilder, EmailBuilderProvider } from "create-email-template";
import "create-email-template/style.css"; // ¡necesario! Estilos autocontenidos

export function EditorPage() {
  return (
    <EmailBuilderProvider>
      <EmailBuilder />
    </EmailBuilderProvider>
  );
}

<EmailBuilder /> renderiza el editor completo (palette + canvas + panel de propiedades). Todo lo demás es configuración opcional.

📥 ¿Ya creaste una plantilla y quieres volver a editarla? Ve a Editar una plantilla existente — se carga con hydrate() y el autoguardado sigue guardando sobre esa misma plantilla.


<EmailBuilderProvider> — configuración completa

<EmailBuilderProvider
  config={{ /* opciones del builder (todas opcionales) */ }}
  uploadImage={fnUpload}       // opcional: subida de imágenes
  uploadFile={fnUploadFile}    // opcional: subida de documentos (PDF, DOC, XLS…)
  autosave={{ /* opcional: autoguardado */ }}
>

config

| Opción | Tipo | Default | Qué hace | |---|---|---|---| | blockDefaults | Partial<Record<Bloque, Partial<Props>>> | — | Valores iniciales por tipo de bloque. Se aplican al arrastrar el bloque al canvas. | | blockLibrary | BlockDefinition[] | DEFAULT_BLOCK_LIBRARY (26 bloques) | Qué bloques aparecen en la palette y con qué defaultProps. | | palette | Partial<EmailPalette> | DEFAULT_PALETTE | Colores base del tema: INK (texto), DARK (fondos oscuros), GOLD (acentos), CREAM_DIM (texto secundario). | | defaultSettings | Partial<EmailSettings> | DEFAULT_SETTINGS | Fondo de página, grosor/redondeo del borde, tipografía y archivos (files). | | files | Partial<EmailBuilderFileOptions> | { accept, maxSizeMb: 10, maxCount: 10, warnTotalMb: 5 } | Límites y formatos aceptados al subir archivos en Ajustes. | | variables | EmailVariable[] | DEFAULT_VARIABLES | Etiquetas {key} disponibles para el usuario. | | variableSections | EmailVariableSection[] | agrupación única | Agrupa las variables por categoría en el dialog "Etiquetas disponibles". | | sampleContext | EmailContext | SAMPLE_CONTEXT | Datos de ejemplo: al previsualizar, {firstName} se ve como el valor de aquí. | | labels | Partial<EmailBuilderLabels> | DEFAULT_LABELS (español) | Textos de la UI del editor (i18n). | | historyLimit | number | 50 | Profundidad máxima del historial de deshacer. |

Ejemplo real de config

<EmailBuilderProvider
  config={{
    // Al arrastrar un header, nace con la marca y el logo ya puestos
    blockDefaults: {
      header: { brandName: "Mi Marca", logoUrl: "https://mi-cdn.com/logo.png" },
      text: { text: "Hola {firstName}, gracias por ser parte de nuestra comunidad." },
      button: { label: "Quiero mi cupo", href: "{registerUrl}", backgroundColor: "#d7b227" },
      footer: { brandName: "Mi Marca" },
    },

    // Personaliza el tema completo
    palette: { INK: "#221d15", DARK: "#0d0b08", GOLD: "#d7b227", CREAM_DIM: "#b8ae9d" },
    defaultSettings: { pageBackground: "#f5f1e8", cardBorderWidth: 1, cardBorderRadius: 4 },

    // Contexto de ejemplo para el preview de variables
    sampleContext: {
      firstName: "Danny",
      companyName: "Mi Empresa",
      registerUrl: "https://mi-sitio.com/registro",
      unsubscribeUrl: "https://mi-sitio.com/baja",
    },

    // Solo estas variables aparecen en el editor (si lo omites: todas las defaults)
    variables: [
      { key: "firstName", label: "Nombre" },
      { key: "email", label: "Correo electrónico" },
      { key: "registerUrl", label: "URL de registro" },
    ],

    // Más undo (cada snapshot es del estado completo de bloques)
    historyLimit: 100,
  }}
>

Bloques disponibles

26 bloques: header, hero, heading, text, list, button, image, quote, columns, container, grid, divider, spacer, footer, social, gallery, stats, pricing, product, checkout, downloads, testimonial, features, avatar, code y link. Muchos tienen variantes (list, footer, pricing, gallery, product).

Todos aceptan align, backgroundColor, paddingY/paddingX (+ overrides por lado), marginTop/marginBottom y gap.

📖 Props, defaults, variantes y ejemplos de cada uno: el catálogo del core, create-email-renderer/docs/BLOCKS.md (desde npm: node_modules/create-email-renderer/docs/BLOCKS.md). Es la única fuente de verdad y viaja con el paquete del renderer.

uploadImage

Se invoca cuando el usuario sube una imagen desde el panel de propiedades. Recibe el File y debe devolver la URL pública donde quedó alojada (S3, R2, tu API…):

uploadImage={async (file: File) => {
  const form = new FormData();
  form.append("file", file);
  const res = await fetch("/api/uploads", { method: "POST", body: form });
  const { url } = await res.json();
  return url; // esta URL queda en la prop `src` del bloque image
}}

uploadFile

Se invoca cuando el usuario sube un documento desde Ajustes (el engranaje del lienzo). Recibe el File y debe devolver la URL pública donde quedó alojado: el destinatario la usa para descargar y tu backend para adjuntarlo al enviar.

uploadFile={async (file: File) => {
  const form = new FormData();
  form.append("file", file);
  const res = await fetch("/api/uploads", { method: "POST", body: form });
  const { url } = await res.json();
  return url; // URL pública (R2, S3, tu API…)
}}

Los archivos quedan en settings.files del payload y el bloque downloads los lista en el correo. Cada archivo tiene dos interruptores:

| Interruptor | Campo | Para qué | |---|---|---| | Adjuntar | attach | Que tu ESP lo adjunte al enviar (el HTML no adjunta nada). | | Enlace | link | Que aparezca en el bloque downloads (default true). |

Contrato del backend:

const { content, settings } = parseTemplatePayload(template.payload);
const attachments = settings.files
  .filter((f) => f.attach)
  .map((f) => ({ filename: f.name, path: f.url })); // p. ej. Resend/SES
const { html, subject } = await renderTemplateEmail({ subject, payload: template.payload, context });

Sin uploadFile la librería usa URL.createObjectURL (blob local) para que puedas probar el editor: sirve para previsualizar, no para un correo real.


Autoguardado (autosave) y guardado manual

Opt-in: sin la prop, no hay guardado automático. La librería no hace network — onSave es tu fetch.

import type { AutosavePayload } from "create-email-template";

async function onSave(payload: AutosavePayload) {
  const res = await fetch(`/api/templates/${templateId}`, {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload), // { content, settings } → tu BD
  });
  if (!res.ok) throw new Error("Error al guardar");
  return res.json(); // lo que devuelvas llega a `lastResult` (ej. { json, html })
}

<EmailBuilderProvider
  autosave={{
    onSave,        // obligatorio
    intervalMs: 10_000, // default 10s
    enabled: true,      // interruptor sin desmontar el provider
    onSaved: (r) => {   // opcional: feedback por intento
      if (!r.ok) toast.error("No se pudo guardar");
    },
  }}
>

Cómo funciona el ciclo: cada intervalMs, si hay cambios (dirty) y no hay un guardado en vuelo, llama a onSave y limpia dirty. Si el usuario edita durante el guardado, el próximo tick toma los cambios — no se pierde nada.

useAutosaveStatus() — estado y botón de guardado manual

import { useAutosaveStatus } from "create-email-template";

function Toolbar() {
  const { isSaving, lastSavedAt, lastError, lastResult, saveNow } = useAutosaveStatus();

  return (
    <>
      {/* saveNow() dispara el MISMO onSave, inmediatamente → botón "Guardar" manual */}
      <button onClick={() => saveNow()} disabled={isSaving}>
        {isSaving ? "Guardando…" : "Guardar"}
      </button>

      {lastSavedAt && <span>Guardado a las {lastSavedAt.toLocaleTimeString()}</span>}
      {lastError && <span className="error">Error al guardar</span>}

      {/* lastResult = lo que devolvió tu onSave. Ej.: mostrar el HTML real del backend */}
      {lastResult?.html && <iframe srcDoc={lastResult.html} title="Vista del servidor" />}
    </>
  );
}

| Campo | Descripción | |---|---| | isSaving | Hay un guardado en vuelo. | | lastSavedAt | Fecha del último guardado exitoso. | | lastError | Error del último intento fallido (se limpia al guardar bien). | | lastResult | Lo que devolvió tu onSave — ej. { json, html } si tu backend renderiza y responde el HTML. | | saveNow() | Guardado inmediato con los mismos guards: no hace nada si no hay cambios o ya hay uno en vuelo. |

¿Solo botón manual, sin autoguardado? autosave={{ enabled: false, onSave }} — saveNow() sigue funcionando.


Editar una plantilla existente (cargar del backend)

¿Ya creaste una plantilla y quieres volver a editarla? El ciclo completo es: fetch del JSON → hydrate() → el usuario edita → el autoguardado guarda sobre la misma plantilla. Ejemplo de página completa:

import { useEffect, useState } from "react";
import {
  EmailBuilder,
  EmailBuilderProvider,
  useEmailBuilderStoreInstance,
  type AutosavePayload,
} from "create-email-template";
import "create-email-template/style.css";

// 1. Página: trae la plantilla y no monta el editor hasta que llegue
export function EditorPage({ templateId }: { templateId: string }) {
  const [payload, setPayload] = useState<AutosavePayload | null>(null);

  useEffect(() => {
    fetch(`/api/templates/${templateId}`)
      .then((r) => r.json())
      .then((template) => setPayload(template.payload)); // { content, settings }
  }, [templateId]);

  if (!payload) return <p>Cargando plantilla…</p>;

  return (
    <EmailBuilderProvider
      config={miConfig}
      autosave={{
        // 4. El autoguardado ahora actualiza ESA MISMA plantilla
        onSave: (data) =>
          fetch(`/api/templates/${templateId}`, {
            method: "PUT",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(data),
          }),
      }}
    >
      {/* 2. Componente puente: inyecta el payload en el store */}
      <TemplateLoader payload={payload} />
      {/* 3. El editor arranca ya con los bloques de la plantilla */}
      <EmailBuilder />
    </EmailBuilderProvider>
  );
}

// Debe vivir DENTRO del provider (usa el store del builder). No renderiza nada.
function TemplateLoader({ payload }: { payload: AutosavePayload }) {
  const store = useEmailBuilderStoreInstance();

  useEffect(() => {
    store.getState().hydrate({
      blocks: payload.content,
      settings: payload.settings, // opcional
    });
  }, [payload, store]);

  return null;
}

Qué hace hydrate({ blocks, settings? }):

  • Normaliza el payload: props faltantes se completan con los defaults actuales, tipos desconocidos se descartan — plantillas guardadas con versiones anteriores del esquema cargan sin romperse.
  • Reinicia el historial de deshacer: el estado cargado es la base nueva (el undo anterior se descarta).
  • Deja dirty: false: el autoguardado no dispara hasta que el usuario edite de verdad.
  • Los id de los bloques se conservan, así que el guardado actualiza en lugar de duplicar.

💡 Cargar antes de montar el editor (el if (!payload) del ejemplo) no es obligatorio, pero evita que el usuario edite un canvas vacío y su trabajo se sobrescriba cuando llegue el fetch.


Deshacer

Undo lineal (solo hacia atrás, nunca adelante), en memoria:

  • Cada acción (añadir/eliminar/reordenar/duplicar bloques, editar propiedades y ajustes) registra un snapshot. Tope: historyLimit (50).
  • Las escrituras continuas se agrupan: Ctrl+Z deshace la frase escrita, no tecla por tecla.
  • Ctrl/Cmd+Z ya funciona dentro de <EmailBuilder />. En un campo de texto editable se respeta el undo nativo.
  • Deshacer marca dirty: true → el autoguardado persiste el estado deshecho en el siguiente tick.
  • Al hydrate() el historial se vacía (el estado cargado es la nueva base).

Para tu propia UI de undo:

import { useEmailBuilderStore } from "create-email-template";

function UndoButton() {
  const canUndo = useEmailBuilderStore((s) => s.past.length > 0);
  const undo = useEmailBuilderStore((s) => s.undo);
  return <button onClick={() => undo()} disabled={!canUndo}>Deshacer</button>;
}

useRenderEmail() — payload y render de preview

import { useRenderEmail } from "create-email-template";

function Acciones() {
  const { getPayload, renderHtml, getHtml, resolve, html, isPending } = useRenderEmail();

  const guardar = () => fetch("/api/templates", { method: "POST", body: JSON.stringify(getPayload()) });
  const preview = () => renderHtml({ subject: "Hola {firstName}" }); // html se actualiza

  return <button onClick={preview} disabled={isPending}>Refrescar preview</button>;
}

| Miembro | Descripción | |---|---| | getPayload() | { content: EmailBlock[], settings } — el JSON para persistir. | | renderHtml(opts?) | Renderiza el preview con react-email (async). Opciones: { subject?, context? }. | | getHtml(opts?) | Igual que renderHtml pero cacheado: sin opciones devuelve el HTML ya generado. | | resolve(text, ctx?) | Reemplaza {etiquetas} en un texto con el sampleContext (o el que pases). | | html / isPending / setHtml | Estado reactivo del último render. |


useEmailBuilderStore(selector) — estado y acciones

Suscripción selectiva (re-renderiza solo lo que cambia). Requiere el provider.

import { useEmailBuilderStore } from "create-email-template";

const blocks = useEmailBuilderStore((s) => s.blocks);        // bloques actuales
const dirty = useEmailBuilderStore((s) => s.dirty);          // ¿hay cambios sin guardar?
const canUndo = useEmailBuilderStore((s) => s.past.length > 0);
const undo = useEmailBuilderStore((s) => s.undo);
const addBlock = useEmailBuilderStore((s) => s.addBlock);

addBlock("text");          // añade un bloque de texto al final (con tus blockDefaults)
addBlock("button", 0);     // …o en una posición específica

Estado: blocks, selectedId, settings, dirty, propertiesOpen, past (pila de undo).

Acciones: addBlock(type, index?) · removeBlock(id) · duplicateBlock(id) · updateBlockProps(id, props) · reorder(activeIndex, overIndex) · setSettings(patch) · select(id) · setPropertiesOpen(open) · hydrate({ blocks, settings? }) · undo() · markSaved() · getPayload().

¿Fuera de React? createEmailBuilderStore(config) crea el mismo store vanilla (getState/setState/subscribe) sin dependencias.


Notas

  • Estilos: importa create-email-template/style.css una vez. Todo va bajo la clase .ter-theme con clases ter-* — no colisiona con tu Tailwind/CSS: ningún selector global, así que no pisa tus @layer ni tus utilidades (verificado por check:css-scope en el build del paquete).
    • Versiones < 0.1.5: el reset universal * salía sin scope y, al llegar sin capa, podía pisar utilidades de apps con Tailwind v4 (p-4, border…). Actualiza a >= 0.1.5; si no puedes, cárgalo en una capa de baja prioridad: @import 'create-email-template/style.css' layer(email-lib); (declarando @layer email-lib; antes de tu @import 'tailwindcss').
  • Componentes sueltos: también se exportan BlockPalette, Canvas, PropertiesPanel, EditableBlockRenderer, InlineTextEditor, SelectionToolbar, VariablesInfoDialog para armar un editor a medida.
  • Demo completa: apps/vite-test del monorepo — editor con config de muestra, autoguardado, undo y dialog comparando el render de React vs. el HTML del backend.

Licencia

MIT