From b460632b324632e5cb715b5b78de9d2a77516ebd Mon Sep 17 00:00:00 2001
From: Earlopain <14981592+Earlopain@users.noreply.github.com>
Date: Wed, 26 Aug 2026 22:16:33 +0200
Subject: [PATCH 1/2] Some playground improvements
* Add color to empty locations
* Fix display of string type as `[Object Object]`
* Include slice for location fields
* Add `location: ` to node locations like in ruby inspect output
---
doc/playground.css | 2 +-
doc/playground.js | 27 +++++++++++++++++++--------
2 files changed, 20 insertions(+), 9 deletions(-)
diff --git a/doc/playground.css b/doc/playground.css
index 9666ae4bfe..038c314947 100644
--- a/doc/playground.css
+++ b/doc/playground.css
@@ -268,7 +268,7 @@ main {
}
.tree-null {
- color: var(--color-text-light);
+ color: #7e22ce;
font-style: italic;
}
diff --git a/doc/playground.js b/doc/playground.js
index f593b96afe..cea0cd8b4f 100644
--- a/doc/playground.js
+++ b/doc/playground.js
@@ -252,11 +252,18 @@ function offsetToLineCol(source, offset) {
}
-function formatLoc(source, loc) {
+function formatLoc(source, loc, includeSlice) {
if (!loc || loc.startOffset === undefined) return null;
const start = offsetToLineCol(source, loc.startOffset);
const end = offsetToLineCol(source, loc.startOffset + loc.length);
- return { start, end, text: `${start.line}:${start.col}-${end.line}:${end.col}` };
+
+ let text = `${start.line}:${start.col}-${end.line}:${end.col}`;
+
+ if (includeSlice) {
+ const slice = source.slice(loc.startOffset, loc.startOffset + loc.length);
+ text = `${text} = ${JSON.stringify(slice)} `
+ }
+ return { start, end, text };
}
function locDataAttrs(loc) {
@@ -284,6 +291,10 @@ function isNode(value) {
return value && typeof value === "object" && !Array.isArray(value) && value.location && value.constructor && value.constructor.name !== "Object";
}
+function isString(value) {
+ return value && typeof value === "object" && Object.hasOwn(value, "encoding")
+}
+
// Get the node type name from the class name
function nodeType(node) {
return node.constructor?.name || "Unknown";
@@ -346,11 +357,11 @@ function renderNode(node, source, prefix, isLast, isRoot) {
if (!isRoot) html += `${prefix}${isLast ? CONNECTOR.last : CONNECTOR.mid} `;
if (foldable) html += `▼ `;
- const loc = formatLoc(source, node.location);
+ const loc = formatLoc(source, node.location, false);
const locAttrs = locDataAttrs(loc);
html += `@ ${escapedType} `;
- if (loc) html += ` (${loc.text}) `;
+ if (loc) html += ` (location: ${loc.text}) `;
html += ``;
html += `
`;
@@ -386,12 +397,12 @@ function renderNode(node, source, prefix, isLast, isRoot) {
html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} :
`;
html += renderNode(value, source, fieldChildPrefix, true);
} else if (typeof value === "object" && value.startOffset !== undefined) {
- const fieldLoc = formatLoc(source, value);
+ const fieldLoc = formatLoc(source, value, true);
if (fieldLoc) {
html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} : ${fieldLoc.text}
`;
}
- } else if (typeof value === "string") {
- html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} : ${escapeHtml(JSON.stringify(value))}
`;
+ } else if (isString(value)) {
+ html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} : ${escapeHtml(JSON.stringify(value.value))}
`;
} else {
html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} : ${escapeHtml(String(value))}
`;
}
@@ -408,7 +419,7 @@ function escapeHtml(str) {
// Render a single diagnostic line
function renderDiagnostic(source, item, kind) {
- const loc = formatLoc(source, item.location);
+ const loc = formatLoc(source, item.location, false);
const cssClass = kind === "Error" ? "error-text" : "warning-text";
return `
${kind}: ${escapeHtml(item.message)}${loc ? ` (${loc.text}) ` : ""}
`;
}
From ffeef1e102498c35978970199ca5830d97ff46e5 Mon Sep 17 00:00:00 2001
From: Earlopain <14981592+Earlopain@users.noreply.github.com>
Date: Wed, 26 Aug 2026 23:11:02 +0200
Subject: [PATCH 2/2] Correctly handle multibyte chars in the playground
Javascript strings are always utf16, TextEncoder
converts it to utf-8 bytes.
Doing it like this seems like a fine solution
---
doc/playground.js | 45 +++++++++++++++++++++++++--------------------
1 file changed, 25 insertions(+), 20 deletions(-)
diff --git a/doc/playground.js b/doc/playground.js
index cea0cd8b4f..e617bdb53f 100644
--- a/doc/playground.js
+++ b/doc/playground.js
@@ -6,6 +6,9 @@ const editorDiv = document.getElementById("editor");
const loading = document.getElementById("loading");
const toast = document.getElementById("toast");
+const encoder = new TextEncoder();
+const decoder = new TextDecoder();
+
// Load Prism WASM and Monaco, show error if either fails
let instance, monaco;
try {
@@ -115,7 +118,7 @@ end
// URL-safe base64 encode/decode (RFC 4648 §5)
function encodeSource(str) {
- const bytes = new TextEncoder().encode(str);
+ const bytes = encoder.encode(str);
let binary = "";
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
@@ -123,7 +126,7 @@ function encodeSource(str) {
function decodeSource(str) {
const padded = str.replace(/-/g, "+").replace(/_/g, "/") + "==".slice(0, (4 - str.length % 4) % 4);
- return new TextDecoder().decode(Uint8Array.from(atob(padded), ch => ch.codePointAt(0)));
+ return decoder.decode(Uint8Array.from(atob(padded), ch => ch.codePointAt(0)));
}
// Read initial source from URL hash or use default
@@ -241,26 +244,27 @@ document.getElementById("expand-all").addEventListener("click", () => {
output.querySelectorAll(".tree-toggle").forEach(toggle => setToggleState(toggle, false));
});
-// Convert byte offset to line:column using the source string
-function offsetToLineCol(source, offset) {
+// Convert byte offset to line:column using the utf8 bytes
+function offsetToLineCol(utf8Bytes, offset) {
let line = 1, col = 0;
- for (let i = 0; i < offset && i < source.length; i++) {
- if (source[i] === "\n") { line++; col = 0; }
+ for (let i = 0; i < offset && i < utf8Bytes.length; i++) {
+ // Check for newline
+ if (utf8Bytes[i] === 10) { line++; col = 0; }
else { col++; }
}
return { line, col };
}
-function formatLoc(source, loc, includeSlice) {
+function formatLoc(utf8Bytes, loc, includeSlice) {
if (!loc || loc.startOffset === undefined) return null;
- const start = offsetToLineCol(source, loc.startOffset);
- const end = offsetToLineCol(source, loc.startOffset + loc.length);
+ const start = offsetToLineCol(utf8Bytes, loc.startOffset);
+ const end = offsetToLineCol(utf8Bytes, loc.startOffset + loc.length);
let text = `${start.line}:${start.col}-${end.line}:${end.col}`;
if (includeSlice) {
- const slice = source.slice(loc.startOffset, loc.startOffset + loc.length);
+ const slice = decoder.decode(utf8Bytes.slice(loc.startOffset, loc.startOffset + loc.length));
text = `${text} =
${JSON.stringify(slice)} `
}
return { start, end, text };
@@ -344,7 +348,7 @@ function hasChildNodes(fields, node) {
const CONNECTOR = { last: "└── ", mid: "├── ", lastPad: " ", midPad: "│ " };
// Build the AST tree as interactive HTML
-function renderNode(node, source, prefix, isLast, isRoot) {
+function renderNode(node, utf8Bytes, prefix, isLast, isRoot) {
if (!isNode(node)) return "";
const type = nodeType(node);
@@ -357,7 +361,7 @@ function renderNode(node, source, prefix, isLast, isRoot) {
if (!isRoot) html += `
${prefix}${isLast ? CONNECTOR.last : CONNECTOR.mid} `;
if (foldable) html += `
▼ `;
- const loc = formatLoc(source, node.location, false);
+ const loc = formatLoc(utf8Bytes, node.location, false);
const locAttrs = locDataAttrs(loc);
html += `
@ ${escapedType} `;
@@ -386,7 +390,7 @@ function renderNode(node, source, prefix, isLast, isRoot) {
html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} : (${value.length} item${value.length === 1 ? "" : "s"})
`;
value.forEach((item, i) => {
if (isNode(item)) {
- html += renderNode(item, source, fieldChildPrefix, i === value.length - 1);
+ html += renderNode(item, utf8Bytes, fieldChildPrefix, i === value.length - 1);
} else {
const itemConnector = i === value.length - 1 ? CONNECTOR.last : CONNECTOR.mid;
html += `
${fieldChildPrefix}${itemConnector} ${escapeHtml(JSON.stringify(item))}
`;
@@ -395,9 +399,9 @@ function renderNode(node, source, prefix, isLast, isRoot) {
}
} else if (isNode(value)) {
html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} :
`;
- html += renderNode(value, source, fieldChildPrefix, true);
+ html += renderNode(value, utf8Bytes, fieldChildPrefix, true);
} else if (typeof value === "object" && value.startOffset !== undefined) {
- const fieldLoc = formatLoc(source, value, true);
+ const fieldLoc = formatLoc(utf8Bytes, value, true);
if (fieldLoc) {
html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} : ${fieldLoc.text}
`;
}
@@ -418,8 +422,8 @@ function escapeHtml(str) {
}
// Render a single diagnostic line
-function renderDiagnostic(source, item, kind) {
- const loc = formatLoc(source, item.location, false);
+function renderDiagnostic(utf8Bytes, item, kind) {
+ const loc = formatLoc(utf8Bytes, item.location, false);
const cssClass = kind === "Error" ? "error-text" : "warning-text";
return `
${kind}: ${escapeHtml(item.message)}${loc ? ` (${loc.text}) ` : ""}
`;
}
@@ -464,9 +468,10 @@ function render() {
output.setAttribute("aria-labelledby", currentTab === "ast" ? "tab-ast" : "tab-diagnostics");
+ const utf8Bytes = encoder.encode(lastSource);
switch (currentTab) {
case "ast":
- const tree = renderNode(lastResult.value, lastSource, "", true, true);
+ const tree = renderNode(lastResult.value, utf8Bytes, "", true, true);
output.innerHTML = tree
? `
${tree}
`
: `
${escapeHtml(lastResult.error || "Failed to parse.")}
`;
@@ -479,8 +484,8 @@ function render() {
output.innerHTML = `
No errors or warnings.
`;
} else {
let html = "";
- for (const err of errors) html += renderDiagnostic(lastSource, err, "Error");
- for (const warn of warnings) html += renderDiagnostic(lastSource, warn, "Warning");
+ for (const err of errors) html += renderDiagnostic(utf8Bytes, err, "Error");
+ for (const warn of warnings) html += renderDiagnostic(utf8Bytes, warn, "Warning");
output.innerHTML = html;
}
break;