diff --git a/index.html b/index.html
index 33e12a2..909a99a 100644
--- a/index.html
+++ b/index.html
@@ -55,8 +55,10 @@
.json-value-null { color: #be123c; font-style: italic; }
.json-empty { padding: .25rem .65rem; color: #475569; font-style: italic; }
.json-long-value { border-inline-start: 2px solid #94a3b8; margin-inline-start: .5rem; }
- .json-long-value > summary { cursor: pointer; padding: .3rem .45rem; overflow-wrap: anywhere; }
+ .json-long-value > summary, .json-raw-value > summary { cursor: pointer; padding: .3rem .45rem; overflow-wrap: anywhere; }
.json-long-text { margin: .2rem .5rem .5rem; padding: .5rem; max-height: 24rem; overflow: auto; background: #e2e8f0; white-space: pre-wrap; overflow-wrap: anywhere; }
+ .json-raw-value { margin-inline-start: .5rem; color: #475569; font-size: .9em; }
+ .json-raw-value > .json-long-text { border-inline-start: 2px solid #94a3b8; }
@media (max-width: 42rem) { .json-entry { grid-template-columns: 1fr; gap: .05rem; } }
@media (prefers-color-scheme: dark) {
.json-view { border-color: #475569; background: #111827; color: #e5e7eb; }
@@ -69,6 +71,7 @@
.json-value-number { color: #c4b5fd; }
.json-value-boolean { color: #fcd34d; }
.json-value-null { color: #fda4af; }
+ .json-raw-value { color: #cbd5e1; }
}
#admin-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; }
#player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; }
@@ -656,6 +659,24 @@
parent.append(element);
return element;
}
+ function decodeEscapedSequences(text) {
+ const source = String(text).replace(/\\(?:\r\n|\n|\r)[ \t]*/g, '');
+ const escapes = {
+ '0': '\0', a: '\x07', b: '\b', t: '\t', n: '\n', v: '\x0b',
+ f: '\f', r: '\r', e: '\x1b', ' ': ' ', '"': '"', "'": "'",
+ '/': '/', '\\': '\\', N: '\u0085', _: '\u00a0', L: '\u2028', P: '\u2029',
+ };
+ return source.replace(/\\(U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g, (sequence, escape) => {
+ if (Object.hasOwn(escapes, escape)) return escapes[escape];
+ if (/^x[0-9a-fA-F]{2}$/.test(escape)) return String.fromCharCode(Number.parseInt(escape.slice(1), 16));
+ if (/^u[0-9a-fA-F]{4}$/.test(escape)) return String.fromCharCode(Number.parseInt(escape.slice(1), 16));
+ if (/^U[0-9a-fA-F]{8}$/.test(escape)) {
+ const point = Number.parseInt(escape.slice(1), 16);
+ if (point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff)) return String.fromCodePoint(point);
+ }
+ return sequence;
+ });
+ }
function renderJsonTree(container, value) {
container.className = 'json-view';
container.setAttribute('aria-label', 'Utfällbart JSON-svar');
@@ -677,7 +698,19 @@
const groupSummary = item => Array.isArray(item)
? `Lista · ${item.length} ${item.length === 1 ? 'post' : 'poster'}`
: `Objekt · ${Object.keys(item).length} fält`;
+ const addRawText = (parent, value) => {
+ const raw = document.createElement('details');
+ raw.className = 'json-raw-value';
+ const rawSummary = document.createElement('summary');
+ rawSummary.textContent = 'Visa lagrad text med escape-tecken';
+ const rawText = document.createElement('div');
+ rawText.className = 'json-long-text';
+ rawText.textContent = value;
+ raw.append(rawSummary, rawText);
+ parent.append(raw);
+ };
const addLeaf = (parent, label, item) => {
+ const readable = typeof item === 'string' ? decodeEscapedSequences(item) : item;
if (typeof item === 'string' && item.length > 180) {
const details = document.createElement('details');
details.className = 'json-long-value';
@@ -685,13 +718,15 @@
summary.append(document.createTextNode(`${label ? `${label} · ` : ''}Text · ${item.length} tecken · `));
const preview = document.createElement('span');
preview.className = 'json-value-string';
- preview.textContent = `${item.slice(0, 100)}${item.length > 100 ? '…' : ''}`;
+ const previewText = readable.slice(0, 100);
+ preview.textContent = `${previewText}${readable.length > 100 ? '…' : ''}`;
summary.append(preview);
const fullText = document.createElement('div');
fullText.className = 'json-long-text';
- fullText.textContent = item;
+ fullText.textContent = readable;
details.append(summary, fullText);
parent.append(details);
+ if (readable !== item) addRawText(parent, item);
return;
}
const row = document.createElement('div');
@@ -705,9 +740,10 @@
const valueNode = document.createElement('span');
const type = item === null ? 'null' : typeof item;
valueNode.className = `json-value-${type === 'boolean' ? 'boolean' : type}`;
- valueNode.textContent = typeof item === 'string' ? JSON.stringify(item) : String(item);
+ valueNode.textContent = typeof readable === 'string' ? readable : String(readable);
row.append(valueNode);
parent.append(row);
+ if (typeof item === 'string' && readable !== item) addRawText(parent, item);
};
const populate = (details, item, depth) => {
if (details.dataset.loaded === 'true') return;
diff --git a/scripts/check-frontend.sh b/scripts/check-frontend.sh
index 0da6a6b..e3b9e5f 100755
--- a/scripts/check-frontend.sh
+++ b/scripts/check-frontend.sh
@@ -100,6 +100,9 @@ grep -q "query AdminPlayerTrace" index.html
grep -q 'adminPlayerTrace(accountId: \$accountId, limit: \$limit, offset: \$offset)' index.html
grep -q 'function renderDecisionTrace' index.html
grep -q 'function renderJsonTree' index.html
+grep -q 'function decodeEscapedSequences' index.html
+grep -q 'decodeEscapedSequences(item)' index.html
+grep -q "Visa lagrad text med escape-tecken" index.html
grep -q "'Öppna alla block'" index.html
grep -q "'Fäll ihop nästlade block'" index.html
grep -q "nested.className = 'json-node'" index.html
diff --git a/tests/admin-conversation-trace.test.cjs b/tests/admin-conversation-trace.test.cjs
index 342f6f5..dd64e5c 100644
--- a/tests/admin-conversation-trace.test.cjs
+++ b/tests/admin-conversation-trace.test.cjs
@@ -64,3 +64,18 @@ test('admin JSON responses are readable as accessible collapsible trees', () =>
assert.match(page, /JSON\.stringify\(npc\.profile, null, '\\t'\)/);
assert.doesNotMatch(page, /jsnview|cdn\.jsdelivr/);
});
+
+test('admin JSON text decodes common JSON and YAML escapes while retaining the stored form', () => {
+ const decoder = page.match(/function decodeEscapedSequences\(text\) \{[\s\S]*?\n \}/);
+ assert.ok(decoder, 'escape decoder should be present');
+ assert.match(page, /Visa lagrad text med escape-tecken/);
+ assert.match(page, /decodeEscapedSequences\(item\)/);
+ const decode = new Function(`${decoder[0]}; return decodeEscapedSequences;`)();
+
+ assert.equal(decode(String.raw`Rad 1\nRad 2`), 'Rad 1\nRad 2');
+ assert.equal(decode(String.raw`Kolumn 1\tKolumn 2`), 'Kolumn 1\tKolumn 2');
+ assert.equal(decode(String.raw`citat: \'hej\'`), "citat: 'hej'");
+ assert.equal(decode(String.raw`\u00E5\x21`), 'å!');
+ assert.equal(decode(String.raw`\U0001F642`), '🙂');
+ assert.equal(decode(String.raw`okänt: \q`), String.raw`okänt: \q`);
+});