Bound style components and unify button style validation
This commit is contained in:
1 parent
14669bb4f3
commit
1f90e01e6e
4 files changed
+38
-9
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/email-renderer",
|
"name": "@wrnexus/email-renderer",
|
||||||
"version": "0.1.42",
|
"version": "0.1.43",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
|
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -107,22 +107,22 @@ const semanticStyle = (node: BlockNode) => {
|
|||||||
return declarations.join(";");
|
return declarations.join(";");
|
||||||
};
|
};
|
||||||
const buttonBackground = (node: BlockNode, context: unknown) => {
|
const buttonBackground = (node: BlockNode, context: unknown) => {
|
||||||
const override = safeCssValue(styleBase(node, "backgroundColor"), /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i);
|
const override = emailStyleValue("backgroundColor", styleBase(node, "backgroundColor"));
|
||||||
const brand = (context as { brandLinkColor?: string } | undefined)?.brandLinkColor;
|
const brand = (context as { brandLinkColor?: string } | undefined)?.brandLinkColor;
|
||||||
return override || (brand && brandColor("#2563eb").parse(brand).ok ? brand : "#2563eb");
|
return override || (brand && brandColor("#2563eb").parse(brand).ok ? brand : "#2563eb");
|
||||||
};
|
};
|
||||||
const buttonVisuals = (node: BlockNode) => ({
|
const buttonVisuals = (node: BlockNode) => ({
|
||||||
color: safeCssValue(styleBase(node, "color"), /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i) || "#ffffff",
|
color: emailStyleValue("color", styleBase(node, "color")) || "#ffffff",
|
||||||
fontSize: safeCssValue(styleBase(node, "fontSize"), /^\d+(?:\.\d+)?(?:px|rem|em|%)$/) || "16px",
|
fontSize: emailStyleValue("fontSize", styleBase(node, "fontSize")) || "16px",
|
||||||
fontWeight: safeCssValue(styleBase(node, "fontWeight"), /^(normal|bold|[1-9]00)$/) || "700",
|
fontWeight: emailStyleValue("fontWeight", styleBase(node, "fontWeight")) || "700",
|
||||||
radius: safeCssValue(styleBase(node, "borderRadius"), /^\d+(?:\.\d+)?(?:px|rem|em|%)$/) || "6px",
|
radius: emailStyleValue("borderRadius", styleBase(node, "borderRadius")) || "6px",
|
||||||
});
|
});
|
||||||
const buttonVisualCss = (node: BlockNode) => {
|
const buttonVisualCss = (node: BlockNode) => {
|
||||||
const values = buttonVisuals(node);
|
const values = buttonVisuals(node);
|
||||||
return `color:${values.color};font-size:${values.fontSize};font-weight:${values.fontWeight};border-radius:${values.radius}`;
|
return `color:${values.color};font-size:${values.fontSize};font-weight:${values.fontWeight};border-radius:${values.radius}`;
|
||||||
};
|
};
|
||||||
const buttonAlignment = (node: BlockNode, context: unknown) => {
|
const buttonAlignment = (node: BlockNode, context: unknown) => {
|
||||||
const value = safeCssValue(styleBase(node, "textAlign"), /^(left|center|right|start|end)$/) || "start";
|
const value = emailStyleValue("textAlign", styleBase(node, "textAlign")) || "start";
|
||||||
const rtl = (context as { direction?: string } | undefined)?.direction === "rtl";
|
const rtl = (context as { direction?: string } | undefined)?.direction === "rtl";
|
||||||
return value === "start" ? (rtl ? "right" : "left") : value === "end" ? (rtl ? "left" : "right") : value;
|
return value === "start" ? (rtl ? "right" : "left") : value === "end" ? (rtl ? "left" : "right") : value;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const color = /^(#[0-9a-f]{3}|#[0-9a-f]{4}|#[0-9a-f]{6}|#[0-9a-f]{8}|rgba?\([0-9., %]+\))$/i;
|
const color = /^(#[0-9a-f]{3}|#[0-9a-f]{4}|#[0-9a-f]{6}|#[0-9a-f]{8}|transparent|rgba?\([0-9., %]+\))$/i;
|
||||||
const spacing = /^(?:0|\d+(?:\.\d+)?(?:px|rem|em|%))(?:\s+(?:0|\d+(?:\.\d+)?(?:px|rem|em|%))){0,3}$/;
|
const spacing = /^(?:0|\d+(?:\.\d+)?(?:px|rem|em|%))(?:\s+(?:0|\d+(?:\.\d+)?(?:px|rem|em|%))){0,3}$/;
|
||||||
const patterns: Record<string, RegExp> = {
|
const patterns: Record<string, RegExp> = {
|
||||||
color, backgroundColor: color, padding: spacing, margin: spacing,
|
color, backgroundColor: color, padding: spacing, margin: spacing,
|
||||||
@@ -8,11 +8,30 @@ const patterns: Record<string, RegExp> = {
|
|||||||
fontWeight: /^(normal|bold|[1-9]00)$/,
|
fontWeight: /^(normal|bold|[1-9]00)$/,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function validColor(value: string): boolean {
|
||||||
|
if (!/^rgba?\(/i.test(value)) return true;
|
||||||
|
const match = /^(rgb|rgba)\((.*)\)$/i.exec(value)!;
|
||||||
|
const channels = match[2]!.split(",").map(part => part.trim());
|
||||||
|
if (channels.length !== (match[1]!.toLowerCase() === "rgba" ? 4 : 3)) return false;
|
||||||
|
const numeric = /^(?:\d+(?:\.\d+)?|\.\d+)%?$/;
|
||||||
|
if (!channels.every(channel => numeric.test(channel))) return false;
|
||||||
|
const percentages = channels.slice(0, 3).filter(channel => channel.endsWith("%")).length;
|
||||||
|
if (percentages !== 0 && percentages !== 3) return false;
|
||||||
|
return channels.every((channel, index) => {
|
||||||
|
const maximum = channel.endsWith("%") ? 100 : index === 3 ? 1 : 255;
|
||||||
|
return Number.parseFloat(channel) <= maximum;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Accepted base-value syntax shared by the inspector and semantic style compiler. */
|
/** Accepted base-value syntax shared by the inspector and semantic style compiler. */
|
||||||
export function emailStyleValue(key: string, value: unknown): string {
|
export function emailStyleValue(key: string, value: unknown): string {
|
||||||
if (typeof value !== "string") return "";
|
if (typeof value !== "string") return "";
|
||||||
const candidate = value.trim();
|
const candidate = value.trim();
|
||||||
return candidate.length <= 80 && Object.prototype.hasOwnProperty.call(patterns, key) && patterns[key]!.test(candidate) ? candidate : "";
|
if (candidate.length > 80 || !Object.prototype.hasOwnProperty.call(patterns, key) || !patterns[key]!.test(candidate)) return "";
|
||||||
|
if ((key === "color" || key === "backgroundColor") && !validColor(candidate)) return "";
|
||||||
|
if (["padding", "margin", "fontSize", "borderRadius"].includes(key) &&
|
||||||
|
candidate.split(/\s+/).some(part => Number.parseFloat(part) > 4000)) return "";
|
||||||
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateEmailStyleValue(key: string, value: string): void {
|
export function validateEmailStyleValue(key: string, value: string): void {
|
||||||
|
|||||||
@@ -11,3 +11,13 @@ test("base style syntax rejects ignored or injected values and accepts supported
|
|||||||
expect(() => validateEmailStyleValue(key!, value!)).not.toThrow();
|
expect(() => validateEmailStyleValue(key!, value!)).not.toThrow();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("RGB functions require correct channel counts, units and bounded components", () => {
|
||||||
|
for (const value of ["rgb(1)", "rgb(1,2,3,4)", "rgba(1,2,3)", "rgb(256,0,0)", "rgb(100%,0,0)", "rgba(1,2,3,1.1)", "rgb(1..2,2,3)", "rgba(0,0,0,101%)"])
|
||||||
|
expect(emailStyleValue("color", value)).toBe("");
|
||||||
|
for (const value of ["rgb(255, 0, 12)", "rgb(100%, 0%, 50%)", "rgba(0,0,0,.5)", "rgba(0,0,0,50%)", "transparent"])
|
||||||
|
expect(emailStyleValue("backgroundColor", value)).toBe(value);
|
||||||
|
expect(emailStyleValue("padding", "0 4000px")).toBe("0 4000px");
|
||||||
|
expect(emailStyleValue("padding", "0 4001px")).toBe("");
|
||||||
|
expect(emailStyleValue("fontSize", "999999999999999999999px")).toBe("");
|
||||||
|
});
|
||||||
Reference in new issue
Block a user