mail-editor-pancake 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * 列宽内联化(编译后处理)。
3
+ *
4
+ * MJML 的多列布局是「行内移动端宽度 + 媒体查询桌面宽度」两段式:
5
+ * 列 div 的行内样式写死 `width:100%`,桌面宽度由
6
+ * `@media only screen and (min-width:480px) { .mj-column-per-50 { width:50% !important; } }` 提供。
7
+ *
8
+ * 但部分邮件客户端与网页邮箱(临时邮箱预览、部分 sanitize 过度的转发页面等)会剥离
9
+ * `<style>` 与 `class`,媒体查询随之失效,行内的 `width:100%` 就会让多列退化成纵向堆叠。
10
+ *
11
+ * 这里把媒体查询里的列宽写成行内样式:
12
+ * - 支持 `<style>` 的客户端:媒体查询带 `!important`,优先级高于行内样式,行为与之前完全一致;
13
+ * - 剥离 `<style>` 的客户端:行内宽度兜底,至少保住多列布局。
14
+ *
15
+ * 同时补一条小屏重置规则(仅作用于本次内联过、且不在 mj-group 内的列),
16
+ * 让支持 `<style>` 的客户端在 480px 以下仍然按 MJML 原语义堆叠。
17
+ */
18
+ import { serializeParsedHtmlDocument } from './html';
19
+
20
+ /** 与 MJML 默认堆叠断点(min-width:480px)互补的小屏断点 */
21
+ const MOBILE_STACK_BREAKPOINT = 479;
22
+
23
+ /** 打在「已内联宽度、且需要小屏堆叠」的列上 */
24
+ const STACKABLE_COLUMN_CLASS = 'sm-stack-col';
25
+
26
+ /**
27
+ * 打在 mj-group(移动端保留列)内的 mj-column 上。
28
+ * 这类列任何宽度下都不堆叠,因此不参与小屏重置。
29
+ */
30
+ export const COLUMN_NO_STACK_CLASS = 'sm-nostack';
31
+
32
+ interface ColumnWidthRule {
33
+ width: string;
34
+ maxWidth?: string;
35
+ }
36
+
37
+ function firstDeclaration(block: string, prop: string): string | undefined {
38
+ const m = new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`, 'i').exec(block);
39
+ if (!m) return undefined;
40
+ return m[1].replace(/!\s*important/gi, '').trim();
41
+ }
42
+
43
+ /** 从编译产物 head 的 CSS 里抽取 `.mj-column-per-XX { width/ max-width }` 规则 */
44
+ function parseColumnWidthRules(css: string): Map<string, ColumnWidthRule> {
45
+ const rules = new Map<string, ColumnWidthRule>();
46
+ if (!css) return rules;
47
+
48
+ const re = /\.mj-column-per-([^\s,{]+)\s*\{([^}]*)\}/g;
49
+ let m: RegExpExecArray | null;
50
+ while ((m = re.exec(css))) {
51
+ const width = firstDeclaration(m[2], 'width');
52
+ if (!width) continue;
53
+ const className = `mj-column-per-${m[1]}`;
54
+ if (rules.has(className)) continue;
55
+ rules.set(className, { width, maxWidth: firstDeclaration(m[2], 'max-width') });
56
+ }
57
+ return rules;
58
+ }
59
+
60
+ function collectStyleText(parsed: Document): string {
61
+ return Array.from(parsed.querySelectorAll('style'))
62
+ .map((s) => s.textContent || '')
63
+ .join('\n');
64
+ }
65
+
66
+ /** 行内样式里已写明宽度且不是 100% 时(如 mj-group 内的列)保持原样 */
67
+ function applyColumnWidth(el: Element, rule: ColumnWidthRule): boolean {
68
+ const style = el.getAttribute('style') || '';
69
+ const currentWidth = firstDeclaration(style, 'width')?.toLowerCase();
70
+ if (currentWidth && currentWidth !== '100%') return false;
71
+
72
+ let next = style;
73
+ if (new RegExp('(?:^|;)\\s*width\\s*:', 'i').test(next)) {
74
+ // 保留前导的 `;`,否则会吞掉上一条声明的分隔符
75
+ next = next.replace(/((?:^|;)\s*)width\s*:\s*[^;]*/i, `$1width:${rule.width}`);
76
+ } else {
77
+ const base = next.trim().replace(/;+$/, '');
78
+ next = base ? `${base};width:${rule.width}` : `width:${rule.width}`;
79
+ }
80
+
81
+ if (rule.maxWidth) {
82
+ if (new RegExp('(?:^|;)\\s*max-width\\s*:', 'i').test(next)) {
83
+ next = next.replace(
84
+ /((?:^|;)\s*)max-width\s*:\s*[^;]*/i,
85
+ `$1max-width:${rule.maxWidth}`,
86
+ );
87
+ } else {
88
+ next = `${next};max-width:${rule.maxWidth}`;
89
+ }
90
+ }
91
+
92
+ next = next.replace(/;{2,}/g, ';').replace(/^\s*;/, '');
93
+
94
+ if (next === style) return false;
95
+ el.setAttribute('style', next);
96
+ return true;
97
+ }
98
+
99
+ function appendStackResetRule(parsed: Document): void {
100
+ const style = parsed.createElement('style');
101
+ style.setAttribute('type', 'text/css');
102
+ style.textContent = `\n @media only screen and (max-width:${MOBILE_STACK_BREAKPOINT}px) {\n .${STACKABLE_COLUMN_CLASS} { width:100% !important; max-width:100% !important; }\n }\n `;
103
+ parsed.head.appendChild(style);
104
+ }
105
+
106
+ /**
107
+ * 把 MJML 媒体查询中的列宽内联到列元素上,避免 `<style>` / `class` 被剥离后多列纵向堆叠。
108
+ * 无法解析 HTML 或未找到列宽规则时原样返回。
109
+ */
110
+ export function inlineColumnWidthsInHtml(html: string): string {
111
+ let parsed: Document;
112
+ try {
113
+ parsed = new DOMParser().parseFromString(html, 'text/html');
114
+ } catch {
115
+ return html;
116
+ }
117
+
118
+ const rules = parseColumnWidthRules(collectStyleText(parsed));
119
+ if (!rules.size) return html;
120
+
121
+ let changed = false;
122
+ let stackable = false;
123
+
124
+ for (const el of Array.from(parsed.querySelectorAll('[class]'))) {
125
+ const classes = (el.getAttribute('class') || '').split(/\s+/).filter(Boolean);
126
+ if (!classes.length) continue;
127
+
128
+ let matched: ColumnWidthRule | undefined;
129
+ for (const c of classes) {
130
+ const rule = rules.get(c);
131
+ if (rule) {
132
+ matched = rule;
133
+ break;
134
+ }
135
+ }
136
+ if (!matched) continue;
137
+
138
+ if (!applyColumnWidth(el, matched)) continue;
139
+ changed = true;
140
+
141
+ if (!classes.includes(COLUMN_NO_STACK_CLASS)) {
142
+ el.classList.add(STACKABLE_COLUMN_CLASS);
143
+ stackable = true;
144
+ }
145
+ }
146
+
147
+ if (!changed) return html;
148
+ if (stackable) appendStackResetRule(parsed);
149
+
150
+ return serializeParsedHtmlDocument(parsed, html);
151
+ }
@@ -1,4 +1,5 @@
1
1
  import type { EmailDoc } from '../types';
2
+ import { serializeParsedHtmlDocument } from './html';
2
3
  import {
3
4
  DYNAMIC_VARIANT_HTML_ATTR,
4
5
  getSectionDynamicVariantKey,
@@ -6,12 +7,6 @@ import {
6
7
  sectionMjClassName,
7
8
  } from './dynamicVariantKey';
8
9
 
9
- function serializeParsedHtml(doc: Document): string {
10
- const head = doc.head?.innerHTML || '';
11
- const body = doc.body?.innerHTML || '';
12
- return `${head}${body}`;
13
- }
14
-
15
10
  /**
16
11
  * 编译后为带 `dynamicVariantKey` 的 Section 根节点写入 `data-dv="{key}"`,供宿主 DOM 抽取。
17
12
  */
@@ -38,7 +33,7 @@ export function annotateDynamicVariantHtmlAttributes(html: string, doc: EmailDoc
38
33
  if (!target && candidates.length) target = candidates[0];
39
34
  if (target) target.setAttribute(DYNAMIC_VARIANT_HTML_ATTR, key);
40
35
  }
41
- return serializeParsedHtml(parsed);
36
+ return serializeParsedHtmlDocument(parsed, html);
42
37
  } catch {
43
38
  return html;
44
39
  }
@@ -1,3 +1,34 @@
1
+ import { escapeAttr } from './dom';
2
+
3
+ function openTagOf(el: Element | null): string {
4
+ if (!el) return '';
5
+ const tag = el.tagName.toLowerCase();
6
+ const attrs = Array.from(el.attributes)
7
+ .map((a) => ` ${a.name}="${escapeAttr(a.value)}"`)
8
+ .join('');
9
+ return `<${tag}${attrs}>`;
10
+ }
11
+
12
+ /**
13
+ * 把解析后的文档还原成字符串,尽量保留原文档的 DOCTYPE、`<html>` 与 `<body>` 开标签属性
14
+ * (`head.innerHTML + body.innerHTML` 会丢掉这三者)。
15
+ * 原文档是片段(没有 html / body 标签)时退化为片段拼接。
16
+ */
17
+ export function serializeParsedHtmlDocument(doc: Document, original: string): string {
18
+ const head = doc.head?.innerHTML || '';
19
+ const bodyInner = doc.body?.innerHTML || '';
20
+
21
+ const hasHtml = /<html[\s>]/i.test(original);
22
+ const hasBody = /<body[\s>]/i.test(original);
23
+
24
+ if (!hasHtml) {
25
+ return hasBody ? `${openTagOf(doc.body)}${bodyInner}</body>` : `${head}${bodyInner}`;
26
+ }
27
+
28
+ const doctype = /^[\s\S]*?<!DOCTYPE[^>]*>/i.exec(original)?.[0] ?? '';
29
+ return `${doctype}${openTagOf(doc.documentElement)}${head}${openTagOf(doc.body)}${bodyInner}</body></html>`;
30
+ }
31
+
1
32
  /**
2
33
  * 极轻量 HTML 格式化器(约 50 行)。
3
34
  *
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 邮件链接可用性判定。
3
+ *
4
+ * Outlook 网页端(outlook.com / OWA)会清洗「不可跳转」的 href:空值、`#`、
5
+ * 纯数字 / 相对路径(如 `1234`)、以及非标准协议(javascript:、data: 等)。
6
+ * 被清洗后 `<a>` 会连同内容一起消失,表现为按钮 / 图标「元素在但看不见」。
7
+ *
8
+ * 两个用途:
9
+ * 1. 右栏 `type: 'url'` 字段的默认校验,输入即时提示;
10
+ * 2. 渲染层兜底:不可跳转时不输出 href(如 MJML 会退化为 `<p>`,样式保留、仅不可点)。
11
+ */
12
+
13
+ /** 变量占位(发送时替换),如 {{couponLink}} */
14
+ const VARIABLE_PATTERN = /\{\{[^{}]*\}\}/
15
+ /** 放行协议;其余一律不放行,兼顾安全(屏蔽 javascript: / data: 等) */
16
+ const ALLOWED_SCHEMES = ['http:', 'https:', 'mailto:', 'tel:', 'sms:']
17
+
18
+ export function isRenderableHref(raw: unknown): boolean {
19
+ const href = String(raw ?? '').trim();
20
+ if (!href || href === '#') return false;
21
+ if (VARIABLE_PATTERN.test(href)) return true;
22
+
23
+ /** 协议相对写法 //example.com */
24
+ const normalized = href.startsWith('//') ? `https:${href}` : href;
25
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(normalized)?.[1]?.toLowerCase();
26
+ /** 无协议即相对路径 / 纯数字,邮件客户端不会保留 */
27
+ if (!scheme || !ALLOWED_SCHEMES.includes(`${scheme}:`)) return false;
28
+
29
+ try {
30
+ const url = new URL(normalized);
31
+ return scheme === 'mailto' || scheme === 'tel' || scheme === 'sms' ? true : !!url.hostname;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /** 可跳转则原样返回,否则返回空串,由调用方降级为不可点元素 */
38
+ export function resolveHref(raw: unknown): string {
39
+ const href = String(raw ?? '').trim();
40
+ return isRenderableHref(href) ? href : '';
41
+ }
42
+
43
+ /** 右栏链接字段默认校验:空值放行(允许暂不填),填了但不可跳转则给提示 */
44
+ export function invalidHrefMessage(
45
+ raw: unknown,
46
+ t: (key: string) => string,
47
+ ): string | null {
48
+ const href = String(raw ?? '').trim();
49
+ if (!href || isRenderableHref(href)) return null;
50
+ return t('rightPanel.block.invalidLink');
51
+ }
@@ -0,0 +1,46 @@
1
+ import type { Registry } from '../registry/registry';
2
+ import type { BlockDefinition, PaletteDropResult } from '../types';
3
+
4
+ /**
5
+ * palette 拖入语义的判定。
6
+ *
7
+ * 画布是四层模型(Section → Column → Block),左栏也分两组:
8
+ * - `sections`:布局卡片,落在 Section 之间;
9
+ * - `blocks`:组件卡片,落在 Column 内。
10
+ *
11
+ * 自带布局的组件(如一次生成双列 Section 的卡组)实际会新建 Section,
12
+ * 若仍归到 `blocks`,拖拽时会显示「可放进列内」的占位,松手却落到该 Section 之后,
13
+ * 与预期不符。这里统一判定,让它与布局卡片同组,拖拽表现与布局一致。
14
+ */
15
+
16
+ /** 拖入结果是否自带 Section 布局(多列布局 / 多列分发) */
17
+ export function paletteDropHasSectionLayout(drop: PaletteDropResult): boolean {
18
+ if (drop.sectionLayout && drop.sectionLayout !== '1') return true;
19
+ return (drop.columnBlocks?.length ?? 0) > 1;
20
+ }
21
+
22
+ /** 解析 palette 拖入结果:未声明 `expandPaletteDrop` 时按单块处理 */
23
+ export function resolvePaletteDropResult(
24
+ def: BlockDefinition,
25
+ registry: Registry,
26
+ ): PaletteDropResult {
27
+ if (def.expandPaletteDrop) {
28
+ const out = def.expandPaletteDrop((t) => registry.createBlock(t));
29
+ return Array.isArray(out) ? { blocks: out } : out;
30
+ }
31
+ return { blocks: [registry.createBlock(def.type)] };
32
+ }
33
+
34
+ /** 该 palette 卡片是否按「布局」处理:只能落在 Section 之间,不能落进 Column */
35
+ export function paletteDropIsSectionLevel(
36
+ def: BlockDefinition,
37
+ registry: Registry,
38
+ opts?: { enableDynamicVariantKey?: boolean },
39
+ ): boolean {
40
+ if (def.paletteDropLevel) return def.paletteDropLevel === 'section';
41
+ if (!def.expandPaletteDrop) return false;
42
+ const drop = resolvePaletteDropResult(def, registry);
43
+ if (paletteDropHasSectionLayout(drop)) return true;
44
+ /** 开启动态变量节时,带 dynamicVariantKey 的组合块同样会新建独立 Section */
45
+ return opts?.enableDynamicVariantKey === true && !!drop.sectionAttrs?.dynamicVariantKey;
46
+ }