email-editor-core 0.0.4
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.
- package/README.md +438 -0
- package/dist/index.d.ts +127 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +260 -0
- package/dist/renderer/blocks/button.d.ts +18 -0
- package/dist/renderer/blocks/button.d.ts.map +1 -0
- package/dist/renderer/blocks/button.js +57 -0
- package/dist/renderer/blocks/divider.d.ts +18 -0
- package/dist/renderer/blocks/divider.d.ts.map +1 -0
- package/dist/renderer/blocks/divider.js +42 -0
- package/dist/renderer/blocks/highlight.d.ts +18 -0
- package/dist/renderer/blocks/highlight.d.ts.map +1 -0
- package/dist/renderer/blocks/highlight.js +49 -0
- package/dist/renderer/blocks/image.d.ts +18 -0
- package/dist/renderer/blocks/image.d.ts.map +1 -0
- package/dist/renderer/blocks/image.js +59 -0
- package/dist/renderer/blocks/paragraph.d.ts +18 -0
- package/dist/renderer/blocks/paragraph.d.ts.map +1 -0
- package/dist/renderer/blocks/paragraph.js +41 -0
- package/dist/renderer/blocks/title.d.ts +18 -0
- package/dist/renderer/blocks/title.d.ts.map +1 -0
- package/dist/renderer/blocks/title.js +49 -0
- package/dist/renderer/parseInlineFormatting.d.ts +14 -0
- package/dist/renderer/parseInlineFormatting.d.ts.map +1 -0
- package/dist/renderer/parseInlineFormatting.js +178 -0
- package/dist/renderer/renderBlock.d.ts +21 -0
- package/dist/renderer/renderBlock.d.ts.map +1 -0
- package/dist/renderer/renderBlock.js +44 -0
- package/dist/renderer/renderEmail.d.ts +26 -0
- package/dist/renderer/renderEmail.d.ts.map +1 -0
- package/dist/renderer/renderEmail.js +275 -0
- package/dist/sanitizer.d.ts +147 -0
- package/dist/sanitizer.d.ts.map +1 -0
- package/dist/sanitizer.js +533 -0
- package/dist/template-config.d.ts +38 -0
- package/dist/template-config.d.ts.map +1 -0
- package/dist/template-config.js +196 -0
- package/dist/test-formatting.d.ts +6 -0
- package/dist/test-formatting.d.ts.map +1 -0
- package/dist/test-formatting.js +132 -0
- package/dist/types.d.ts +243 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/validator.d.ts +86 -0
- package/dist/validator.d.ts.map +1 -0
- package/dist/validator.js +435 -0
- package/package.json +17 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TITLE BLOCK RENDERER
|
|
3
|
+
*
|
|
4
|
+
* Responsibility: Render a single title block to HTML
|
|
5
|
+
* - Receives: TitleBlock
|
|
6
|
+
* - Returns: HTML string for this block only
|
|
7
|
+
* - Owns: All styling for title blocks
|
|
8
|
+
* - Does NOT: Know about other blocks, templates, or document structure
|
|
9
|
+
*/
|
|
10
|
+
import { parseInlineFormatting } from '../parseInlineFormatting.js';
|
|
11
|
+
/**
|
|
12
|
+
* Render a title block to HTML
|
|
13
|
+
*
|
|
14
|
+
* @param block - The title block to render
|
|
15
|
+
* @returns HTML string for this block
|
|
16
|
+
*/
|
|
17
|
+
export function renderTitleBlock(block) {
|
|
18
|
+
// Determine heading tag based on level
|
|
19
|
+
const tag = block.level || 'h2';
|
|
20
|
+
// Font size based on heading level
|
|
21
|
+
const fontSizeMap = {
|
|
22
|
+
h1: '28px',
|
|
23
|
+
h2: '24px',
|
|
24
|
+
h3: '20px',
|
|
25
|
+
};
|
|
26
|
+
const fontSize = fontSizeMap[tag] || '24px';
|
|
27
|
+
// Default colors if not specified
|
|
28
|
+
const color = block.color || '#1a1a1a';
|
|
29
|
+
const paddingBottom = block.paddingBottom || 20;
|
|
30
|
+
// Inline styles for title
|
|
31
|
+
const styles = [
|
|
32
|
+
`font-size: ${fontSize}`,
|
|
33
|
+
'font-weight: 700',
|
|
34
|
+
`color: ${color}`,
|
|
35
|
+
`padding-bottom: ${paddingBottom}px`,
|
|
36
|
+
'margin-top: 0',
|
|
37
|
+
'line-height: 1.3',
|
|
38
|
+
].join(';');
|
|
39
|
+
// HTML table wrapper (email compatibility)
|
|
40
|
+
return `
|
|
41
|
+
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
42
|
+
<tr>
|
|
43
|
+
<td style="${styles}">
|
|
44
|
+
${parseInlineFormatting(block.content)}
|
|
45
|
+
</td>
|
|
46
|
+
</tr>
|
|
47
|
+
</table>
|
|
48
|
+
`.trim();
|
|
49
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse inline formatting tokens in text
|
|
3
|
+
*
|
|
4
|
+
* Converts tokens like:
|
|
5
|
+
* - {{bold:#HEX_COLOR}}text{{/bold}} to safe inline <span> with color and font-weight
|
|
6
|
+
* - {{link:URL}}text{{/link}} to safe inline <a> with href and inline styles
|
|
7
|
+
* - {{link:URL|bold|color:#HEX}}text{{/link}} to <a> with combined formatting
|
|
8
|
+
* - {{style:weight|color:#HEX}}text{{/style}} to <span> with unified styling
|
|
9
|
+
*
|
|
10
|
+
* @param text - The text string containing formatting tokens
|
|
11
|
+
* @returns HTML string with formatted spans and links
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseInlineFormatting(text: string): string;
|
|
14
|
+
//# sourceMappingURL=parseInlineFormatting.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parseInlineFormatting.d.ts","sourceRoot":"","sources":["../../src/renderer/parseInlineFormatting.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAoFH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAqG1D"}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse inline formatting tokens in text
|
|
3
|
+
*
|
|
4
|
+
* Converts tokens like:
|
|
5
|
+
* - {{bold:#HEX_COLOR}}text{{/bold}} to safe inline <span> with color and font-weight
|
|
6
|
+
* - {{link:URL}}text{{/link}} to safe inline <a> with href and inline styles
|
|
7
|
+
* - {{link:URL|bold|color:#HEX}}text{{/link}} to <a> with combined formatting
|
|
8
|
+
* - {{style:weight|color:#HEX}}text{{/style}} to <span> with unified styling
|
|
9
|
+
*
|
|
10
|
+
* @param text - The text string containing formatting tokens
|
|
11
|
+
* @returns HTML string with formatted spans and links
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Validate if URL is safe to use in href
|
|
15
|
+
*/
|
|
16
|
+
function isValidUrl(url) {
|
|
17
|
+
if (!url || typeof url !== 'string')
|
|
18
|
+
return false;
|
|
19
|
+
// Whitelist: allow https://, http://, mailto:, https://wa.me/
|
|
20
|
+
if (url.startsWith('https://'))
|
|
21
|
+
return true;
|
|
22
|
+
if (url.startsWith('http://'))
|
|
23
|
+
return true;
|
|
24
|
+
if (url.startsWith('mailto:'))
|
|
25
|
+
return true;
|
|
26
|
+
if (url.startsWith('https://wa.me/'))
|
|
27
|
+
return true;
|
|
28
|
+
// Blacklist: reject javascript:, data:, vbscript:, file:
|
|
29
|
+
if (url.startsWith('javascript:'))
|
|
30
|
+
return false;
|
|
31
|
+
if (url.startsWith('data:'))
|
|
32
|
+
return false;
|
|
33
|
+
if (url.startsWith('vbscript:'))
|
|
34
|
+
return false;
|
|
35
|
+
if (url.startsWith('file:'))
|
|
36
|
+
return false;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Parse link modifiers from token
|
|
41
|
+
* Format: {{link:URL|bold|color:#HEX}}
|
|
42
|
+
* Returns: { url, hasBold, color }
|
|
43
|
+
*/
|
|
44
|
+
function parseLinkModifiers(spec) {
|
|
45
|
+
const parts = spec.split('|').map((p) => p.trim());
|
|
46
|
+
const url = parts[0];
|
|
47
|
+
let hasBold = false;
|
|
48
|
+
let color = null;
|
|
49
|
+
for (let i = 1; i < parts.length; i++) {
|
|
50
|
+
const part = parts[i];
|
|
51
|
+
if (part === 'bold') {
|
|
52
|
+
hasBold = true;
|
|
53
|
+
}
|
|
54
|
+
else if (part.startsWith('color:')) {
|
|
55
|
+
const colorValue = part.substring(6); // Remove "color:" prefix
|
|
56
|
+
// Validate hex color
|
|
57
|
+
if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(colorValue)) {
|
|
58
|
+
color = colorValue;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { url, hasBold, color };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Parse style modifiers from token
|
|
66
|
+
* Format: {{style:weight|color:#HEX}}
|
|
67
|
+
* Weight ∈ normal, semibold, bold (optional)
|
|
68
|
+
* Color format: color:#HEX (optional)
|
|
69
|
+
* Returns: { weight, color, isValid }
|
|
70
|
+
*/
|
|
71
|
+
function parseStyleModifiers(spec) {
|
|
72
|
+
const parts = spec.split('|').map((p) => p.trim());
|
|
73
|
+
let weight = null;
|
|
74
|
+
let color = null;
|
|
75
|
+
let hasOption = false;
|
|
76
|
+
for (let i = 0; i < parts.length; i++) {
|
|
77
|
+
const part = parts[i];
|
|
78
|
+
// Check for weight
|
|
79
|
+
if (part === 'normal' || part === 'semibold' || part === 'bold') {
|
|
80
|
+
weight = part;
|
|
81
|
+
hasOption = true;
|
|
82
|
+
}
|
|
83
|
+
else if (part.startsWith('color:')) {
|
|
84
|
+
const colorValue = part.substring(6); // Remove "color:" prefix
|
|
85
|
+
// Validate hex color
|
|
86
|
+
if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(colorValue)) {
|
|
87
|
+
color = colorValue;
|
|
88
|
+
hasOption = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// At least one option must exist
|
|
93
|
+
return { weight, color, isValid: hasOption };
|
|
94
|
+
}
|
|
95
|
+
export function parseInlineFormatting(text) {
|
|
96
|
+
let result = text;
|
|
97
|
+
// Step 1: Parse bold tokens first
|
|
98
|
+
const boldRegex = /\{\{bold:(#[0-9A-Fa-f]{3}|#[0-9A-Fa-f]{6})\}\}(.*?)\{\{\/bold\}\}/g;
|
|
99
|
+
let boldMatch;
|
|
100
|
+
while ((boldMatch = boldRegex.exec(text)) !== null) {
|
|
101
|
+
const color = boldMatch[1]; // e.g., #008867
|
|
102
|
+
const contentText = boldMatch[2]; // The text to format
|
|
103
|
+
const fullToken = boldMatch[0]; // The entire {{bold:#...}}...{{/bold}}
|
|
104
|
+
// Create the replacement span with inline styles
|
|
105
|
+
const replacement = `<span style="color:${color};font-weight:700;">${contentText}</span>`;
|
|
106
|
+
// Replace only this specific token occurrence
|
|
107
|
+
result = result.replace(fullToken, replacement);
|
|
108
|
+
}
|
|
109
|
+
// Step 2: Parse unified style tokens
|
|
110
|
+
// Regex to match {{style:modifiers}}...{{/style}}
|
|
111
|
+
const styleRegex = /\{\{style:([^}]+)\}\}(.*?)\{\{\/style\}\}/g;
|
|
112
|
+
let styleMatch;
|
|
113
|
+
while ((styleMatch = styleRegex.exec(result)) !== null) {
|
|
114
|
+
const spec = styleMatch[1]; // e.g., "bold|color:#008867"
|
|
115
|
+
const contentText = styleMatch[2]; // The text to format
|
|
116
|
+
const fullToken = styleMatch[0]; // The entire {{style:...}}...{{/style}}
|
|
117
|
+
// Parse style modifiers
|
|
118
|
+
const { weight, color, isValid } = parseStyleModifiers(spec);
|
|
119
|
+
// Skip invalid tokens (at least one option must exist)
|
|
120
|
+
if (!isValid) {
|
|
121
|
+
result = result.replace(fullToken, contentText);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
// Build inline styles for the span
|
|
125
|
+
const styles = [];
|
|
126
|
+
// Add color if specified
|
|
127
|
+
if (color) {
|
|
128
|
+
styles.push(`color:${color}`);
|
|
129
|
+
}
|
|
130
|
+
// Add font-weight based on weight value
|
|
131
|
+
if (weight === 'bold') {
|
|
132
|
+
styles.push('font-weight:700');
|
|
133
|
+
}
|
|
134
|
+
else if (weight === 'semibold') {
|
|
135
|
+
styles.push('font-weight:600');
|
|
136
|
+
}
|
|
137
|
+
else if (weight === 'normal') {
|
|
138
|
+
styles.push('font-weight:400');
|
|
139
|
+
}
|
|
140
|
+
// Create the replacement span with inline styles
|
|
141
|
+
const replacement = `<span style="${styles.join(';')};">${contentText}</span>`;
|
|
142
|
+
// Replace only this specific token occurrence
|
|
143
|
+
result = result.replace(fullToken, replacement);
|
|
144
|
+
}
|
|
145
|
+
// Step 3: Parse combined link tokens with optional modifiers
|
|
146
|
+
// Regex to match {{link:URL|modifiers}}...{{/link}}
|
|
147
|
+
const combinedLinkRegex = /\{\{link:([^}]+)\}\}(.*?)\{\{\/link\}\}/g;
|
|
148
|
+
let combinedLinkMatch;
|
|
149
|
+
while ((combinedLinkMatch = combinedLinkRegex.exec(result)) !== null) {
|
|
150
|
+
const spec = combinedLinkMatch[1]; // e.g., "URL|bold|color:#008867"
|
|
151
|
+
const linkText = combinedLinkMatch[2]; // The text to display
|
|
152
|
+
const fullToken = combinedLinkMatch[0]; // The entire {{link:...}}...{{/link}}
|
|
153
|
+
// Parse URL and modifiers
|
|
154
|
+
const { url, hasBold, color } = parseLinkModifiers(spec);
|
|
155
|
+
// Validate URL before rendering
|
|
156
|
+
if (!isValidUrl(url)) {
|
|
157
|
+
// Invalid URL: render plain text without link
|
|
158
|
+
result = result.replace(fullToken, linkText);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
// Build inline styles for the anchor
|
|
162
|
+
const styles = [];
|
|
163
|
+
const linkColor = color || '#008867'; // Default color
|
|
164
|
+
styles.push(`color:${linkColor}`);
|
|
165
|
+
styles.push('text-decoration:none');
|
|
166
|
+
if (hasBold) {
|
|
167
|
+
styles.push('font-weight:700');
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
styles.push('font-weight:600');
|
|
171
|
+
}
|
|
172
|
+
// Create the replacement anchor with inline styles
|
|
173
|
+
const replacement = `<a href="${url}" style="${styles.join(';')};" target="_blank" rel="noopener noreferrer">${linkText}</a>`;
|
|
174
|
+
// Replace only this specific token occurrence
|
|
175
|
+
result = result.replace(fullToken, replacement);
|
|
176
|
+
}
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLOCK DISPATCHER
|
|
3
|
+
*
|
|
4
|
+
* Responsibility: Route blocks to correct renderer
|
|
5
|
+
* - Receives: Block (union type of all block types)
|
|
6
|
+
* - Returns: HTML string for that block
|
|
7
|
+
* - Does NOT: Generate HTML itself, contain block logic, know about document structure
|
|
8
|
+
*/
|
|
9
|
+
import type { Block } from '../types.js';
|
|
10
|
+
/**
|
|
11
|
+
* Render any block to HTML
|
|
12
|
+
*
|
|
13
|
+
* Routes block to correct renderer based on block type.
|
|
14
|
+
* Each block type handler owns its complete HTML generation.
|
|
15
|
+
*
|
|
16
|
+
* @param block - The block to render (any block type)
|
|
17
|
+
* @returns HTML string for this block
|
|
18
|
+
* @throws Error if block type is unknown
|
|
19
|
+
*/
|
|
20
|
+
export declare function renderBlock(block: Block): string;
|
|
21
|
+
//# sourceMappingURL=renderBlock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"renderBlock.d.ts","sourceRoot":"","sources":["../../src/renderer/renderBlock.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAQzC;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAyBhD"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLOCK DISPATCHER
|
|
3
|
+
*
|
|
4
|
+
* Responsibility: Route blocks to correct renderer
|
|
5
|
+
* - Receives: Block (union type of all block types)
|
|
6
|
+
* - Returns: HTML string for that block
|
|
7
|
+
* - Does NOT: Generate HTML itself, contain block logic, know about document structure
|
|
8
|
+
*/
|
|
9
|
+
import { renderTitleBlock } from './blocks/title.js';
|
|
10
|
+
import { renderParagraphBlock } from './blocks/paragraph.js';
|
|
11
|
+
import { renderImageBlock } from './blocks/image.js';
|
|
12
|
+
import { renderButtonBlock } from './blocks/button.js';
|
|
13
|
+
import { renderDividerBlock } from './blocks/divider.js';
|
|
14
|
+
import { renderHighlightBlock } from './blocks/highlight.js';
|
|
15
|
+
/**
|
|
16
|
+
* Render any block to HTML
|
|
17
|
+
*
|
|
18
|
+
* Routes block to correct renderer based on block type.
|
|
19
|
+
* Each block type handler owns its complete HTML generation.
|
|
20
|
+
*
|
|
21
|
+
* @param block - The block to render (any block type)
|
|
22
|
+
* @returns HTML string for this block
|
|
23
|
+
* @throws Error if block type is unknown
|
|
24
|
+
*/
|
|
25
|
+
export function renderBlock(block) {
|
|
26
|
+
switch (block.type) {
|
|
27
|
+
case 'title':
|
|
28
|
+
return renderTitleBlock(block);
|
|
29
|
+
case 'paragraph':
|
|
30
|
+
return renderParagraphBlock(block);
|
|
31
|
+
case 'image':
|
|
32
|
+
return renderImageBlock(block);
|
|
33
|
+
case 'button':
|
|
34
|
+
return renderButtonBlock(block);
|
|
35
|
+
case 'divider':
|
|
36
|
+
return renderDividerBlock(block);
|
|
37
|
+
case 'highlight-box':
|
|
38
|
+
return renderHighlightBlock(block);
|
|
39
|
+
default:
|
|
40
|
+
// TypeScript exhaustiveness check - this should never happen in production
|
|
41
|
+
// but provides helpful error message if a new block type is added but not handled
|
|
42
|
+
throw new Error(`Unknown block type: ${block.type}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMAIL RENDERER
|
|
3
|
+
*
|
|
4
|
+
* Responsibility: Assemble complete HTML email document
|
|
5
|
+
* - Receives: EmailDocument
|
|
6
|
+
* - Returns: Complete HTML email string
|
|
7
|
+
* - Does NOT: Generate block HTML (delegates to renderBlock), know about specific block types
|
|
8
|
+
*/
|
|
9
|
+
import type { EmailDocument } from "../types.js";
|
|
10
|
+
/**
|
|
11
|
+
* Render complete HTML email from EmailDocument
|
|
12
|
+
*
|
|
13
|
+
* Generates a fully-formed HTML email with:
|
|
14
|
+
* - DOCTYPE and basic HTML structure
|
|
15
|
+
* - Email-safe inline styles in head
|
|
16
|
+
* - Header section (logo/company info)
|
|
17
|
+
* - Body blocks (rendered by block renderer)
|
|
18
|
+
* - Help section (contact information)
|
|
19
|
+
* - Compliance section (legal notice)
|
|
20
|
+
* - Footer section (company info, social links)
|
|
21
|
+
*
|
|
22
|
+
* @param email - The EmailDocument to render
|
|
23
|
+
* @returns Complete HTML email string
|
|
24
|
+
*/
|
|
25
|
+
export declare function renderEmail(email: EmailDocument): string;
|
|
26
|
+
//# sourceMappingURL=renderEmail.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"renderEmail.d.ts","sourceRoot":"","sources":["../../src/renderer/renderEmail.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAGjD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAmRxD"}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMAIL RENDERER
|
|
3
|
+
*
|
|
4
|
+
* Responsibility: Assemble complete HTML email document
|
|
5
|
+
* - Receives: EmailDocument
|
|
6
|
+
* - Returns: Complete HTML email string
|
|
7
|
+
* - Does NOT: Generate block HTML (delegates to renderBlock), know about specific block types
|
|
8
|
+
*/
|
|
9
|
+
import { renderBlock } from "./renderBlock.js";
|
|
10
|
+
/**
|
|
11
|
+
* Render complete HTML email from EmailDocument
|
|
12
|
+
*
|
|
13
|
+
* Generates a fully-formed HTML email with:
|
|
14
|
+
* - DOCTYPE and basic HTML structure
|
|
15
|
+
* - Email-safe inline styles in head
|
|
16
|
+
* - Header section (logo/company info)
|
|
17
|
+
* - Body blocks (rendered by block renderer)
|
|
18
|
+
* - Help section (contact information)
|
|
19
|
+
* - Compliance section (legal notice)
|
|
20
|
+
* - Footer section (company info, social links)
|
|
21
|
+
*
|
|
22
|
+
* @param email - The EmailDocument to render
|
|
23
|
+
* @returns Complete HTML email string
|
|
24
|
+
*/
|
|
25
|
+
export function renderEmail(email) {
|
|
26
|
+
// Render all body blocks
|
|
27
|
+
const bodyBlocksHtml = email.body.blocks
|
|
28
|
+
.map((block) => renderBlock(block))
|
|
29
|
+
.join("\n");
|
|
30
|
+
// Build help section HTML
|
|
31
|
+
let helpSectionHtml = "";
|
|
32
|
+
if (email.helpSection) {
|
|
33
|
+
const contactItemsHtml = (email.helpSection.contactItems || [])
|
|
34
|
+
.map((item) => {
|
|
35
|
+
// Map icon URL based on contact type
|
|
36
|
+
let iconUrl = "";
|
|
37
|
+
if (item.type === "email") {
|
|
38
|
+
iconUrl = "https://nobi.id/icons/icon-mail.png";
|
|
39
|
+
}
|
|
40
|
+
else if (item.type === "phone" || item.type === "whatsapp") {
|
|
41
|
+
iconUrl = "https://nobi.id/icons/icon-phone.png";
|
|
42
|
+
}
|
|
43
|
+
return `
|
|
44
|
+
<table cellpadding="0" cellspacing="0" role="presentation"
|
|
45
|
+
style="margin-bottom: 8px;">
|
|
46
|
+
<tr>
|
|
47
|
+
<td style="padding-right: 8px;" valign="middle">
|
|
48
|
+
<img src="${iconUrl}" height="20"
|
|
49
|
+
width="20" style="display: block;" alt="${item.label}" />
|
|
50
|
+
</td>
|
|
51
|
+
<td valign="middle">
|
|
52
|
+
<a href="${item.href}"
|
|
53
|
+
style="color: #008867; font-size: 14px; text-decoration: none;">
|
|
54
|
+
${item.value}
|
|
55
|
+
</a>
|
|
56
|
+
</td>
|
|
57
|
+
</tr>
|
|
58
|
+
</table>`;
|
|
59
|
+
})
|
|
60
|
+
.join("\n");
|
|
61
|
+
helpSectionHtml = `
|
|
62
|
+
<tr>
|
|
63
|
+
<td style="padding: 40px; background-color: #f6f6f6;">
|
|
64
|
+
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
65
|
+
<tr>
|
|
66
|
+
<!-- Kolom Teks -->
|
|
67
|
+
<td class="stack-column" valign="top" style="padding-right: 20px; width: 60%;">
|
|
68
|
+
<h3
|
|
69
|
+
style="font-size: 20px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px;">
|
|
70
|
+
${email.helpSection.title ||
|
|
71
|
+
"Butuh Bantuan untuk Mulai?"}
|
|
72
|
+
</h3>
|
|
73
|
+
<p
|
|
74
|
+
style="line-height: 1.5; margin-top: 12px; color: #444; margin-bottom: 16px;">
|
|
75
|
+
${email.helpSection.description ||
|
|
76
|
+
""}
|
|
77
|
+
</p>
|
|
78
|
+
${contactItemsHtml}
|
|
79
|
+
</td>
|
|
80
|
+
|
|
81
|
+
<!-- Kolom Gambar -->
|
|
82
|
+
<td class="stack-column" style="width: 40%; text-align: right;" valign="middle">
|
|
83
|
+
<img src="${email.helpSection.imageUrl || ""}" alt="${email.helpSection.title || "Help"}"
|
|
84
|
+
width="150" style="border-radius: 5px; height: 150px; width: auto;" />
|
|
85
|
+
</td>
|
|
86
|
+
</tr>
|
|
87
|
+
</table>
|
|
88
|
+
</td>
|
|
89
|
+
</tr>`;
|
|
90
|
+
}
|
|
91
|
+
// Build footer social links HTML (table-based icons)
|
|
92
|
+
let socialLinksHtml = "";
|
|
93
|
+
if (email.footer.socialLinks && email.footer.socialLinks.length > 0) {
|
|
94
|
+
const socialIconsHtml = email.footer.socialLinks
|
|
95
|
+
.map((link, index) => {
|
|
96
|
+
// Map icon URL based on platform
|
|
97
|
+
let iconUrl = "";
|
|
98
|
+
if (link.platform === "instagram") {
|
|
99
|
+
iconUrl =
|
|
100
|
+
"https://cdn.tools.unlayer.com/social/icons/circle-black/instagram.png";
|
|
101
|
+
}
|
|
102
|
+
else if (link.platform === "twitter") {
|
|
103
|
+
iconUrl =
|
|
104
|
+
"https://cdn.tools.unlayer.com/social/icons/circle-black/twitter.png";
|
|
105
|
+
}
|
|
106
|
+
else if (link.platform === "linkedin") {
|
|
107
|
+
iconUrl =
|
|
108
|
+
"https://cdn.tools.unlayer.com/social/icons/circle-black/linkedin.png";
|
|
109
|
+
}
|
|
110
|
+
else if (link.platform === "facebook") {
|
|
111
|
+
iconUrl =
|
|
112
|
+
"https://cdn.tools.unlayer.com/social/icons/circle-black/facebook.png";
|
|
113
|
+
}
|
|
114
|
+
else if (link.platform === "email") {
|
|
115
|
+
iconUrl =
|
|
116
|
+
"https://cdn.tools.unlayer.com/social/icons/circle-black/email.png";
|
|
117
|
+
}
|
|
118
|
+
else if (link.platform === "whatsapp") {
|
|
119
|
+
iconUrl =
|
|
120
|
+
"https://cdn.tools.unlayer.com/social/icons/circle-black/whatsapp.png";
|
|
121
|
+
}
|
|
122
|
+
// Last item has margin-right: 0px, others have 5px
|
|
123
|
+
const isLast = index === email.footer.socialLinks.length - 1;
|
|
124
|
+
const marginRight = isLast ? "0px" : "5px";
|
|
125
|
+
return `
|
|
126
|
+
<table role="presentation" border="0" cellspacing="0"
|
|
127
|
+
cellpadding="0" width="32" height="32"
|
|
128
|
+
style="width:32px!important;height:32px!important;display:inline-block;border-collapse:collapse;table-layout:fixed;border-spacing:0;vertical-align:top;margin-right:${marginRight}">
|
|
129
|
+
<tbody>
|
|
130
|
+
<tr style="vertical-align:top">
|
|
131
|
+
<td valign="middle"
|
|
132
|
+
style="word-break:break-word;border-collapse:collapse!important;vertical-align:top">
|
|
133
|
+
<a href="${link.url}" title="${link.platform}"
|
|
134
|
+
target="_blank">
|
|
135
|
+
<img src="${iconUrl}"
|
|
136
|
+
alt="${link.platform}" title="${link.platform}" width="32"
|
|
137
|
+
style="outline:none;text-decoration:none;clear:both;display:block!important;border:none;height:auto;float:none;max-width:32px!important"
|
|
138
|
+
class="CToWUd" data-bit="iit">
|
|
139
|
+
</a>
|
|
140
|
+
</td>
|
|
141
|
+
</tr>
|
|
142
|
+
</tbody>
|
|
143
|
+
</table>`;
|
|
144
|
+
})
|
|
145
|
+
.join("\n");
|
|
146
|
+
socialLinksHtml = socialIconsHtml;
|
|
147
|
+
}
|
|
148
|
+
// Build compliance section HTML
|
|
149
|
+
const complianceText = email.complianceSection.text || "";
|
|
150
|
+
const sandboxNumber = email.complianceSection.sandboxNumber
|
|
151
|
+
? `<strong>${email.complianceSection.sandboxNumber}</strong>`
|
|
152
|
+
: "";
|
|
153
|
+
// HTML email structure with email-safe styling
|
|
154
|
+
const html = `<!DOCTYPE html>
|
|
155
|
+
<html lang="en">
|
|
156
|
+
<head>
|
|
157
|
+
<meta charset="UTF-8">
|
|
158
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
159
|
+
<title>${email.templateType}</title>
|
|
160
|
+
<style type="text/css">
|
|
161
|
+
/* Reset styles for better email client compatibility */
|
|
162
|
+
body {
|
|
163
|
+
margin: 0;
|
|
164
|
+
padding: 0;
|
|
165
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
166
|
+
line-height: 1.6;
|
|
167
|
+
color: #1a1a1a;
|
|
168
|
+
}
|
|
169
|
+
table {
|
|
170
|
+
border-collapse: collapse;
|
|
171
|
+
}
|
|
172
|
+
img {
|
|
173
|
+
display: block;
|
|
174
|
+
outline: none;
|
|
175
|
+
border: none;
|
|
176
|
+
text-decoration: none;
|
|
177
|
+
}
|
|
178
|
+
a {
|
|
179
|
+
color: inherit;
|
|
180
|
+
text-decoration: none;
|
|
181
|
+
}
|
|
182
|
+
</style>
|
|
183
|
+
</head>
|
|
184
|
+
<body style="margin: 0; padding: 0; background-color: #f8fafc;">
|
|
185
|
+
<!-- Outer container -->
|
|
186
|
+
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f8fafc">
|
|
187
|
+
<!-- HEADER SECTION -->
|
|
188
|
+
<tr>
|
|
189
|
+
<td align="center">
|
|
190
|
+
<span style="display:inline-block; margin:20px 0;">
|
|
191
|
+
${email.header.logoUrl
|
|
192
|
+
? `<img src="${email.header.logoUrl}" alt="Logo" style="height: ${email.header.logoHeight || 32}px; max-width: 100%;" />`
|
|
193
|
+
: '<h1 style="margin: 0; font-size: 24px; font-weight: bold; color: #1a1a1a;">Email</h1>'}
|
|
194
|
+
</span>
|
|
195
|
+
</td>
|
|
196
|
+
</tr>
|
|
197
|
+
|
|
198
|
+
<tr>
|
|
199
|
+
<td align="center">
|
|
200
|
+
<!-- Main email container (max 600px width) -->
|
|
201
|
+
<table width="600" cellpadding="0" cellspacing="0" border="0" bgcolor="#ffffff" style="max-width: 100%;">
|
|
202
|
+
<!-- BODY BLOCKS SECTION -->
|
|
203
|
+
<tr>
|
|
204
|
+
<td style="padding: 20px;">
|
|
205
|
+
${bodyBlocksHtml}
|
|
206
|
+
</td>
|
|
207
|
+
</tr>
|
|
208
|
+
|
|
209
|
+
<!-- HELP SECTION (if provided) -->
|
|
210
|
+
${helpSectionHtml}
|
|
211
|
+
|
|
212
|
+
<!-- COMPLIANCE SECTION -->
|
|
213
|
+
<table width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #e4f4f0;">
|
|
214
|
+
<tr>
|
|
215
|
+
<td align="center" style="padding: 0;">
|
|
216
|
+
<table width="100%" cellpadding="12" cellspacing="0"
|
|
217
|
+
style="max-width: 600px; background-color: #e4f4f0; border-radius: 0;">
|
|
218
|
+
<tr>
|
|
219
|
+
<td align="center"
|
|
220
|
+
style="font-family: Arial, sans-serif; color: #006950; font-size: 15px; line-height: 1.5;">
|
|
221
|
+
<strong>PT Dana Kripto Indonesia</strong><br />
|
|
222
|
+
sebagai peserta sandbox OJK dengan nomor surat
|
|
223
|
+
<strong>${sandboxNumber}</strong>
|
|
224
|
+
</td>
|
|
225
|
+
</tr>
|
|
226
|
+
</table>
|
|
227
|
+
</td>
|
|
228
|
+
</tr>
|
|
229
|
+
</table>
|
|
230
|
+
|
|
231
|
+
<!-- FOOTER SECTION -->
|
|
232
|
+
<tr>
|
|
233
|
+
<td style="
|
|
234
|
+
padding: 30px 40px 20px 40px;
|
|
235
|
+
background-color: #006950;
|
|
236
|
+
color: white;
|
|
237
|
+
font-size: 14px;
|
|
238
|
+
" class="responsive-padding-x">
|
|
239
|
+
${email.footer.logoUrl
|
|
240
|
+
? `<img src="${email.footer.logoUrl}" height="20"
|
|
241
|
+
style="margin-bottom: 8px" />`
|
|
242
|
+
: ""}
|
|
243
|
+
<p style="margin: 4px 0; font-weight: 700;">${email.footer.companyName || "PT. Company Indonesia"}</p>
|
|
244
|
+
${email.footer.address
|
|
245
|
+
? `<p style="margin: 4px 0">${email.footer.address}</p>`
|
|
246
|
+
: ""}
|
|
247
|
+
<table style="font-family:'Open Sans',sans-serif" role="presentation" cellpadding="0"
|
|
248
|
+
cellspacing="0" width="100%" border="0">
|
|
249
|
+
<tbody>
|
|
250
|
+
<tr>
|
|
251
|
+
<td style="word-break:break-word;padding-top:10px;font-family:'Open Sans',sans-serif"
|
|
252
|
+
align="left">
|
|
253
|
+
|
|
254
|
+
<div align="left" style="direction:ltr">
|
|
255
|
+
<div style="display:table;max-width:${email.footer.socialLinks &&
|
|
256
|
+
email.footer.socialLinks.length > 0
|
|
257
|
+
? email.footer.socialLinks.length * 37 + "px"
|
|
258
|
+
: "0"}">
|
|
259
|
+
${socialLinksHtml}
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
</td>
|
|
263
|
+
</tr>
|
|
264
|
+
</tbody>
|
|
265
|
+
</table>
|
|
266
|
+
</td>
|
|
267
|
+
</tr>
|
|
268
|
+
</table>
|
|
269
|
+
</td>
|
|
270
|
+
</tr>
|
|
271
|
+
</table>
|
|
272
|
+
</body>
|
|
273
|
+
</html>`;
|
|
274
|
+
return html.trim();
|
|
275
|
+
}
|