From b7ebacdf1d56a58cfb171a88f3a27316752a7902 Mon Sep 17 00:00:00 2001 From: Orkhan Ahmadov Date: Wed, 13 May 2026 10:50:30 +0200 Subject: [PATCH] feat: add support for hosted PNG social icons to improve compatibility with Outlook --- .changeset/social-icons-png-output.md | 6 + .gitignore | 3 - apps/docs/.gitignore | 2 + apps/docs/api/renderer-typescript.md | 46 ++++-- apps/docs/de/api/renderer-typescript.md | 46 ++++-- apps/playground/.gitignore | 1 + .../src/components/toolbar/SocialToolbar.vue | 29 ++-- packages/editor/tests/socialToolbar.test.ts | 4 +- packages/renderer/.gitignore | 1 + packages/renderer/package.json | 6 +- .../rasterize-social.mjs} | 110 +++++++++----- packages/renderer/src/index.ts | 23 ++- packages/renderer/src/render-context.ts | 12 ++ packages/renderer/src/renderers/social.ts | 15 +- .../renderer/tests/mjml-bg-roundtrip.test.ts | 31 ++++ .../renderer/tests/render-to-mjml.test.ts | 50 ++++++- packages/renderer/tests/social-icons.test.ts | 93 ------------ packages/renderer/tests/social.test.ts | 99 ++++++++++++- pnpm-lock.yaml | 134 ++++++++++++++++++ 19 files changed, 529 insertions(+), 182 deletions(-) create mode 100644 .changeset/social-icons-png-output.md create mode 100644 apps/docs/.gitignore create mode 100644 apps/playground/.gitignore create mode 100644 packages/renderer/.gitignore rename packages/renderer/{src/social-icons.ts => scripts/rasterize-social.mjs} (82%) delete mode 100644 packages/renderer/tests/social-icons.test.ts diff --git a/.changeset/social-icons-png-output.md b/.changeset/social-icons-png-output.md new file mode 100644 index 00000000..fe8d717b --- /dev/null +++ b/.changeset/social-icons-png-output.md @@ -0,0 +1,6 @@ +--- +"@templatical/renderer": patch +"@templatical/editor": patch +--- + +Render social icons as hosted PNGs instead of inline SVG data URIs so they display in Outlook desktop (the Word rendering engine has no SVG support and rejects base64 in ``). PNGs are shipped with the npm package and served via the version-pinned unpkg URL by default; override via the new `RenderOptions.socialIconsBaseUrl` to self-host. Replace the Style segmented control in the social icons sidebar with a native dropdown so the 5-option list no longer overflows the sidebar. diff --git a/.gitignore b/.gitignore index b1dece95..63f4954c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,6 @@ coverage .DS_Store .idea *.tsbuildinfo -apps/playground/.env -apps/docs/.vitepress/cache -apps/docs/.vitepress/dist playwright-report/ test-results/ blob-report/ diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore new file mode 100644 index 00000000..7b64d98e --- /dev/null +++ b/apps/docs/.gitignore @@ -0,0 +1,2 @@ +.vitepress/cache +.vitepress/dist \ No newline at end of file diff --git a/apps/docs/api/renderer-typescript.md b/apps/docs/api/renderer-typescript.md index c02ad8d4..600cc9b1 100644 --- a/apps/docs/api/renderer-typescript.md +++ b/apps/docs/api/renderer-typescript.md @@ -40,6 +40,7 @@ interface RenderOptions { defaultFallbackFont?: string; allowHtmlBlocks?: boolean; // default: true renderCustomBlock?: (block: CustomBlock) => Promise; + socialIconsBaseUrl?: string; } ``` @@ -49,6 +50,7 @@ interface RenderOptions { | `defaultFallbackFont` | `'Arial, sans-serif'` | Fallback font stack | | `allowHtmlBlocks` | `true` | Set to `false` to strip HTML blocks from output | | `renderCustomBlock` | -- | Resolves custom blocks to HTML. Called once per custom block. Editor consumers pass `editor.renderCustomBlock`; headless consumers wire their own resolver. If omitted, custom blocks fall back to the block's `renderedHtml` field (if present) and otherwise are omitted. | +| `socialIconsBaseUrl` | version-pinned unpkg URL | Base URL (no trailing slash) for the social icon PNG assets. Resolved per icon to `${baseUrl}/${style}/${platform}.png`. See [Social icons](#social-icons) below. | ### Custom blocks @@ -77,6 +79,34 @@ const mjml = await renderToMjml(content, { }); ``` +### Social icons + +Social icon blocks are emitted as ``. The default `socialIconsBaseUrl` points at the version-pinned unpkg mirror of `@templatical/renderer`, which ships pre-rasterized PNGs (16 platforms × 5 styles) alongside the package: + +``` +https://unpkg.com/@templatical/renderer@/assets/social/{style}/{platform}.png +``` + +**Why PNGs.** Outlook desktop (Word rendering engine) does not support SVG and rejects base64 data URIs in ``. Hosted PNGs are the only format that renders across every mainstream email client. + +**Why version-pinned.** Email is archival — recipients open messages months or years after they're sent. The version pin freezes the icon visuals at render time so a future redesign or regression in the package doesn't retroactively break already-delivered emails. It also avoids a per-image 302 redirect and unlocks long-lived immutable cache headers. + +**Self-hosting.** Override `socialIconsBaseUrl` to serve the assets from your own CDN — useful for air-gapped environments, brand-specific theming, or removing the unpkg dependency: + +```ts +const mjml = await renderToMjml(content, { + socialIconsBaseUrl: 'https://cdn.example.com/email-assets/social', +}); +``` + +The exact filenames the renderer expects are `{style}/{platform}.png` where `style` is one of `solid | outlined | rounded | square | circle` and `platform` is one of `facebook | twitter | instagram | linkedin | youtube | tiktok | pinterest | email | whatsapp | telegram | discord | snapchat | reddit | github | dribbble | behance`. The shipped 192×192 PNGs are a reasonable starting point if you want to mirror them. + +The package also exports `DEFAULT_SOCIAL_ICONS_BASE_URL` if you want to compose URLs against the same default: + +```ts +import { DEFAULT_SOCIAL_ICONS_BASE_URL } from '@templatical/renderer'; +``` + ## Utilities The renderer also exports utility functions: @@ -88,13 +118,12 @@ import { convertMergeTagsToValues, isHiddenOnAll, toPaddingString, - generateSocialIconDataUri, renderBlock, getCssClassAttr, getCssClasses, getWidthPercentages, getWidthPixels, - SOCIAL_ICONS, + DEFAULT_SOCIAL_ICONS_BASE_URL, RenderContext, } from '@templatical/renderer'; ``` @@ -154,15 +183,6 @@ toPaddingString({ top: 10, right: 20, bottom: 10, left: 20 }); // '10px 20px 10px 20px' ``` -### `generateSocialIconDataUri(platform, style, size)` - -Generates a base64-encoded SVG data URI for a social media platform icon. Used internally by the renderer for social icon blocks: - -```ts -const uri = generateSocialIconDataUri('twitter', 'circle', 32); -// 'data:image/svg+xml,...' -``` - ### `renderBlock(block, context)` Renders a single block to its MJML representation. Used internally by `renderToMjml()` but exported for advanced use cases where you need to render individual blocks. @@ -179,9 +199,9 @@ Generate CSS class attributes from a block's visibility settings. Used internall Calculate column widths for a given `ColumnLayout`. Returns an array of percentage or pixel values per column. -### `SOCIAL_ICONS` +### `DEFAULT_SOCIAL_ICONS_BASE_URL` -A map of all built-in social platform SVG icon data, keyed by platform and style. +The default value of `RenderOptions.socialIconsBaseUrl` — the version-pinned unpkg URL pointing at this package's bundled social icon PNGs. See [Social icons](#social-icons). ## Compiling MJML to HTML diff --git a/apps/docs/de/api/renderer-typescript.md b/apps/docs/de/api/renderer-typescript.md index 6fa37772..16d3ec6f 100644 --- a/apps/docs/de/api/renderer-typescript.md +++ b/apps/docs/de/api/renderer-typescript.md @@ -40,6 +40,7 @@ interface RenderOptions { defaultFallbackFont?: string; allowHtmlBlocks?: boolean; // Standard: true renderCustomBlock?: (block: CustomBlock) => Promise; + socialIconsBaseUrl?: string; } ``` @@ -49,6 +50,7 @@ interface RenderOptions { | `defaultFallbackFont` | `'Arial, sans-serif'` | Fallback-Schriftart-Stack | | `allowHtmlBlocks` | `true` | Auf `false` setzen, um HTML-Blöcke aus der Ausgabe zu entfernen | | `renderCustomBlock` | -- | Wandelt benutzerdefinierte Blöcke in HTML um. Wird einmal pro benutzerdefiniertem Block aufgerufen. Editor-Konsumenten übergeben `editor.renderCustomBlock`; Headless-Konsumenten verwenden einen eigenen Resolver. Wenn weggelassen, fällt der Renderer auf das `renderedHtml`-Feld des Blocks zurück (falls vorhanden) und lässt den Block andernfalls weg. | +| `socialIconsBaseUrl` | versionsgebundene unpkg-URL | Basis-URL (ohne abschließenden Schrägstrich) für die PNG-Assets der Social-Media-Icons. Wird pro Icon zu `${baseUrl}/${style}/${platform}.png` aufgelöst. Siehe [Social-Media-Icons](#social-media-icons) unten. | ### Benutzerdefinierte Blöcke @@ -77,6 +79,34 @@ const mjml = await renderToMjml(content, { }); ``` +### Social-Media-Icons + +Social-Icon-Blöcke werden als `` ausgegeben. Der Standardwert von `socialIconsBaseUrl` verweist auf den versionsgebundenen unpkg-Mirror von `@templatical/renderer`, der vorgerasterte PNGs (16 Plattformen × 5 Stile) mit dem Paket ausliefert: + +``` +https://unpkg.com/@templatical/renderer@/assets/social/{style}/{platform}.png +``` + +**Warum PNGs.** Outlook Desktop (Word-Rendering-Engine) unterstützt kein SVG und lehnt base64-Daten-URIs in `` ab. Gehostete PNGs sind das einzige Format, das in allen gängigen E-Mail-Clients zuverlässig dargestellt wird. + +**Warum versionsgebunden.** E-Mails sind Archivinhalt — Empfänger öffnen Nachrichten Monate oder Jahre nach dem Versand. Die Versionsbindung friert die Icon-Darstellung zum Renderzeitpunkt ein, sodass ein späteres Redesign oder ein Regressionsfehler im Paket bereits zugestellte E-Mails nicht rückwirkend beschädigt. Außerdem entfällt eine 302-Weiterleitung pro Icon und es können langlebige, unveränderliche Cache-Header gesetzt werden. + +**Selbst hosten.** Überschreiben Sie `socialIconsBaseUrl`, um die Assets über Ihr eigenes CDN auszuliefern — nützlich für Air-Gapped-Umgebungen, markenspezifische Themen oder um die Abhängigkeit von unpkg zu entfernen: + +```ts +const mjml = await renderToMjml(content, { + socialIconsBaseUrl: 'https://cdn.example.com/email-assets/social', +}); +``` + +Die exakten Dateinamen, die der Renderer erwartet, sind `{style}/{platform}.png`, wobei `style` einer von `solid | outlined | rounded | square | circle` und `platform` einer von `facebook | twitter | instagram | linkedin | youtube | tiktok | pinterest | email | whatsapp | telegram | discord | snapchat | reddit | github | dribbble | behance` ist. Die ausgelieferten 192×192-PNGs sind ein sinnvoller Ausgangspunkt, wenn Sie sie spiegeln möchten. + +Das Paket exportiert außerdem `DEFAULT_SOCIAL_ICONS_BASE_URL`, falls Sie URLs gegen denselben Standardwert komponieren möchten: + +```ts +import { DEFAULT_SOCIAL_ICONS_BASE_URL } from '@templatical/renderer'; +``` + ## Hilfsfunktionen Der Renderer exportiert außerdem Hilfsfunktionen: @@ -88,13 +118,12 @@ import { convertMergeTagsToValues, isHiddenOnAll, toPaddingString, - generateSocialIconDataUri, renderBlock, getCssClassAttr, getCssClasses, getWidthPercentages, getWidthPixels, - SOCIAL_ICONS, + DEFAULT_SOCIAL_ICONS_BASE_URL, RenderContext, } from '@templatical/renderer'; ``` @@ -154,15 +183,6 @@ toPaddingString({ top: 10, right: 20, bottom: 10, left: 20 }); // '10px 20px 10px 20px' ``` -### `generateSocialIconDataUri(platform, style, size)` - -Erzeugt eine base64-kodierte SVG-Data-URI für ein Social-Media-Plattform-Icon. Wird intern vom Renderer für Social-Icon-Blöcke verwendet: - -```ts -const uri = generateSocialIconDataUri('twitter', 'circle', 32); -// 'data:image/svg+xml,...' -``` - ### `renderBlock(block, context)` Rendert einen einzelnen Block in seine MJML-Darstellung. Wird intern von `renderToMjml()` verwendet, aber für fortgeschrittene Anwendungsfälle exportiert, in denen Sie einzelne Blöcke rendern müssen. @@ -179,9 +199,9 @@ Erzeugen CSS-Klassen-Attribute aus den Sichtbarkeitseinstellungen eines Blocks. Berechnen Spaltenbreiten für ein gegebenes `ColumnLayout`. Gibt ein Array von Prozent- oder Pixelwerten pro Spalte zurück. -### `SOCIAL_ICONS` +### `DEFAULT_SOCIAL_ICONS_BASE_URL` -Eine Zuordnung aller eingebauten SVG-Icon-Daten für soziale Plattformen, nach Plattform und Stil indiziert. +Der Standardwert von `RenderOptions.socialIconsBaseUrl` — die versionsgebundene unpkg-URL, die auf die in diesem Paket mitgelieferten PNGs der Social-Media-Icons verweist. Siehe [Social-Media-Icons](#social-media-icons). ## MJML zu HTML kompilieren diff --git a/apps/playground/.gitignore b/apps/playground/.gitignore new file mode 100644 index 00000000..2eea525d --- /dev/null +++ b/apps/playground/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/packages/editor/src/components/toolbar/SocialToolbar.vue b/packages/editor/src/components/toolbar/SocialToolbar.vue index 5317e4c3..a166bd43 100644 --- a/packages/editor/src/components/toolbar/SocialToolbar.vue +++ b/packages/editor/src/components/toolbar/SocialToolbar.vue @@ -17,6 +17,7 @@ import { import type { SocialIcon, SocialIconsBlock, + SocialIconStyle, SocialPlatform, } from "@templatical/types"; import { generateId } from "@templatical/types"; @@ -74,6 +75,7 @@ function removeSocialIcon(iconId: string): void { >
+ + + + + +
diff --git a/packages/editor/tests/socialToolbar.test.ts b/packages/editor/tests/socialToolbar.test.ts index f4d48448..14882371 100644 --- a/packages/editor/tests/socialToolbar.test.ts +++ b/packages/editor/tests/socialToolbar.test.ts @@ -21,7 +21,7 @@ describe('SocialToolbar CRUD', () => { ]); const wrapper = mountIt(block); - const selects = wrapper.findAll('select'); + const selects = wrapper.findAll('[data-testid="social-platform-select"]'); expect(selects).toHaveLength(2); expect((selects[0].element as HTMLSelectElement).value).toBe('facebook'); expect((selects[1].element as HTMLSelectElement).value).toBe('twitter'); @@ -52,7 +52,7 @@ describe('SocialToolbar CRUD', () => { ]); const wrapper = mountIt(block); - const select = wrapper.findAll('select')[1]; + const select = wrapper.findAll('[data-testid="social-platform-select"]')[1]; (select.element as HTMLSelectElement).value = 'instagram'; await select.trigger('change'); diff --git a/packages/renderer/.gitignore b/packages/renderer/.gitignore new file mode 100644 index 00000000..a2c92486 --- /dev/null +++ b/packages/renderer/.gitignore @@ -0,0 +1 @@ +assets/social/ \ No newline at end of file diff --git a/packages/renderer/package.json b/packages/renderer/package.json index 6899c807..5f1110ca 100644 --- a/packages/renderer/package.json +++ b/packages/renderer/package.json @@ -7,6 +7,7 @@ "@templatical/types": "workspace:*" }, "devDependencies": { + "@resvg/resvg-js": "^2.6.2", "mjml": "^5.1.0", "tsup": "^8.5.1", "typescript": "^6.0.3", @@ -19,7 +20,8 @@ } }, "files": [ - "dist" + "dist", + "assets" ], "homepage": "https://templatical.com", "keywords": [ @@ -41,7 +43,7 @@ "directory": "packages/renderer" }, "scripts": { - "build": "tsup", + "build": "tsup && node scripts/rasterize-social.mjs", "test": "vitest run --config vitest.config.ts", "typecheck": "tsc --noEmit" }, diff --git a/packages/renderer/src/social-icons.ts b/packages/renderer/scripts/rasterize-social.mjs similarity index 82% rename from packages/renderer/src/social-icons.ts rename to packages/renderer/scripts/rasterize-social.mjs index 56b608df..29ae3296 100644 --- a/packages/renderer/src/social-icons.ts +++ b/packages/renderer/scripts/rasterize-social.mjs @@ -1,7 +1,24 @@ -/** - * Social icon SVG path data and brand colors for all supported platforms. - */ -export const SOCIAL_ICONS: Record = { +// Rasterizes the social icon SVG source into PNGs that ship with the npm +// tarball (jsdelivr serves them at the version-pinned URL referenced by +// `DEFAULT_SOCIAL_ICONS_BASE_URL`). Runs as part of `pnpm build`. +// +// PNGs are derived artifacts — not committed to git. SVG path + brand color +// data below is the source of truth. +// +// Outputs `assets/social/{style}/{platform}.png` at 192×192 (8× retina for +// the default 24px display size; scales cleanly to 48px). + +import { renderAsync } from "@resvg/resvg-js"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OUT_DIR = resolve(__dirname, "../assets/social"); +const RENDER_SIZE = 192; +const VIEW_SIZE = 24; + +const SOCIAL_ICONS = { facebook: { path: "M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z", color: "#1877F2", @@ -68,56 +85,77 @@ export const SOCIAL_ICONS: Record = { }, }; -/** - * Generate a base64-encoded SVG data URI for a social icon. - * Ported from SocialIconSvgGenerator.php. - */ -export function generateSocialIconDataUri( - platform: string, - style: string, - size: number, -): string { - const iconData = SOCIAL_ICONS[platform]; - const path = iconData?.path ?? ""; - const brandColor = iconData?.color ?? "#6B7280"; +const STYLES = ["solid", "outlined", "rounded", "square", "circle"]; - if (path === "") { - return ""; - } - - // Only outlined style has transparent bg with colored icon - // All other styles (solid, rounded, square, circle) have colored bg with white icon +function buildSvg(platform, style) { + const { path, color } = SOCIAL_ICONS[platform]; const isOutlined = style === "outlined"; - const iconColor = isOutlined ? brandColor : "#ffffff"; + const iconColor = isOutlined ? color : "#ffffff"; - // Border radius values proportional to editor (based on 24x24 viewBox) - let bgShape: string; + let bgShape; switch (style) { case "circle": - bgShape = ``; + bgShape = ``; break; case "rounded": - bgShape = ``; + bgShape = ``; break; case "square": - bgShape = ``; + bgShape = ``; break; case "outlined": - bgShape = ``; + bgShape = ``; break; default: - bgShape = ``; + bgShape = ``; break; } - // Icon size = 60% of container (matching editor's Math.floor(size * 0.6)) - // In 24x24 viewBox: 60% = 14.4, so translate by (24-14.4)/2 = 4.8 and scale by 0.6 - const svg = - `` + + return ( + `` + bgShape + `` + `` + - ``; + `` + ); +} - return "data:image/svg+xml;base64," + btoa(svg); +async function renderOne(platform, style, styleDir) { + const svg = buildSvg(platform, style); + const rendered = await renderAsync(svg, { + fitTo: { mode: "width", value: RENDER_SIZE }, + shapeRendering: 2, + textRendering: 2, + imageRendering: 0, + }); + await writeFile(resolve(styleDir, `${platform}.png`), rendered.asPng()); } + +async function main() { + const start = performance.now(); + await rm(OUT_DIR, { recursive: true, force: true }); + + await Promise.all( + STYLES.map((style) => + mkdir(resolve(OUT_DIR, style), { recursive: true }), + ), + ); + + const platforms = Object.keys(SOCIAL_ICONS); + const jobs = []; + for (const style of STYLES) { + const styleDir = resolve(OUT_DIR, style); + for (const platform of platforms) { + jobs.push(renderOne(platform, style, styleDir)); + } + } + await Promise.all(jobs); + + const ms = Math.round(performance.now() - start); + console.log(`rasterize-social: wrote ${jobs.length} PNGs in ${ms}ms`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts index abeb3556..81f31a2d 100644 --- a/packages/renderer/src/index.ts +++ b/packages/renderer/src/index.ts @@ -5,7 +5,7 @@ import type { CustomFont, } from "@templatical/types"; import { isSection, isCustomBlock } from "@templatical/types"; -import { RenderContext } from "./render-context"; +import { RenderContext, DEFAULT_SOCIAL_ICONS_BASE_URL } from "./render-context"; import { renderBlock } from "./renderers"; import { escapeHtml, escapeAttr } from "./escape"; @@ -28,6 +28,17 @@ export interface RenderOptions { * output. */ renderCustomBlock?: (block: CustomBlock) => Promise; + /** + * Base URL (no trailing slash) for the social icon PNG assets. Resolved to + * `${baseUrl}/${style}/${platform}.png` per icon. Defaults to the + * version-pinned unpkg mirror of this package. Override to self-host + * (e.g., behind your own CDN or for air-gapped environments). + * + * Why PNGs: Outlook desktop (Word rendering engine) does not support SVG + * and rejects base64 data URIs in ``, so social icons must be + * served as raster images over HTTP for cross-client compatibility. + */ + socialIconsBaseUrl?: string; } /** @@ -47,6 +58,9 @@ export async function renderToMjml( const defaultFallbackFont = options?.defaultFallbackFont ?? "Arial, sans-serif"; const allowHtmlBlocks = options?.allowHtmlBlocks ?? true; + const socialIconsBaseUrl = stripTrailingSlash( + options?.socialIconsBaseUrl ?? DEFAULT_SOCIAL_ICONS_BASE_URL, + ); const customBlockHtml = await resolveCustomBlocks( content, @@ -59,6 +73,7 @@ export async function renderToMjml( defaultFallbackFont, allowHtmlBlocks, customBlockHtml, + socialIconsBaseUrl, ); const blocks = filterHtmlBlocks(content.blocks, allowHtmlBlocks); @@ -158,6 +173,10 @@ ${content} `; } +function stripTrailingSlash(url: string): string { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + function generatePreviewTag(preheaderText?: string): string { if (!preheaderText) { return ""; @@ -257,6 +276,6 @@ export { escapeHtml, escapeAttr, convertMergeTagsToValues } from "./escape"; export { isHiddenOnAll, getCssClassAttr, getCssClasses } from "./visibility"; export { getWidthPercentages, getWidthPixels } from "./columns"; export { toPaddingString } from "./padding"; -export { SOCIAL_ICONS, generateSocialIconDataUri } from "./social-icons"; +export { DEFAULT_SOCIAL_ICONS_BASE_URL } from "./render-context"; export { renderBlock } from "./renderers"; export type { BlockRenderer } from "./renderers/section"; diff --git a/packages/renderer/src/render-context.ts b/packages/renderer/src/render-context.ts index 513f3523..ab5585f2 100644 --- a/packages/renderer/src/render-context.ts +++ b/packages/renderer/src/render-context.ts @@ -1,4 +1,7 @@ import type { CustomFont } from "@templatical/types"; +import pkg from "../package.json" with { type: "json" }; + +export const DEFAULT_SOCIAL_ICONS_BASE_URL = `https://unpkg.com/@templatical/renderer@${pkg.version}/assets/social`; const BUILT_IN_FONT_FALLBACKS: Record = { arial: "Arial, sans-serif", @@ -26,6 +29,14 @@ export class RenderContext { * `renderCustomBlock` option. Empty by default. */ public readonly customBlockHtml: ReadonlyMap = new Map(), + /** + * Base URL (no trailing slash) for the social icon PNG assets. Resolved to + * `${baseUrl}/${style}/${platform}.png`. Outlook desktop has no SVG support + * and rejects base64 data URIs in ``, so PNGs must be served over + * HTTP. Default points at the version-pinned unpkg mirror of this + * package; consumers can override to self-host. + */ + public readonly socialIconsBaseUrl: string = DEFAULT_SOCIAL_ICONS_BASE_URL, ) {} /** @@ -39,6 +50,7 @@ export class RenderContext { this.defaultFallbackFont, this.allowHtmlBlocks, this.customBlockHtml, + this.socialIconsBaseUrl, ); } diff --git a/packages/renderer/src/renderers/social.ts b/packages/renderer/src/renderers/social.ts index e06daa44..bdb0cbe8 100644 --- a/packages/renderer/src/renderers/social.ts +++ b/packages/renderer/src/renderers/social.ts @@ -3,14 +3,21 @@ import type { RenderContext } from "../render-context"; import { escapeAttr } from "../escape"; import { toPaddingString } from "../padding"; import { isHiddenOnAll, getCssClassAttr } from "../visibility"; -import { generateSocialIconDataUri } from "../social-icons"; /** * Render a social icons block to MJML markup. + * + * Icons are emitted as `` rather than + * inline SVG or base64 data URIs. Outlook desktop (Word rendering engine) + * does not support SVG and rejects base64 in ``, so hosted PNGs are + * the only format that renders across every mainstream client. The base URL + * is read from `context.socialIconsBaseUrl` (configurable via + * `RenderOptions.socialIconsBaseUrl`; default is the version-pinned unpkg + * mirror of this package). */ export function renderSocialIcons( block: SocialIconsBlock, - _context: RenderContext, + context: RenderContext, ): string { if (isHiddenOnAll(block)) { return ""; @@ -66,9 +73,7 @@ export function renderSocialIcons( const socialElements = icons.map((icon, index) => { const platform = icon.platform; const url = escapeAttr(icon.url); - - // Generate custom SVG icon as data URI to match editor appearance - const iconSrc = generateSocialIconDataUri(platform, iconStyle, iconSizePx); + const iconSrc = `${context.socialIconsBaseUrl}/${iconStyle}/${platform}.png`; // Apply spacing as right padding only (except last icon) to match CSS gap behavior const rightPad = index === iconCount - 1 ? 0 : spacing; diff --git a/packages/renderer/tests/mjml-bg-roundtrip.test.ts b/packages/renderer/tests/mjml-bg-roundtrip.test.ts index 6c03e22f..45b31236 100644 --- a/packages/renderer/tests/mjml-bg-roundtrip.test.ts +++ b/packages/renderer/tests/mjml-bg-roundtrip.test.ts @@ -245,6 +245,37 @@ describe("background-color round-trip through MJML compiler", () => { }); }); +describe("social icons render as hosted , not inline SVG (Outlook safety)", () => { + /** + * Outlook desktop (Word rendering engine) does not support SVG and rejects + * base64 data URIs in ``. The renderer must emit hosted PNG URLs + * so the compiled email HTML uses `` for social icons. + * If a future change reverts to inline SVG or data URIs, this test fails. + */ + it("compiled HTML uses tags with HTTP PNG URLs for social icons", async () => { + const block = createSocialIconsBlock({ + icons: [ + { platform: "facebook", url: "https://facebook.com" }, + { platform: "twitter", url: "https://twitter.com" }, + ], + iconStyle: "circle", + }); + const mjml = renderBlock(block, ctx); + const html = await compile(wrapBlock(mjml)); + + expect(html).toContain( + 'src="https://unpkg.com/@templatical/renderer@', + ); + expect(html).toContain("/circle/facebook.png"); + expect(html).toContain("/circle/twitter.png"); + + expect(html).not.toMatch(/]/i); + expect(html).not.toContain(""); + expect(html).not.toContain("data:image/svg"); + expect(html).not.toContain("data:image/png;base64"); + }); +}); + describe("MJML silent-drop trap (regression baseline)", () => { /** * Documents the actual MJML behavior we're protecting against: passing diff --git a/packages/renderer/tests/render-to-mjml.test.ts b/packages/renderer/tests/render-to-mjml.test.ts index b1143c64..46cb39cd 100644 --- a/packages/renderer/tests/render-to-mjml.test.ts +++ b/packages/renderer/tests/render-to-mjml.test.ts @@ -4,8 +4,9 @@ import { createParagraphBlock, createSectionBlock, createImageBlock, + createSocialIconsBlock, } from '@templatical/types'; -import { renderToMjml } from '../src'; +import { renderToMjml, DEFAULT_SOCIAL_ICONS_BASE_URL } from '../src'; describe('renderToMjml', () => { it('renders empty template', async () => { @@ -228,6 +229,53 @@ describe('renderToMjml', () => { expect(mjml).toContain('role="presentation"'); }); + it('uses default unpkg URL for social icons', async () => { + const content = createDefaultTemplateContent(); + content.blocks = [ + createSocialIconsBlock({ + icons: [{ platform: 'facebook', url: 'https://facebook.com' }], + iconStyle: 'circle', + }), + ]; + const mjml = await renderToMjml(content); + expect(mjml).toContain( + `src="${DEFAULT_SOCIAL_ICONS_BASE_URL}/circle/facebook.png"`, + ); + }); + + it('honors socialIconsBaseUrl option', async () => { + const content = createDefaultTemplateContent(); + content.blocks = [ + createSocialIconsBlock({ + icons: [{ platform: 'twitter', url: 'https://twitter.com' }], + iconStyle: 'solid', + }), + ]; + const mjml = await renderToMjml(content, { + socialIconsBaseUrl: 'https://cdn.example.com/social', + }); + expect(mjml).toContain( + 'src="https://cdn.example.com/social/solid/twitter.png"', + ); + }); + + it('strips trailing slash from socialIconsBaseUrl', async () => { + const content = createDefaultTemplateContent(); + content.blocks = [ + createSocialIconsBlock({ + icons: [{ platform: 'github', url: 'https://github.com' }], + iconStyle: 'square', + }), + ]; + const mjml = await renderToMjml(content, { + socialIconsBaseUrl: 'https://cdn.example.com/social/', + }); + expect(mjml).toContain( + 'src="https://cdn.example.com/social/square/github.png"', + ); + expect(mjml).not.toContain('//square/'); + }); + it('renders non-decorative image preserving alt and omitting role', async () => { const content = createDefaultTemplateContent(); content.blocks = [ diff --git a/packages/renderer/tests/social-icons.test.ts b/packages/renderer/tests/social-icons.test.ts deleted file mode 100644 index 1460aa66..00000000 --- a/packages/renderer/tests/social-icons.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { SOCIAL_ICONS, generateSocialIconDataUri } from '../src/social-icons'; - -describe('generateSocialIconDataUri', () => { - it('returns a valid data URI format for a known platform', () => { - const result = generateSocialIconDataUri('facebook', 'solid', 32); - expect(result).toMatch(/^data:image\/svg\+xml;base64,/); - }); - - it('returns empty string for an unknown platform', () => { - const result = generateSocialIconDataUri('nonexistent', 'solid', 32); - expect(result).toBe(''); - }); - - it('generates solid style with rect background and rx=3', () => { - const result = generateSocialIconDataUri('facebook', 'solid', 32); - const svg = atob(result.replace('data:image/svg+xml;base64,', '')); - expect(svg).toContain('rx="3"'); - expect(svg).toContain('fill="#1877F2"'); - expect(svg).toContain('fill="#ffffff"'); - }); - - it('generates circle style with circle element', () => { - const result = generateSocialIconDataUri('twitter', 'circle', 32); - const svg = atob(result.replace('data:image/svg+xml;base64,', '')); - expect(svg).toContain(' { - const result = generateSocialIconDataUri('instagram', 'rounded', 32); - const svg = atob(result.replace('data:image/svg+xml;base64,', '')); - expect(svg).toContain('rx="6"'); - }); - - it('generates square style with rx=0', () => { - const result = generateSocialIconDataUri('linkedin', 'square', 32); - const svg = atob(result.replace('data:image/svg+xml;base64,', '')); - expect(svg).toContain('rx="0"'); - }); - - it('generates outlined style with transparent fill and stroke', () => { - const result = generateSocialIconDataUri('youtube', 'outlined', 32); - const svg = atob(result.replace('data:image/svg+xml;base64,', '')); - expect(svg).toContain('fill="transparent"'); - expect(svg).toContain('stroke="#FF0000"'); - // Outlined uses brand color for icon, not white - expect(svg).toContain(`fill="#FF0000"`); - }); - - it('falls back to solid-like style for unknown style string', () => { - const result = generateSocialIconDataUri('facebook', 'unknown-style', 32); - const svg = atob(result.replace('data:image/svg+xml;base64,', '')); - // Default case: rect with rx="3" - expect(svg).toContain('rx="3"'); - expect(svg).toContain('fill="#ffffff"'); - }); - - it('includes the size parameter in the SVG width and height', () => { - const result = generateSocialIconDataUri('facebook', 'solid', 48); - const svg = atob(result.replace('data:image/svg+xml;base64,', '')); - expect(svg).toContain('width="48"'); - expect(svg).toContain('height="48"'); - }); - - it('generates a data URI for all known platforms', () => { - const platforms = Object.keys(SOCIAL_ICONS); - expect(platforms.length).toBeGreaterThan(0); - - for (const platform of platforms) { - const result = generateSocialIconDataUri(platform, 'solid', 32); - expect(result).toMatch(/^data:image\/svg\+xml;base64,/); - } - }); - - it('decoded SVG contains the icon path data', () => { - const result = generateSocialIconDataUri('facebook', 'solid', 32); - const svg = atob(result.replace('data:image/svg+xml;base64,', '')); - expect(svg).toContain(SOCIAL_ICONS.facebook.path); - }); - - it('uses the correct brand color per platform', () => { - const result = generateSocialIconDataUri('instagram', 'solid', 32); - const svg = atob(result.replace('data:image/svg+xml;base64,', '')); - expect(svg).toContain(`fill="${SOCIAL_ICONS.instagram.color}"`); - }); - - it('includes transform for icon scaling', () => { - const result = generateSocialIconDataUri('github', 'solid', 32); - const svg = atob(result.replace('data:image/svg+xml;base64,', '')); - expect(svg).toContain('translate(4.8, 4.8) scale(0.6)'); - }); -}); diff --git a/packages/renderer/tests/social.test.ts b/packages/renderer/tests/social.test.ts index 319a9f2e..541e4694 100644 --- a/packages/renderer/tests/social.test.ts +++ b/packages/renderer/tests/social.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import { createSocialIconsBlock } from "@templatical/types"; -import { renderBlock, RenderContext } from "../src"; +import { + renderBlock, + RenderContext, + DEFAULT_SOCIAL_ICONS_BASE_URL, +} from "../src"; const ctx = new RenderContext(600, [], "Arial, sans-serif", true); @@ -123,6 +127,99 @@ describe("renderSocialIcons", () => { expect(result).toContain('container-background-color="#ff0000"'); }); + it("emits PNG asset URL with style/platform path, not data URI", () => { + const block = createSocialIconsBlock({ + icons: [{ platform: "facebook", url: "https://facebook.com" }], + iconStyle: "circle", + }); + const result = renderBlock(block, ctx); + expect(result).toContain( + `src="${DEFAULT_SOCIAL_ICONS_BASE_URL}/circle/facebook.png"`, + ); + expect(result).not.toContain("data:"); + }); + + it("emits no inline SVG markup", () => { + const block = createSocialIconsBlock({ + icons: [ + { platform: "facebook", url: "https://facebook.com" }, + { platform: "twitter", url: "https://twitter.com" }, + ], + iconStyle: "circle", + }); + const result = renderBlock(block, ctx); + expect(result).not.toMatch(/]/i); + expect(result).not.toContain(""); + expect(result).not.toContain("xmlns=\"http://www.w3.org/2000/svg\""); + expect(result).not.toContain("image/svg+xml"); + }); + + it("default base URL points at version-pinned unpkg mirror", () => { + expect(DEFAULT_SOCIAL_ICONS_BASE_URL).toMatch( + /^https:\/\/unpkg\.com\/@templatical\/renderer@\d+\.\d+\.\d+\/assets\/social$/, + ); + }); + + it("honors a custom socialIconsBaseUrl", () => { + const custom = new RenderContext( + 600, + [], + "Arial, sans-serif", + true, + new Map(), + "https://cdn.example.com/icons", + ); + const block = createSocialIconsBlock({ + icons: [{ platform: "twitter", url: "https://twitter.com" }], + iconStyle: "rounded", + }); + const result = renderBlock(block, custom); + expect(result).toContain( + 'src="https://cdn.example.com/icons/rounded/twitter.png"', + ); + }); + + it("emits URL for every supported platform and style", () => { + const platforms = [ + "facebook", + "twitter", + "instagram", + "linkedin", + "youtube", + "tiktok", + "pinterest", + "email", + "whatsapp", + "telegram", + "discord", + "snapchat", + "reddit", + "github", + "dribbble", + "behance", + ] as const; + const styles = [ + "solid", + "outlined", + "rounded", + "square", + "circle", + ] as const; + + for (const style of styles) { + for (const platform of platforms) { + const block = createSocialIconsBlock({ + icons: [{ platform, url: `https://${platform}.example` }], + iconStyle: style, + }); + const result = renderBlock(block, ctx); + expect(result).toContain( + `src="${DEFAULT_SOCIAL_ICONS_BASE_URL}/${style}/${platform}.png"`, + ); + } + } + }); + it("escapes special characters in URL href", () => { const block = createSocialIconsBlock({ icons: [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a179e4e..623d6e14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -475,6 +475,9 @@ importers: specifier: workspace:* version: link:../types devDependencies: + '@resvg/resvg-js': + specifier: ^2.6.2 + version: 2.6.2 mjml: specifier: ^5.1.0 version: 5.1.0(relateurl@0.2.7)(svgo@4.0.1)(terser@5.46.2)(typescript@6.0.3) @@ -1264,6 +1267,86 @@ packages: engines: {node: '>=18'} hasBin: true + '@resvg/resvg-js-android-arm-eabi@2.6.2': + resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@resvg/resvg-js-android-arm64@2.6.2': + resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@resvg/resvg-js-darwin-arm64@2.6.2': + resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@resvg/resvg-js-darwin-x64@2.6.2': + resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@resvg/resvg-js@2.6.2': + resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} + engines: {node: '>= 10'} + '@rolldown/binding-android-arm64@1.0.0-rc.17': resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5606,6 +5689,57 @@ snapshots: dependencies: playwright: 1.59.1 + '@resvg/resvg-js-android-arm-eabi@2.6.2': + optional: true + + '@resvg/resvg-js-android-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-x64@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js@2.6.2': + optionalDependencies: + '@resvg/resvg-js-android-arm-eabi': 2.6.2 + '@resvg/resvg-js-android-arm64': 2.6.2 + '@resvg/resvg-js-darwin-arm64': 2.6.2 + '@resvg/resvg-js-darwin-x64': 2.6.2 + '@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2 + '@resvg/resvg-js-linux-arm64-gnu': 2.6.2 + '@resvg/resvg-js-linux-arm64-musl': 2.6.2 + '@resvg/resvg-js-linux-x64-gnu': 2.6.2 + '@resvg/resvg-js-linux-x64-musl': 2.6.2 + '@resvg/resvg-js-win32-arm64-msvc': 2.6.2 + '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 + '@resvg/resvg-js-win32-x64-msvc': 2.6.2 + '@rolldown/binding-android-arm64@1.0.0-rc.17': optional: true