diff --git a/src/components/Player/ClueText.js b/src/components/Player/ClueText.js
deleted file mode 100644
index 8038ceaf..00000000
--- a/src/components/Player/ClueText.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import React from 'react';
-
-function decodeHtml(htmlText) {
- let text = document.createElement('textarea');
- text.innerHTML = htmlText;
- let decodedText = text.value;
-
- // Check if the text should be italicized and remove double quotes from the start and end if they encase the entire string
- if (shouldItalicize(decodedText)) {
- decodedText = decodedText.replace(/^"(.*)"$/, '$1');
- }
-
- return decodedText;
-}
-
-function shouldItalicize(text) {
- const doubleQuotePattern = /^"{2}.*"{2}$/;
- return doubleQuotePattern.test(text);
-}
-
-export default ({text = ''}) => {
- const isItalic = shouldItalicize(text);
- const parts = [];
- if (shouldItalicize(text)) {
- parts.push({
- text: text,
- ital: true,
- });
- } else {
- while (text.length > 0) {
- const s = text.indexOf('');
- const e = text.indexOf('');
- if (s === 0 && e !== -1) {
- parts.push({
- text: text.substring(3, e),
- ital: true,
- });
- text = text.substring(e + 4);
- } else if (s !== -1) {
- parts.push({
- text: text.substring(0, s),
- });
- text = text.substring(s);
- } else {
- parts.push({
- text,
- });
- text = '';
- }
- }
- }
-
- return (
- <>
- {parts.map(({text, ital}, i) => (
-
- {decodeHtml(text)}
-
- ))}
- >
- );
-};
diff --git a/src/components/Player/ClueText.ts b/src/components/Player/ClueText.ts
new file mode 100644
index 00000000..11e760ed
--- /dev/null
+++ b/src/components/Player/ClueText.ts
@@ -0,0 +1,75 @@
+import { createElement, Fragment, ReactNode } from 'react'
+
+type Tree =
+ | { name?: string; children: Tree[] }
+ | { name: 'text'; value: string }
+
+// parse HTML by creating a template element and walking its tree
+// keep only elements and their contents (i.e. no attributes)
+const simpleParse = (clue: string): Tree => {
+ const template = document.createElement('template')
+ template.innerHTML = clue
+
+ const tree: Tree = { children: [] }
+ const stack: [Tree, Node][] = [[tree, template.content]]
+
+ while (stack.length) {
+ const [parent, node] = stack.pop()!
+
+ // we never push text nodes onto the stack, so this should not happen
+ if (!('children' in parent)) throw new Error('tree invariant broken')
+ if (!node.hasChildNodes()) continue
+
+ for (let i = 0; i < node.childNodes.length; i++) {
+ const child = node.childNodes[i]
+ if (child.nodeType === Node.TEXT_NODE) {
+ parent.children.push({
+ name: 'text',
+ value: child.nodeValue!,
+ })
+ } else if (child.nodeType === Node.ELEMENT_NODE) {
+ const { tagName } = child as Element
+ const treeChild: Tree = {
+ name: tagName.toLowerCase(),
+ children: [],
+ }
+ parent.children.push(treeChild)
+ stack.push([treeChild, child])
+ }
+ }
+ }
+
+ template.remove()
+
+ return tree
+}
+
+// render allowed elements into React elements
+const simpleRender = (tree: Tree, allowed: string[]): ReactNode => {
+ if (tree.name === 'text') return 'value' in tree ? tree.value : ''
+
+ // if the name is not 'text' then it is guaranteed that `children` exists
+ if (!('children' in tree)) throw new Error('unreachable')
+
+ const children = tree.children.map((child) => simpleRender(child, allowed))
+ if (tree.name !== undefined && allowed.includes(tree.name)) {
+ return createElement(tree.name, {}, ...children)
+ } else {
+ return createElement(Fragment, {}, ...children)
+ }
+}
+
+export default ({ text = '' }): JSX.Element => {
+ // case where we should italicize the whole clue
+ if (text.startsWith('""') && text.endsWith('""')) {
+ return createElement('i', {}, text.slice(1, -1))
+ }
+
+ // fast path for text with no HTML and no entities
+ if (!text.match(/[<>]|&[^;]+;/)) return createElement('span', {}, text)
+
+ // otherwise, parse HTML and render allowed elements
+ const allowed = ['em', 'strong', 'u', 'i', 'b', 'sup', 'sub']
+ const tree = simpleParse(text)
+ return createElement('span', {}, simpleRender(tree, allowed))
+}