A tag function before a template literal receives a frozen array of the text parts (raw text in .raw), created once per call site, followed by the evaluated values. Since ES2018 invalid escapes there yield undefined, not an error.
const esc = (v) => String(v).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
const html = (strings, ...values) => strings.reduce((out, s, i) => out + esc(values[i - 1]) + s);
const name = '<img src=x onerror=alert(1)>';
console.log(html`<p title="${name}">Hi ${name}</p>`);
const text = 'e\u0301\u{1F44D}\u{1F3FD}'; // e + combining acute; thumbs up + skin tone
const graphemes = [...new Intl.Segmenter('en', { granularity: 'grapheme' }).segment(text)];
console.log(text.length, [...text].length, graphemes.length);
console.log(text.charCodeAt(2).toString(16), text.codePointAt(2).toString(16));<p title="<img src=x onerror=alert(1)>">Hi <img src=x onerror=alert(1)></p> 6 4 2 d83d 1f44d
length and indexes count UTF-16 code units; code points above U+FFFF take two. Spread, for...of and codePointAt() use code points. A user-perceived character is a grapheme cluster, counted by Intl.Segmenter (Other Intl Formatters).
