diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index 326df046..346d71e0 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -1,12 +1,13 @@ { "name": "@wrnexus/email-renderer", - "version": "0.1.41", + "version": "0.1.42", "type": "module", "description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.", "exports": { ".": "./src/index.ts", "./blocks": "./src/blocks.ts", - "./create": "./src/create.ts" + "./create": "./src/create.ts", + "./style-values": "./src/style-values.ts" }, "files": [ "src", diff --git a/packages/email-renderer/src/blocks.ts b/packages/email-renderer/src/blocks.ts index d736e436..40258f9b 100644 --- a/packages/email-renderer/src/blocks.ts +++ b/packages/email-renderer/src/blocks.ts @@ -12,6 +12,7 @@ import { type ValueSchema, } from "@wrnexus/blocks"; import { brandColor, brandFont, brandWidth } from "./brand.ts"; +import { emailStyleValue } from "./style-values.ts"; const styles = record(json, { maxKeys: 50 }) as never; const label = (key: string, fallback: string) => ({ key, fallback }); @@ -87,14 +88,14 @@ const safeCssValue = (value: unknown, pattern: RegExp) => { }; const semanticStyle = (node: BlockNode) => { const declarations: string[] = []; - const color = safeCssValue(styleBase(node, "color"), /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i); - const background = safeCssValue(styleBase(node, "backgroundColor"), /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i); - const padding = safeCssValue(styleBase(node, "padding"), /^\d+(?:\.\d+)?(?:px|rem|em|%)?(?:\s+\d+(?:\.\d+)?(?:px|rem|em|%)?){0,3}$/); - const margin = safeCssValue(styleBase(node, "margin"), /^\d+(?:\.\d+)?(?:px|rem|em|%)?(?:\s+\d+(?:\.\d+)?(?:px|rem|em|%)?){0,3}$/); - const fontSize = safeCssValue(styleBase(node, "fontSize"), /^\d+(?:\.\d+)?(?:px|rem|em|%)$/); - const radius = safeCssValue(styleBase(node, "borderRadius"), /^\d+(?:\.\d+)?(?:px|rem|em|%)$/); - const align = safeCssValue(styleBase(node, "textAlign"), /^(left|center|right|start|end)$/); - const weight = safeCssValue(styleBase(node, "fontWeight"), /^(normal|bold|[1-9]00)$/); + const color = emailStyleValue("color", styleBase(node, "color")); + const background = emailStyleValue("backgroundColor", styleBase(node, "backgroundColor")); + const padding = emailStyleValue("padding", styleBase(node, "padding")); + const margin = emailStyleValue("margin", styleBase(node, "margin")); + const fontSize = emailStyleValue("fontSize", styleBase(node, "fontSize")); + const radius = emailStyleValue("borderRadius", styleBase(node, "borderRadius")); + const align = emailStyleValue("textAlign", styleBase(node, "textAlign")); + const weight = emailStyleValue("fontWeight", styleBase(node, "fontWeight")); if (color) declarations.push(`color:${color}`); if (background) declarations.push(`background-color:${background}`); if (padding) declarations.push(`padding:${padding}`); diff --git a/packages/email-renderer/src/style-values.ts b/packages/email-renderer/src/style-values.ts new file mode 100644 index 00000000..3de1fc2a --- /dev/null +++ b/packages/email-renderer/src/style-values.ts @@ -0,0 +1,20 @@ +const color = /^(#[0-9a-f]{3}|#[0-9a-f]{4}|#[0-9a-f]{6}|#[0-9a-f]{8}|rgba?\([0-9., %]+\))$/i; +const spacing = /^(?:0|\d+(?:\.\d+)?(?:px|rem|em|%))(?:\s+(?:0|\d+(?:\.\d+)?(?:px|rem|em|%))){0,3}$/; +const patterns: Record = { + color, backgroundColor: color, padding: spacing, margin: spacing, + fontSize: /^\d+(?:\.\d+)?(?:px|rem|em|%)$/, + borderRadius: /^(?:0|\d+(?:\.\d+)?(?:px|rem|em|%))$/, + textAlign: /^(left|center|right|start|end)$/, + fontWeight: /^(normal|bold|[1-9]00)$/, +}; + +/** Accepted base-value syntax shared by the inspector and semantic style compiler. */ +export function emailStyleValue(key: string, value: unknown): string { + if (typeof value !== "string") return ""; + const candidate = value.trim(); + return candidate.length <= 80 && Object.prototype.hasOwnProperty.call(patterns, key) && patterns[key]!.test(candidate) ? candidate : ""; +} + +export function validateEmailStyleValue(key: string, value: string): void { + if (!emailStyleValue(key, value)) throw new Error(`styles.${key}: Unsupported style value. Use an email-safe value with units where required.`); +} diff --git a/packages/email-renderer/test/style-values.test.ts b/packages/email-renderer/test/style-values.test.ts new file mode 100644 index 00000000..ff9e6623 --- /dev/null +++ b/packages/email-renderer/test/style-values.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from "bun:test"; +import { emailStyleValue, validateEmailStyleValue } from "../src/style-values.ts"; + +test("base style syntax rejects ignored or injected values and accepts supported units", () => { + for (const [key, value] of [["padding","12"],["padding","1px 2px 3px 4px 5px"],["color","#12345"],["fontWeight","heavy"],["fontSize","12px;color:red"],["position","fixed"],["constructor","x"]]) { + expect(emailStyleValue(key!, value)).toBe(""); + expect(() => validateEmailStyleValue(key!, value!)).toThrow(); + } + for (const [key, value] of [["padding","0 12px"],["margin","1rem 2%"],["color","#1234"],["fontWeight","700"],["fontSize","16px"],["borderRadius","0"],["textAlign","end"]]) { + expect(emailStyleValue(key!, value)).toBe(value!); + expect(() => validateEmailStyleValue(key!, value!)).not.toThrow(); + } +});