/** Container tags an app may widen with `allowedStyles`. */ const EXTENSION_TAGS = new Set(['td', 'th', 'table', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']); /** Properties an app can never allow, whatever its config. */ export const EXTENSION_DENYLIST = new Set([ 'position', 'top', 'right', 'bottom', 'left', 'inset', 'inset-block', 'inset-block-start', 'inset-block-end', 'inset-inline', 'inset-inline-start', 'inset-inline-end', 'z-index', 'content', 'behavior', '-moz-binding', 'transform', 'transform-origin', 'transform-box', 'filter', 'backdrop-filter', 'perspective', 'perspective-origin', 'display', 'visibility', 'opacity', 'pointer-events', 'clip', 'clip-path', 'mask', 'mask-image', 'mask-repeat', 'mask-position', 'mask-size', 'animation', 'animation-name', 'transition', 'transition-property', 'will-change', 'cursor', 'float', 'clear', 'overflow', 'overflow-x', 'overflow-y', 'background', 'background-image', 'list-style-image', 'border-image', 'border-image-source', ]); /** Properties a built-in fidelity path already handles; kept in sync with `sanitize.ts`. */ const BUILTIN_HANDLED = new Set([ // ALLOWED_STYLES 'color', 'background-color', 'font-family', 'font-size', 'font-style', 'font-weight', 'text-align', 'text-decoration', 'text-decoration-line', 'vertical-align', 'margin-left', 'margin-inline-start', 'list-style-type', 'line-height', // CONTAINER_DROP 'text-decoration-style', 'text-decoration-color', // CELL_LAYOUT 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'width', 'border', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-radius', // TABLE_LAYOUT 'max-width', 'margin-right', // BLOCK_LAYOUT 'margin', 'margin-top', 'margin-bottom', ]); /** Lowercase name (optionally `mso-`), not denylisted and not built in. */ export function isExtensionPropValid(name: string): boolean { if (!/^(?:mso-)?[a-z]+(?:-[a-z]+)*$/.test(name)) return false; if (EXTENSION_DENYLIST.has(name)) return false; if (BUILTIN_HANDLED.has(name)) return false; return true; } /** Strict value grammar: no functions except numeric colours, no escape characters. */ export function isExtensionValueValid(value: string): boolean { const v = value.trim(); if (!v || v.length > 128) return false; // eslint-disable-next-line no-control-regex if (/[^\x00-\x7f]/.test(v)) return false; if (/\b(?:url|expression|var|calc|attr|env|image-set)\b/i.test(v)) return false; if (/[\\<>{};@]/.test(v)) return false; if (/\/\*/.test(v)) return false; if (/\//.test(v)) return false; if (/!important/i.test(v)) return false; if (/[()]/.test(v)) { if ((v.match(/\(/g) ?? []).length !== 1) return false; const fn = /(?:rgba?|hsla?)\(\s*[\d.]+%?(?:\s*,\s*[\d.]+%?){2,3}\s*\)/i.exec(v); if (!fn || /[()]/.test(v.replace(fn[0], ''))) return false; } return true; } /** tag → set of accepted extension property names. */ export type NormalizedAllowedStyles = Map>; /** Validated tag → props map; warns once per dropped entry. */ export function normalizeEmailAllowedStyles( config: Record ): NormalizedAllowedStyles { const out: NormalizedAllowedStyles = new Map(); if (!config || typeof config !== 'object') return out; for (const [rawTag, rawProps] of Object.entries(config)) { const tag = String(rawTag).toLowerCase(); if (!EXTENSION_TAGS.has(tag)) { // eslint-disable-next-line no-console console.warn( `[nile-wysiwyg-editor] allowedStyles: ignoring tag "${rawTag}" (unsupported tag)` ); continue; } if (!Array.isArray(rawProps)) continue; const props = out.get(tag) ?? new Set(); for (const rawProp of rawProps) { const prop = String(rawProp); if (!isExtensionPropValid(prop)) { const reason = EXTENSION_DENYLIST.has(prop) ? 'denylisted' : BUILTIN_HANDLED.has(prop) ? 'already handled by a built-in' : 'invalid property name'; // eslint-disable-next-line no-console console.warn( `[nile-wysiwyg-editor] allowedStyles: ignoring "${tag}: ${rawProp}" (${reason})` ); continue; } props.add(prop); } if (props.size) out.set(tag, props); } return out; } /** `"td: letter-spacing; p: text-indent"` → `{ td: ['letter-spacing'], p: ['text-indent'] }`. */ export function parseAllowedStylesAttr(raw: string): Record { const out: Record = {}; for (const group of raw.split(';')) { const idx = group.indexOf(':'); if (idx === -1) continue; const tag = group.slice(0, idx).trim(); if (!tag) continue; const props = group .slice(idx + 1) .split(',') .map(p => p.trim()) .filter(Boolean); if (props.length) out[tag] = props; } return out; } /** Re-validates a stored `extraStyle` on write-back, where the per-app allowlist isn't visible. */ export function sanitizeExtraStyleForWrite(s: string): string { const kept: string[] = []; for (const decl of String(s ?? '').split(';')) { const idx = decl.indexOf(':'); if (idx === -1) continue; const name = decl.slice(0, idx).trim().toLowerCase(); const value = decl.slice(idx + 1).trim(); if (isExtensionPropValid(name) && isExtensionValueValid(value)) { kept.push(`${name}: ${value}`); } } return kept.join('; '); }