@sdxc/mail 2026.10.6 → 2026.10.10
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/dist/lib/html-to-text.js +93 -29
- package/dist/markdown.js +3 -0
- package/package.json +6 -6
package/dist/lib/html-to-text.js
CHANGED
|
@@ -7,26 +7,19 @@
|
|
|
7
7
|
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
8
8
|
* @copyright Sergio Xalambrí 2026
|
|
9
9
|
*/
|
|
10
|
-
/**
|
|
11
|
-
const
|
|
12
|
-
/**
|
|
13
|
-
const
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
*/
|
|
19
|
-
const HIDDEN_BLOCK = /<(div|span|p)\b[^>]*display\s*:\s*none[^>]*>[\s\S]*?<\/\1>/gi;
|
|
10
|
+
/** Elements whose content belongs to the document structure. */
|
|
11
|
+
const DROPPED_ELEMENTS = new Set(["head", "script", "style", "title"]);
|
|
12
|
+
/** Elements an email layout hides with `display:none`, such as a preheader. */
|
|
13
|
+
const HIDEABLE_ELEMENTS = new Set(["div", "span", "p"]);
|
|
14
|
+
/** The name an opening tag starts with, lowercased by the caller. */
|
|
15
|
+
const OPENING_NAME = /^<(\w+)/;
|
|
16
|
+
/** An inline style that hides an element from sighted readers. */
|
|
17
|
+
const DISPLAY_NONE = /display\s*:\s*none/i;
|
|
20
18
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* decorative rule, a spacer, or a logo's alt text.
|
|
19
|
+
* The explicit signal an author gives that an element belongs to the HTML part
|
|
20
|
+
* alone, covering visible content such as a decorative rule, a spacer, or a logo's alt text.
|
|
24
21
|
*/
|
|
25
|
-
const
|
|
26
|
-
/** The same marker on an element that closes itself, which has nothing to drop between tags. */
|
|
27
|
-
const SKIPPED_VOID = /<\w+\b[^>]*\bdata-skip-in-text\b[^>]*\/?>/gi;
|
|
28
|
-
/** Elements whose content belongs to the document structure. */
|
|
29
|
-
const DROPPED_BLOCK = /<(head|script|style|title)\b[^>]*>[\s\S]*?<\/\1>/gi;
|
|
22
|
+
const SKIP_IN_TEXT = /\bdata-skip-in-text\b/i;
|
|
30
23
|
/** Explicit line breaks, the one inline element that carries layout meaning. */
|
|
31
24
|
const LINE_BREAK = /<br\s*\/?>/gi;
|
|
32
25
|
/** A link with its target, captured as double-quoted, single-quoted, or bare. */
|
|
@@ -52,8 +45,6 @@ const PARAGRAPH_BOUNDARY = /<\/?(p|div|h[1-6]|table|ul|ol|dl|blockquote|section|
|
|
|
52
45
|
const CELL_END = /<\/(td|th)\s*>/gi;
|
|
53
46
|
/** End of a table row or definition entry, which ends the line so the next row starts immediately below it. */
|
|
54
47
|
const ROW_END = /<\/(tr|dt|dd|caption)\s*>/gi;
|
|
55
|
-
/** Any remaining tag, dropped once its structural meaning has been applied. */
|
|
56
|
-
const TAG = /<[^>]+>/g;
|
|
57
48
|
/** A character reference in named, decimal, or hexadecimal form. */
|
|
58
49
|
const ENTITY = /&(#\d+|#x[0-9a-f]+|[a-z]+);/gi;
|
|
59
50
|
/** Named references worth decoding: the ones a renderer emits or copy commonly uses. */
|
|
@@ -72,9 +63,88 @@ const NAMED_ENTITIES = {
|
|
|
72
63
|
const HORIZONTAL_WHITESPACE = /[ \t\r\f\v\u00a0]+/g;
|
|
73
64
|
/** Three or more newlines, collapsed so structure never turns into empty screens. */
|
|
74
65
|
const EXTRA_NEWLINES = /\n{3,}/g;
|
|
75
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Removes markup from a fragment that has already had its structure applied. Each
|
|
68
|
+
* tag runs from a `<` to the next `>`, and a `<` with no `>` after it stays as text,
|
|
69
|
+
* so one forward pass leaves nothing that reads as a tag.
|
|
70
|
+
*/
|
|
76
71
|
function stripTags(html) {
|
|
77
|
-
|
|
72
|
+
let out = "";
|
|
73
|
+
let index = 0;
|
|
74
|
+
while (index < html.length) {
|
|
75
|
+
let open = html.indexOf("<", index);
|
|
76
|
+
if (open === -1)
|
|
77
|
+
break;
|
|
78
|
+
let close = html.indexOf(">", open + 1);
|
|
79
|
+
if (close === -1)
|
|
80
|
+
break;
|
|
81
|
+
out += html.slice(index, open);
|
|
82
|
+
index = close + 1;
|
|
83
|
+
}
|
|
84
|
+
return out + html.slice(index);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The element an opening tag starts that the text part drops together with its
|
|
88
|
+
* content, or `null` when the tag stays. A document type declaration counts as an
|
|
89
|
+
* element with no content.
|
|
90
|
+
*/
|
|
91
|
+
function droppedElement(tag) {
|
|
92
|
+
let lower = tag.toLowerCase();
|
|
93
|
+
if (lower.startsWith("<!doctype"))
|
|
94
|
+
return "";
|
|
95
|
+
let name = OPENING_NAME.exec(lower)?.[1];
|
|
96
|
+
if (name === undefined)
|
|
97
|
+
return null;
|
|
98
|
+
if (DROPPED_ELEMENTS.has(name) || SKIP_IN_TEXT.test(tag))
|
|
99
|
+
return name;
|
|
100
|
+
if (HIDEABLE_ELEMENTS.has(name) && DISPLAY_NONE.test(tag))
|
|
101
|
+
return name;
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Drops comments, the doctype, and every element a reader cannot see in one forward
|
|
106
|
+
* pass, so time stays linear and removing a region never joins its neighbours into a
|
|
107
|
+
* new tag. An element ends at its first same-name closing tag, or loses only its opening tag.
|
|
108
|
+
*/
|
|
109
|
+
function dropInvisible(html) {
|
|
110
|
+
let lower = html.toLowerCase();
|
|
111
|
+
let closings = new Map();
|
|
112
|
+
let out = "";
|
|
113
|
+
let index = 0;
|
|
114
|
+
while (index < html.length) {
|
|
115
|
+
let open = html.indexOf("<", index);
|
|
116
|
+
if (open === -1)
|
|
117
|
+
break;
|
|
118
|
+
out += html.slice(index, open);
|
|
119
|
+
if (html.startsWith("<!--", open)) {
|
|
120
|
+
let end = html.indexOf("-->", open + 4);
|
|
121
|
+
index = end === -1 ? html.length : end + 3;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
let close = html.indexOf(">", open + 1);
|
|
125
|
+
if (close === -1) {
|
|
126
|
+
index = open;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
let tag = html.slice(open, close + 1);
|
|
130
|
+
let name = droppedElement(tag);
|
|
131
|
+
index = close + 1;
|
|
132
|
+
if (name === null) {
|
|
133
|
+
out += tag;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (name === "" || tag.endsWith("/>"))
|
|
137
|
+
continue;
|
|
138
|
+
let closing = `</${name}>`;
|
|
139
|
+
let found = closings.get(name);
|
|
140
|
+
if (found === undefined || (found !== -1 && found < index)) {
|
|
141
|
+
found = lower.indexOf(closing, index);
|
|
142
|
+
closings.set(name, found);
|
|
143
|
+
}
|
|
144
|
+
if (found !== -1)
|
|
145
|
+
index = found + closing.length;
|
|
146
|
+
}
|
|
147
|
+
return out + html.slice(index);
|
|
78
148
|
}
|
|
79
149
|
/** Resolves character references to the characters they stand for, leaving unknown ones intact. */
|
|
80
150
|
function decodeEntities(text) {
|
|
@@ -118,13 +188,7 @@ function formatLink(href, label) {
|
|
|
118
188
|
* @example htmlToText('<p>Hi <a href="https://x.dev">here</a></p>'); // "Hi here (https://x.dev)"
|
|
119
189
|
*/
|
|
120
190
|
export function htmlToText(html) {
|
|
121
|
-
let text = html
|
|
122
|
-
.replace(COMMENT, "")
|
|
123
|
-
.replace(DOCTYPE, "")
|
|
124
|
-
.replace(HIDDEN_BLOCK, "")
|
|
125
|
-
.replace(SKIPPED_BLOCK, "")
|
|
126
|
-
.replace(SKIPPED_VOID, "")
|
|
127
|
-
.replace(DROPPED_BLOCK, "")
|
|
191
|
+
let text = dropInvisible(html)
|
|
128
192
|
.replace(LINE_BREAK, "\n")
|
|
129
193
|
.replace(ANCHOR, (_match, _target, double, single, bare, label) => formatLink(double ?? single ?? bare ?? "", stripTags(label)))
|
|
130
194
|
.replace(IMAGE, (_match, _quoted, double, single) => {
|
package/dist/markdown.js
CHANGED
|
@@ -77,6 +77,7 @@ function textOfNode(node) {
|
|
|
77
77
|
case "footnoteReference":
|
|
78
78
|
return `[${node.identifier}]`;
|
|
79
79
|
case "thematicBreak":
|
|
80
|
+
case "comment":
|
|
80
81
|
return "";
|
|
81
82
|
default:
|
|
82
83
|
return textOf([...node.children]);
|
|
@@ -167,6 +168,8 @@ function convert(node, key) {
|
|
|
167
168
|
return _jsx("sup", { style: "font-size:0.75em;line-height:1;", children: `[${node.identifier}]` }, key);
|
|
168
169
|
case "variable":
|
|
169
170
|
return `{% $${node.name} %}`;
|
|
171
|
+
case "comment":
|
|
172
|
+
return null;
|
|
170
173
|
}
|
|
171
174
|
}
|
|
172
175
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/mail",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.10",
|
|
4
4
|
"description": "Transport-agnostic transactional email with pluggable transports and remix/component rendering",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
"./unsubscribe": "./dist/unsubscribe.js"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@sdxc/crypto": "2026.10.
|
|
17
|
-
"@sdxc/highlight": "2026.10.
|
|
18
|
-
"@sdxc/logger": "2026.10.
|
|
19
|
-
"@sdxc/markdown": "2026.10.
|
|
16
|
+
"@sdxc/crypto": "2026.10.10",
|
|
17
|
+
"@sdxc/highlight": "2026.10.10",
|
|
18
|
+
"@sdxc/logger": "2026.10.10",
|
|
19
|
+
"@sdxc/markdown": "2026.10.10",
|
|
20
20
|
"@sdxc/result": "2026.10.6",
|
|
21
21
|
"remix": "3.0.0"
|
|
22
22
|
},
|
|
23
|
-
"gitHead": "
|
|
23
|
+
"gitHead": "b61f19c59866708a2d89a5a8f4655ebbc16941b0",
|
|
24
24
|
"publishConfig": {
|
|
25
25
|
"access": "public"
|
|
26
26
|
},
|