@sdxc/mail 2026.10.9 → 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.
@@ -7,26 +7,19 @@
7
7
  * @author [Sergio Xalambrí](https://sergiodxa.com)
8
8
  * @copyright Sergio Xalambrí 2026
9
9
  */
10
- /** HTML comments, which never carry reader-visible content. */
11
- const COMMENT = /<!--[\s\S]*?-->/g;
12
- /** A document type declaration, which describes the document format. */
13
- const DOCTYPE = /<!doctype[^>]*>/gi;
14
- /**
15
- * Blocks hidden from sighted readers with `display:none`, such as a preheader.
16
- * Non-greedy matching closes at the first same-tag closing tag, which is correct
17
- * for the hidden single-element blocks email layouts use.
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
- * Blocks marked `data-skip-in-text`, the explicit signal an author gives that an
22
- * element belongs to the HTML part alone, covering visible content such as a
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 SKIPPED_BLOCK = /<(\w+)\b[^>]*\bdata-skip-in-text\b[^>]*>[\s\S]*?<\/\1>/gi;
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
- /** Removes markup from a fragment that has already had its structure applied. */
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
- return html.replace(TAG, "");
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/mail",
3
- "version": "2026.10.9",
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.9",
17
- "@sdxc/highlight": "2026.10.9",
18
- "@sdxc/logger": "2026.10.9",
19
- "@sdxc/markdown": "2026.10.9",
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": "64fa732ed0a8ca8693a35aeef6345ae5be2534ef",
23
+ "gitHead": "b61f19c59866708a2d89a5a8f4655ebbc16941b0",
24
24
  "publishConfig": {
25
25
  "access": "public"
26
26
  },