i built a chrome extension called RosterBlur. teacher pastes a class roster into the options page, and from then on every visible occurrence of a student name gets blurred on every page. gradebooks, Google Classroom, PowerSchool, email. the point is you can project your screen or record a meeting without leaking names.
the free half is click-to-blur, where you click an element and it goes fuzzy. that part was easy. the roster part is where i spent most of the week, because "find every occurrence of this name in the page" sounds like a one-liner and is not.
i wrote about rewriting the name matcher yesterday. this one goes further into the unicode side of it, including a false positive i only found afterwards, and then gets to the bug that actually shipped, which i hadn't caught yet when i wrote that post.
the first version was a regex and it was wrong twice
my first pass was what you'd expect. build one big alternation of the roster names, slap \b on both ends, run it over each text node.
const re = new RegExp("\\b(?:" + names.map(escapeRe).join("|") + ")\\b", "giu");
this fails in two directions at once, and both failures are the kind that show up in front of a classroom instead of in your terminal.
failure one: it matches too much. say the roster has a student named Ana. the page has a different student, Anaïs.
/\bAna\b/iu.test("Anaïs Dupont") // true
\b is defined against ASCII word characters. ï is not one of them, so as far as \b is concerned there's a boundary sitting right there between the a and the ï. Anaïs gets the front of her name blurred because someone else on the roster is named Ana. the u flag does not save you here, i checked.
failure two: it matches too little, or rather, matching on parts is dangerous in the other direction. if you let single names match, "May" fires inside "Maybelle Chenoweth" and now you've blurred a word that isn't a name at all.
so the boundary can't be \b. it has to be "not adjacent to any letter or number, in the unicode sense":
const source =
"(?<![\\p{L}\\p{N}])(?:" + alternatives.join("|") + ")(?![\\p{L}\\p{N}])";
const regex = new RegExp(source, "giu");
\p{L} is any unicode letter, \p{N} is any unicode number. now Anaïs is safe (the ï after Ana is a letter, so the lookahead kills the match) and a real standalone "Ana" still matches fine. same guard stops "May" from firing inside "Maybelle".
one more thing in there worth stealing: i sort the alternatives longest-first before joining them.
alternatives.sort((a, b) => b.length - a.length);
regex alternation is first-match-wins, not longest-match-wins. without that sort, a roster entry that produced both "Chen" and "May Chen" would match the short one first and leave "May " sitting there in the clear.
the accent problem, and the reason the fold is per character
teachers type "Jose" into the roster. the gradebook says "José". that has to match, so the text gets folded (lowercased, accents stripped) before matching.
the catch is what happens after you find a match. i'm not rewriting the page text. RosterBlur never touches the DOM's text content, because the moment you rewrite text inside a web app you risk that app saving your rewrite back to its own database. instead it measures where the match is and paints an overlay over those exact characters. that means the index of a match in the folded string has to be the index of that match in the original string. if folding shifts anything by even one character, the blur lands in the wrong place.
so the fold is done one character at a time, and it is allowed to bail:
const foldChar = (ch) => {
if (QUOTE_FOLD[ch]) return QUOTE_FOLD[ch];
const de = ch.normalize("NFD").replace(/[̀-ͯ]/g, "");
return de.length === 1 ? de : ch; // never change the length
};
const foldText = (text) => {
let out = "";
for (const ch of String(text)) out += ch.length === 1 ? foldChar(ch) : ch;
return out;
};
de.length === 1 ? de : ch is the whole invariant. if decomposing and stripping a character does not give back exactly one character, keep the original character. one char in, one char out, always. and for...of hands you surrogate pairs as two-length strings, so emoji fall through the ch.length === 1 check untouched, which keeps both the indices and the emoji.
then matching runs on the folded copy and slices out of the original:
const folded = foldText(original);
// ...
out.push({ start, end, text: original.slice(start, end), student: /* ... */ });
honest footnote, because i went back and actually tested this while writing this post: for every Latin name i threw at it, the lazy version (normalize the whole string, then strip) also preserves length. José, Nguyễn, Ólafsdóttir, all fine. so the per-character guard is insurance rather than a fix for a bug i personally hit. i'm keeping it, because "the indices line up" is now a property of the code instead of a lucky property of the names my users happen to have.
the bug that actually shipped
none of the above is what broke in the real world. version 1.1.1 fixed this:
manifest content scripts only attach to pages that load after the extension starts. so every tab a teacher already had open when they installed RosterBlur had no content script in it, the popup buttons were dead on those tabs, and the fix from the user's side was to refresh a page they had no reason to think needed refreshing.
the service worker now injects into everything that's already open:
const injectIntoOpenTabs = async () => {
const tabs = await chrome.tabs.query({});
await Promise.allSettled(tabs.map((tab) => chrome.scripting.executeScript({
target: { tabId: tab.id, allFrames: true },
files: ["shared.js", "contentScript.js"]
}).catch(() => { /* chrome:// and the web store reject, that's fine */ })));
};
chrome.runtime.onInstalled.addListener(() => { injectIntoOpenTabs(); });
that cost me the scripting permission and host_permissions in the manifest, which is a real price at store review time even though site access didn't actually change (the content script already matched all urls).
what's still not great
- the roster cap is 1000 names, picked because it felt like enough, not because i measured where it starts to hurt.
- the URL guard is a heuristic. it drops matches that live inside a link-looking token so
example.com/maria-lopezstays readable, and i'm sure there's a page layout where that guard eats a real name. - standalone first-or-last-name matching is opt-in and off by default, because the false positive rate on common names is bad. a student named Grace means the word "grace" gets blurred everywhere.
- nothing here helps with a name rendered inside a canvas or an image. it's DOM text or nothing.
51 unit tests and an 18 check live browser suite, both green. everything runs on device, zero network requests from the extension, and you can verify that claim yourself since there's no fetch or XHR or WebSocket anywhere in the shipped code.
code: https://github.com/TiltedLunar123/rosterblur
if you know a cleaner way to keep fold indices aligned than "refuse to fold anything that isn't 1:1", i'd genuinely like to hear it.
Top comments (0)