@limeade-labs/sparkui-icons 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Limeade Labs, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # sparkui-icons
2
+
3
+ Emoji → Lucide icon replacement library for SparkUI. Replaces 208 emoji characters with inline Lucide SVG icons in a single regex pass.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @limeade-labs/sparkui-icons
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```js
14
+ const { replace, lookup, has } = require('@limeade-labs/sparkui-icons');
15
+
16
+ // Replace emojis in HTML strings
17
+ const html = '<h1>⚡ Dashboard</h1><p>📊 Stats and 🔥 Performance</p>';
18
+ const result = replace(html);
19
+ // Emojis are replaced with inline Lucide SVGs
20
+
21
+ // Look up a single emoji
22
+ const entry = lookup('⚡');
23
+ // { name: 'zap', svg: '<svg class="lucide lucide-zap" ...' }
24
+
25
+ // Check if an emoji is mapped
26
+ has('⚡'); // true
27
+ has('🦄'); // false
28
+ ```
29
+
30
+ ## API
31
+
32
+ ### `replace(html, options?)`
33
+
34
+ Replace mapped emojis with inline SVGs.
35
+
36
+ **Options:**
37
+ | Option | Default | Description |
38
+ |--------|---------|-------------|
39
+ | `size` | `20` | Icon size in pixels |
40
+ | `class` | `'sparkui-icon'` | CSS class for icons |
41
+ | `preserveEmoji` | `true` | Keep emoji as `aria-label` for accessibility |
42
+ | `strokeWidth` | `2` | SVG stroke width |
43
+ | `variant` | `'outline'` | Rendering style: `'outline'`, `'filled'`, or `'duotone'` |
44
+ | `color` | — | Color for filled variant |
45
+ | `fillColor` | — | Fill color for duotone variant |
46
+ | `strokeColor` | — | Stroke color for duotone variant |
47
+ | `fillOpacity` | `0.35` | Fill opacity for duotone variant |
48
+ | `colorMap` | — | Per-emoji color overrides (import from `colors.js`) |
49
+
50
+ **Protected regions:** Content inside `<code>`, `<pre>`, `<textarea>`, and elements with `data-sparkui-no-replace` is never modified.
51
+
52
+ **Skin tones:** Automatically stripped before lookup (👍🏻 → 👍).
53
+
54
+ ### `lookup(emoji)` → `{ name, svg } | undefined`
55
+ ### `has(emoji)` → `boolean`
56
+ ### `entries()` → `Iterator<[emoji, { name, svg }]>`
57
+ ### `size()` → `number`
58
+
59
+ ## Performance
60
+
61
+ - < 2ms for realistic 50KB pages (~100 emojis)
62
+ - Pre-compiled regex and pre-built replacement strings
63
+ - Zero runtime file I/O
64
+ - Protected region detection via fast pre-check
65
+
66
+ ## Colored Circles
67
+
68
+ Circle emojis (🔴 🟢 🟡 🔵 ⚪ 🟠) are mapped to the Lucide `circle` icon with color-specific CSS classes:
69
+
70
+ ```css
71
+ .sparkui-icon.sparkui-red { color: #ef4444; }
72
+ .sparkui-icon.sparkui-green { color: #22c55e; }
73
+ .sparkui-icon.sparkui-yellow { color: #eab308; }
74
+ .sparkui-icon.sparkui-blue { color: #3b82f6; }
75
+ .sparkui-icon.sparkui-white { color: #e5e7eb; }
76
+ .sparkui-icon.sparkui-orange { color: #f97316; }
77
+ ```
78
+
79
+ ## Icon Mapping
80
+
81
+ 208 emojis mapped across categories: Navigation, Sections, Actions, Status, Health, Food, Finance, Travel, Education, Entertainment, Weather, Communication, and Misc.
82
+
83
+ ### Build Notes
84
+ - 🪣 → `paint-bucket` (lucide-static doesn't have `bucket`)
85
+ - ✅/❌ map to `circle-check`/`circle-x` (status versions, not `check-circle`/`x-circle`)
86
+ - Some emojis share icon names (🌮/🍴 → `utensils`, 🔧/🛠️ → `wrench`, etc.)
87
+
88
+ ## License
89
+
90
+ MIT
package/client.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * sparkui-icons client-side module
3
+ * MutationObserver for dynamic content replacement
4
+ */
5
+ (function () {
6
+ 'use strict';
7
+
8
+ if (typeof window === 'undefined' || typeof MutationObserver === 'undefined') return;
9
+
10
+ // Will be injected by SparkUI server or loaded separately
11
+ const sparkuiIcons = window.sparkuiIcons;
12
+ if (!sparkuiIcons) {
13
+ console.warn('[sparkui-icons] window.sparkuiIcons not found. Load the library first.');
14
+ return;
15
+ }
16
+
17
+ const observer = new MutationObserver((mutations) => {
18
+ for (const mutation of mutations) {
19
+ for (const node of mutation.addedNodes) {
20
+ if (node.nodeType !== 1) continue; // Element nodes only
21
+ if (node.closest('[data-sparkui-no-replace]')) continue;
22
+ if (['CODE', 'PRE', 'TEXTAREA'].includes(node.tagName)) continue;
23
+
24
+ // Replace emojis in the new node's HTML
25
+ const original = node.innerHTML;
26
+ const replaced = sparkuiIcons.replace(original);
27
+ if (replaced !== original) {
28
+ node.innerHTML = replaced;
29
+ }
30
+ }
31
+ }
32
+ });
33
+
34
+ // Start observing when DOM is ready
35
+ function init() {
36
+ observer.observe(document.body, {
37
+ childList: true,
38
+ subtree: true,
39
+ });
40
+ }
41
+
42
+ if (document.readyState === 'loading') {
43
+ document.addEventListener('DOMContentLoaded', init);
44
+ } else {
45
+ init();
46
+ }
47
+
48
+ // Expose for manual control
49
+ window.sparkuiIconsObserver = observer;
50
+ })();
package/colors.js ADDED
@@ -0,0 +1,302 @@
1
+ // Semantic color map for sparkui-icons duotone mode
2
+ // Each emoji gets a "natural" fill color for its duotone variant
3
+ 'use strict';
4
+
5
+ // Tailwind color palette
6
+ const RED = '#ef4444';
7
+ const ORANGE = '#f97316';
8
+ const AMBER = '#f59e0b';
9
+ const YELLOW = '#eab308';
10
+ const GREEN = '#10b981';
11
+ const TEAL = '#14b8a6';
12
+ const BLUE = '#3b82f6';
13
+ const INDIGO = '#6366f1';
14
+ const PURPLE = '#8b5cf6';
15
+ const PINK = '#ec4899';
16
+ const SLATE = '#64748b';
17
+ const WHITE = '#ffffff';
18
+
19
+ function c(fill) { return { fill, stroke: WHITE }; }
20
+
21
+ module.exports = {
22
+ // ── Navigation & UI ──
23
+ '⚡': c(AMBER),
24
+ '🔥': c(ORANGE),
25
+ '✅': c(GREEN),
26
+ '❌': c(RED),
27
+ '⚠️': c(YELLOW),
28
+ '💡': c(AMBER),
29
+ '🔔': c(AMBER),
30
+ '⭐': c(AMBER),
31
+ '🎯': c(RED),
32
+ '📌': c(RED),
33
+ '🔍': c(BLUE),
34
+ '➡️': c(BLUE),
35
+ '⬅️': c(BLUE),
36
+ '⬆️': c(GREEN),
37
+ '⬇️': c(RED),
38
+ '🔗': c(BLUE),
39
+ '📎': c(SLATE),
40
+ 'ℹ️': c(BLUE),
41
+ '❓': c(BLUE),
42
+ '❗': c(RED),
43
+ '🚀': c(ORANGE),
44
+ '💬': c(BLUE),
45
+ '📢': c(ORANGE),
46
+ '🏷️': c(BLUE),
47
+ '🔖': c(AMBER),
48
+
49
+ // ── Sections ──
50
+ '🏠': c(BLUE),
51
+ '👤': c(BLUE),
52
+ '👥': c(BLUE),
53
+ '⚙️': c(SLATE),
54
+ '📊': c(BLUE),
55
+ '📈': c(GREEN),
56
+ '📉': c(RED),
57
+ '💰': c(GREEN),
58
+ '🛒': c(GREEN),
59
+ '📱': c(BLUE),
60
+ '💻': c(BLUE),
61
+ '🔒': c(INDIGO),
62
+ '🔓': c(INDIGO),
63
+ '🛡️': c(PURPLE),
64
+ '📧': c(BLUE),
65
+ '📞': c(GREEN),
66
+ '🌐': c(BLUE),
67
+ '🗂️': c(AMBER),
68
+ '📁': c(AMBER),
69
+ '📄': c(BLUE),
70
+ '🖼️': c(PURPLE),
71
+ '🎨': c(PURPLE),
72
+ '🧩': c(PURPLE),
73
+ '📋': c(BLUE),
74
+ '🗓️': c(BLUE),
75
+
76
+ // ── Actions ──
77
+ '➕': c(GREEN),
78
+ '➖': c(RED),
79
+ '✏️': c(AMBER),
80
+ '🗑️': c(RED),
81
+ '📤': c(BLUE),
82
+ '📥': c(BLUE),
83
+ '🔄': c(BLUE),
84
+ '▶️': c(GREEN),
85
+ '⏸️': c(AMBER),
86
+ '⏹️': c(RED),
87
+ '⏭️': c(BLUE),
88
+ '⏮️': c(BLUE),
89
+ '🔊': c(BLUE),
90
+ '🔇': c(SLATE),
91
+ '📷': c(BLUE),
92
+ '🖨️': c(SLATE),
93
+ '💾': c(BLUE),
94
+ '↩️': c(SLATE),
95
+ '↪️': c(SLATE),
96
+ '📝': c(AMBER),
97
+ '🔀': c(BLUE),
98
+ '🔁': c(BLUE),
99
+ '✂️': c(SLATE),
100
+ '📑': c(BLUE),
101
+ '🔽': c(SLATE),
102
+
103
+ // ── Status ──
104
+ '⏳': c(AMBER),
105
+ '⏰': c(RED),
106
+ '🔴': c(RED),
107
+ '🟢': c(GREEN),
108
+ '🟡': c(YELLOW),
109
+ '🔵': c(BLUE),
110
+ '⚪': c(SLATE),
111
+ '🟠': c(ORANGE),
112
+ '✔️': c(GREEN),
113
+ '❎': c(RED),
114
+ '🆕': c(GREEN),
115
+ '🔜': c(BLUE),
116
+ '📶': c(GREEN),
117
+ '🔋': c(GREEN),
118
+ '💤': c(INDIGO),
119
+ '🏁': c(SLATE),
120
+ '📍': c(RED),
121
+ '📅': c(BLUE),
122
+ '📆': c(BLUE),
123
+ '🕐': c(BLUE),
124
+ '🕑': c(BLUE),
125
+ '🕒': c(BLUE),
126
+ '🕓': c(BLUE),
127
+ '🕔': c(BLUE),
128
+ '🕕': c(BLUE),
129
+ '🕖': c(BLUE),
130
+ '🕗': c(BLUE),
131
+ '🕘': c(BLUE),
132
+ '🕙': c(BLUE),
133
+ '🕚': c(BLUE),
134
+ '🕛': c(BLUE),
135
+
136
+ // ── Health ──
137
+ '🏋️': c(ORANGE),
138
+ '💪': c(ORANGE),
139
+ '🏃': c(ORANGE),
140
+ '🏃\u200D♂️': c(ORANGE),
141
+ '❤️': c(RED),
142
+ '💓': c(RED),
143
+ '🩺': c(RED),
144
+ '💊': c(RED),
145
+ '🏥': c(RED),
146
+ '🧘': c(GREEN),
147
+ '🍎': c(RED),
148
+ '🥗': c(GREEN),
149
+ '⚖️': c(BLUE),
150
+ '🩸': c(RED),
151
+ '🌡️': c(RED),
152
+ '😴': c(INDIGO),
153
+
154
+ // ── Food ──
155
+ '🍳': c(AMBER),
156
+ '🍕': c(ORANGE),
157
+ '☕': c(AMBER),
158
+ '🍺': c(AMBER),
159
+ '🍷': c(PURPLE),
160
+ '🥤': c(TEAL),
161
+ '🧁': c(PINK),
162
+ '🍔': c(ORANGE),
163
+ '🌮': c(ORANGE),
164
+ '🥩': c(RED),
165
+ '🍴': c(SLATE),
166
+ '🛎️': c(AMBER),
167
+ '📖': c(BLUE),
168
+ '⏲️': c(BLUE),
169
+ '🔪': c(SLATE),
170
+ '🍽️': c(SLATE),
171
+ '🥑': c(GREEN),
172
+ '🌾': c(AMBER),
173
+ '💧': c(TEAL),
174
+ '🧈': c(AMBER),
175
+ '🥓': c(RED),
176
+ '🧀': c(AMBER),
177
+ '🥚': c(AMBER),
178
+ '🍗': c(ORANGE),
179
+ '🥦': c(GREEN),
180
+ '🫒': c(GREEN),
181
+ '🧅': c(AMBER),
182
+ '🍜': c(ORANGE),
183
+ '🍲': c(ORANGE),
184
+ '🫐': c(INDIGO),
185
+ '🍫': c(AMBER),
186
+ '🍩': c(PINK),
187
+ '🍪': c(AMBER),
188
+
189
+ // ── Finance ──
190
+ '💳': c(BLUE),
191
+ '🏦': c(BLUE),
192
+ '💵': c(GREEN),
193
+ '🧾': c(SLATE),
194
+ '💹': c(GREEN),
195
+ '🏢': c(BLUE),
196
+ '👔': c(BLUE),
197
+ '📃': c(BLUE),
198
+ '🤝': c(BLUE),
199
+ '📐': c(SLATE),
200
+ '💎': c(PURPLE),
201
+ '🪙': c(AMBER),
202
+ '📦': c(AMBER),
203
+ '💸': c(GREEN),
204
+
205
+ // ── Travel ──
206
+ '✈️': c(BLUE),
207
+ '🚗': c(BLUE),
208
+ '🚕': c(AMBER),
209
+ '🚌': c(AMBER),
210
+ '🚂': c(SLATE),
211
+ '🚢': c(BLUE),
212
+ '🏨': c(BLUE),
213
+ '🗺️': c(GREEN),
214
+ '🧭': c(RED),
215
+ '⛽': c(ORANGE),
216
+ '🅿️': c(BLUE),
217
+ '🛫': c(BLUE),
218
+ '🛬': c(BLUE),
219
+ '🚲': c(GREEN),
220
+ '🏖️': c(AMBER),
221
+
222
+ // ── Education ──
223
+ '📚': c(BLUE),
224
+ '🎓': c(INDIGO),
225
+ '✍️': c(BLUE),
226
+ '🔬': c(INDIGO),
227
+ '🧪': c(GREEN),
228
+ '🌍': c(GREEN),
229
+ '🧮': c(SLATE),
230
+
231
+ // ── Entertainment ──
232
+ '🎮': c(PURPLE),
233
+ '🎵': c(PINK),
234
+ '🎬': c(SLATE),
235
+ '📺': c(BLUE),
236
+ '🎙️': c(SLATE),
237
+ '🎧': c(PURPLE),
238
+ '📻': c(SLATE),
239
+ '🎭': c(PURPLE),
240
+ '📸': c(BLUE),
241
+ '🎤': c(SLATE),
242
+ '🎪': c(ORANGE),
243
+ '🎲': c(PURPLE),
244
+ '🃏': c(INDIGO),
245
+ '🏆': c(AMBER),
246
+
247
+ // ── Weather ──
248
+ '☀️': c(AMBER),
249
+ '🌙': c(INDIGO),
250
+ '⛅': c(BLUE),
251
+ '🌧️': c(BLUE),
252
+ '❄️': c(TEAL),
253
+ '🌊': c(TEAL),
254
+ '🌪️': c(SLATE),
255
+ '⛈️': c(SLATE),
256
+ '🌈': c(PURPLE),
257
+ '🌲': c(GREEN),
258
+
259
+ // ── Communication ──
260
+ '💌': c(PINK),
261
+ '📩': c(BLUE),
262
+ '📨': c(BLUE),
263
+ '🗣️': c(BLUE),
264
+ '👁️': c(BLUE),
265
+ '🤖': c(BLUE),
266
+ '👋': c(AMBER),
267
+ '🙏': c(PINK),
268
+ '👍': c(GREEN),
269
+ '👎': c(RED),
270
+ '🔕': c(SLATE),
271
+ '📣': c(ORANGE),
272
+ '🗨️': c(BLUE),
273
+
274
+ // ── Expressions & Social ──
275
+ '🎉': c(AMBER),
276
+ '👏': c(AMBER),
277
+ '🙌': c(AMBER),
278
+ '😊': c(AMBER),
279
+ '😃': c(AMBER),
280
+ '😄': c(AMBER),
281
+ '🤔': c(PURPLE),
282
+
283
+ // ── Misc ──
284
+ '🎁': c(PINK),
285
+ '🧲': c(RED),
286
+ '🔧': c(SLATE),
287
+ '🔨': c(SLATE),
288
+ '⚒️': c(SLATE),
289
+ '🛠️': c(SLATE),
290
+ '🗝️': c(AMBER),
291
+ '🔑': c(AMBER),
292
+ '🧰': c(ORANGE),
293
+ '🪣': c(BLUE),
294
+ '🧹': c(SLATE),
295
+ '🔌': c(SLATE),
296
+ '🧬': c(GREEN),
297
+ '♻️': c(GREEN),
298
+ '⏱️': { fill: '#3b82f6', stroke: '#ffffff' },
299
+ '⏱': { fill: '#3b82f6', stroke: '#ffffff' },
300
+ '⌛': { fill: '#f59e0b', stroke: '#ffffff' },
301
+ '⌚': { fill: '#3b82f6', stroke: '#ffffff' },
302
+ };
package/index.js ADDED
@@ -0,0 +1,202 @@
1
+ 'use strict';
2
+
3
+ const { MAP, CIRCLE_COLORS } = require('./map');
4
+ const { EMOJI_REGEX } = require('./regex');
5
+
6
+ // Skin tone modifier range: U+1F3FB to U+1F3FF
7
+ const SKIN_TONE_RE = /[\u{1F3FB}-\u{1F3FF}]/gu;
8
+
9
+ // Check if string might contain protected elements (fast pre-check)
10
+ const HAS_PROTECTED_RE = /<(?:code|pre|textarea|script|style|meta)\b|data-sparkui-no-replace/i;
11
+
12
+ // Protected region regex
13
+ const PROTECTED_RE = /<(code|pre|textarea|script|style)\b[^>]*>[\s\S]*?<\/\1>|<(\w+)\b[^>]*data-sparkui-no-replace[^>]*>[\s\S]*?<\/\2>/gi;
14
+
15
+ // Pre-build default SVGs (size=20, class=sparkui-icon) for reuse
16
+ const DEFAULT_SVGS = new Map();
17
+ for (const [emoji, entry] of MAP) {
18
+ const colorClass = CIRCLE_COLORS.get(emoji);
19
+ const classes = colorClass ? `sparkui-icon ${colorClass}` : 'sparkui-icon';
20
+
21
+ let svg = entry.svg
22
+ .replace(/width="\d+"/, 'width="20"')
23
+ .replace(/height="\d+"/, 'height="20"');
24
+
25
+ if (/class="/.test(svg)) {
26
+ svg = svg.replace(/class="([^"]*)"/, (_, ex) => `class="${ex} ${classes}"`);
27
+ } else {
28
+ svg = svg.replace('<svg ', `<svg class="${classes}" `);
29
+ }
30
+
31
+ DEFAULT_SVGS.set(emoji, svg);
32
+ }
33
+
34
+ // Pre-build default replacements (outline, no variant)
35
+ const DEFAULT_REPLACEMENTS = new Map();
36
+ for (const [emoji, svg] of DEFAULT_SVGS) {
37
+ DEFAULT_REPLACEMENTS.set(emoji, `<span role="img" aria-label="${emoji}" class="sparkui-icon-wrap">${svg}</span>`);
38
+ }
39
+
40
+ // Build a sorted list of emoji keys for scanning (longest first)
41
+ const EMOJI_KEYS = [...MAP.keys()].sort((a, b) => b.length - a.length);
42
+ const MAX_EMOJI_LEN = EMOJI_KEYS[0].length;
43
+
44
+ // Build a Set of first characters that could start an emoji
45
+ const EMOJI_FIRST_CHARS = new Set();
46
+ for (const key of EMOJI_KEYS) {
47
+ const cp = key.codePointAt(0);
48
+ EMOJI_FIRST_CHARS.add(cp);
49
+ }
50
+
51
+ // Fast scan-and-replace using regex (pre-compiled) with pre-built replacement map
52
+ function fastReplace(str, replacements) {
53
+ EMOJI_REGEX.lastIndex = 0;
54
+ return str.replace(EMOJI_REGEX, (match) => {
55
+ const direct = replacements.get(match);
56
+ if (direct) return direct;
57
+ const stripped = match.replace(SKIN_TONE_RE, '');
58
+ return replacements.get(stripped) || match;
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Build variant wrapper attributes for a single emoji.
64
+ */
65
+ function buildVariantSpan(emoji, svg, variant, opts) {
66
+ const colorMap = opts.colorMap;
67
+ const override = colorMap && colorMap[emoji];
68
+
69
+ let wrapClass = 'sparkui-icon-wrap';
70
+ const styleParts = [];
71
+
72
+ if (variant === 'filled') {
73
+ wrapClass += ' sparkui-filled';
74
+ const color = (override && (override.fill || override.color)) || opts.color;
75
+ if (color) styleParts.push(`--si-color:${color}`);
76
+ } else if (variant === 'duotone') {
77
+ wrapClass += ' sparkui-duotone';
78
+ const fill = (override && override.fill) || opts.fillColor;
79
+ const stroke = (override && override.stroke) || opts.strokeColor;
80
+ const opacity = (override && override.fillOpacity != null) ? override.fillOpacity : opts.fillOpacity;
81
+ if (fill) styleParts.push(`--si-fill:${fill}`);
82
+ if (stroke) styleParts.push(`--si-stroke:${stroke}`);
83
+ if (opacity != null) styleParts.push(`--si-fill-opacity:${opacity}`);
84
+ }
85
+
86
+ const wrapStyle = styleParts.length ? ` style="${styleParts.join(';')}"` : '';
87
+ return `<span role="img" aria-label="${emoji}" class="${wrapClass}"${wrapStyle}>${svg}</span>`;
88
+ }
89
+
90
+ /**
91
+ * Replace mapped emojis with inline Lucide SVGs in an HTML string.
92
+ */
93
+ function replace(html, options) {
94
+ if (!html || typeof html !== 'string') return html;
95
+
96
+ const hasVariant = options && (options.variant || options.colorMap || options.color || options.fillColor || options.strokeColor || options.fillOpacity != null);
97
+
98
+ const isDefault = !options || (!hasVariant && (
99
+ (options.size === undefined || options.size === 20) &&
100
+ (options.class === undefined || options.class === 'sparkui-icon') &&
101
+ (options.preserveEmoji === undefined || options.preserveEmoji === true) &&
102
+ (options.strokeWidth === undefined || options.strokeWidth === 2)
103
+ ));
104
+
105
+ let replacements;
106
+ if (isDefault) {
107
+ replacements = DEFAULT_REPLACEMENTS;
108
+ } else {
109
+ const opts = { size: 20, class: 'sparkui-icon', preserveEmoji: true, strokeWidth: 2, ...options };
110
+ const variant = opts.variant || 'outline';
111
+ const isDefaultSvg = opts.size === 20 && opts.class === 'sparkui-icon' && opts.strokeWidth === 2;
112
+
113
+ replacements = new Map();
114
+ for (const [emoji, entry] of MAP) {
115
+ // Reuse pre-built SVGs when size/class/strokeWidth are defaults
116
+ let svg;
117
+ if (isDefaultSvg) {
118
+ svg = DEFAULT_SVGS.get(emoji);
119
+ } else {
120
+ const colorClass = CIRCLE_COLORS.get(emoji);
121
+ const classes = colorClass ? `${opts.class} ${colorClass}` : opts.class;
122
+ svg = entry.svg
123
+ .replace(/width="\d+"/, `width="${opts.size}"`)
124
+ .replace(/height="\d+"/, `height="${opts.size}"`)
125
+ .replace(/stroke-width="\d+"/, `stroke-width="${opts.strokeWidth}"`);
126
+ if (/class="/.test(svg)) {
127
+ svg = svg.replace(/class="([^"]*)"/, (_, ex) => `class="${ex} ${classes}"`);
128
+ } else {
129
+ svg = svg.replace('<svg ', `<svg class="${classes}" `);
130
+ }
131
+ }
132
+
133
+ if (opts.preserveEmoji) {
134
+ if (variant === 'outline' && !opts.colorMap) {
135
+ // Outline with no colorMap — same as default wrapper
136
+ replacements.set(emoji, `<span role="img" aria-label="${emoji}" class="sparkui-icon-wrap">${svg}</span>`);
137
+ } else {
138
+ replacements.set(emoji, buildVariantSpan(emoji, svg, variant, opts));
139
+ }
140
+ } else {
141
+ replacements.set(emoji, svg);
142
+ }
143
+ }
144
+ }
145
+
146
+ // Strategy: only replace emojis in text nodes (between HTML tags),
147
+ // never inside <tag attributes>, <script>, <style>, <code>, <pre>, <textarea>.
148
+
149
+ // Step 1: Protect block-level regions (script, style, code, pre, textarea, no-replace)
150
+ const regions = [];
151
+ PROTECTED_RE.lastIndex = 0;
152
+ let workHtml = html.replace(PROTECTED_RE, (match) => {
153
+ const idx = regions.length;
154
+ regions.push(match);
155
+ return `\x00P${idx}\x00`;
156
+ });
157
+
158
+ // Step 2: Split by HTML tags — only replace in text segments, not inside <...>
159
+ // This prevents replacing emojis inside attribute values (content="...", title="...", etc.)
160
+ const TAG_RE = /<[^>]*>/g;
161
+ let result = '';
162
+ let lastIdx = 0;
163
+ let tagMatch;
164
+ TAG_RE.lastIndex = 0;
165
+ while ((tagMatch = TAG_RE.exec(workHtml)) !== null) {
166
+ // Text before this tag — safe to replace
167
+ if (tagMatch.index > lastIdx) {
168
+ result += fastReplace(workHtml.slice(lastIdx, tagMatch.index), replacements);
169
+ }
170
+ // The tag itself — do NOT replace
171
+ result += tagMatch[0];
172
+ lastIdx = TAG_RE.lastIndex;
173
+ }
174
+ // Remaining text after last tag
175
+ if (lastIdx < workHtml.length) {
176
+ result += fastReplace(workHtml.slice(lastIdx), replacements);
177
+ }
178
+
179
+ // Step 3: Restore protected regions
180
+ return result.replace(/\x00P(\d+)\x00/g, (_, idx) => regions[parseInt(idx)]);
181
+ }
182
+
183
+ /**
184
+ * Look up the icon entry for a single emoji.
185
+ */
186
+ function lookup(emoji) {
187
+ const stripped = emoji.replace(SKIN_TONE_RE, '');
188
+ return MAP.get(stripped);
189
+ }
190
+
191
+ /**
192
+ * Check if an emoji has a mapped icon.
193
+ */
194
+ function has(emoji) {
195
+ const stripped = emoji.replace(SKIN_TONE_RE, '');
196
+ return MAP.has(stripped);
197
+ }
198
+
199
+ function entries() { return MAP.entries(); }
200
+ function size() { return MAP.size; }
201
+
202
+ module.exports = { replace, lookup, has, entries, size, MAP, CIRCLE_COLORS, EMOJI_REGEX };