diff --git a/doc/index.css b/doc/index.css
index b4683cf24f..e33d019791 100644
--- a/doc/index.css
+++ b/doc/index.css
@@ -1,84 +1,186 @@
:root {
- --color-background: #1c1f26;
- --color-text: white;
+ --color-ruby: #CC342D;
+ --color-ruby-dark: #A50C07;
+ --color-bg: #FFFFFF;
+ --color-bg-alt: #F7F7F7;
+ --color-text: #3E4451;
+ --color-text-light: #6B7280;
+ --color-border: #E5E7EB;
}
-body {
- background-color: var(--color-background);
- font-family: "Noto Sans", "Helvetica Neue", Helvetica, sans-serif;
+* {
margin: 0;
+ padding: 0;
+ box-sizing: border-box;
}
-header {
- height: 10vh;
+body {
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ font-family: "Noto Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
+ line-height: 1.6;
}
-h1 {
- color: var(--color-text);
+header {
+ background-color: var(--color-ruby);
+ color: white;
+ padding: 3rem 2rem;
text-align: center;
- line-height: 10vh;
- margin: 0;
+}
+
+header h1 {
+ font-size: 2.25rem;
+ font-weight: 700;
+ margin-bottom: 0.5rem;
+}
+
+header p {
+ font-size: 1.1rem;
+}
+
+:focus-visible {
+ outline: 2px solid var(--color-ruby);
+ outline-offset: 2px;
}
main {
- display: grid;
- grid-template-rows: 1fr 1fr;
- grid-template-columns: 1fr 1fr;
- height: 36vh;
- margin: 2vh 20vw;
+ max-width: 56rem;
+ margin: 0 auto;
+ padding: 0 2rem;
}
-@media only screen and (max-width: 1200px) {
- main {
- margin: 2vh 10vw;
- }
+section {
+ margin-top: 2.5rem;
}
-@media only screen and (max-width: 900px) {
- main {
- grid-template-rows: repeat(1fr);
- grid-template-columns: 1fr;
- height: 72vh;
- }
+section h2 {
+ font-size: 1.25rem;
+ font-weight: 700;
+ margin-bottom: 1rem;
+ color: var(--color-text);
+}
+
+section p {
+ color: var(--color-text-light);
+ font-size: 0.9375rem;
+ margin-bottom: 0.75rem;
+}
+
+.playground-link {
+ display: inline-block;
+ margin-top: 0.5rem;
+ padding: 0.625rem 1.25rem;
+ background: var(--color-ruby);
+ color: white;
+ text-decoration: none;
+ border-radius: 0.375rem;
+ font-size: 0.9375rem;
+ font-weight: 600;
+ transition: background-color 150ms ease;
+}
+
+.playground-link:hover {
+ background: var(--color-ruby-dark);
+}
+
+pre {
+ background: var(--color-bg-alt);
+ border: 1px solid var(--color-border);
+ border-radius: 0.375rem;
+ padding: 0.75rem 1rem;
+ font-size: 0.875rem;
+ overflow-x: auto;
+ margin-bottom: 0.75rem;
+}
+
+code {
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
-@media only screen and (max-width: 600px) {
- main {
- margin: 2vh 5vw;
+/* API reference cards */
+.grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 1rem;
+}
+
+@media (max-width: 640px) {
+ .grid {
+ grid-template-columns: 1fr;
}
}
.reference {
- border-radius: 1em;
- box-sizing: border-box;
- color: var(--color-text);
- height: 18vh;
- padding: 4vh 0;
- text-align: center;
+ display: flex;
+ align-items: center;
+ gap: 1.25rem;
+ padding: 1.25rem;
+ border: 1px solid var(--color-border);
+ border-radius: 0.5rem;
text-decoration: none;
- transition: background-color 100ms ease;
- vertical-align: middle;
+ color: var(--color-text);
+ background: var(--color-bg);
+ transition: border-color 150ms ease, box-shadow 150ms ease;
+}
+
+.reference:hover {
+ border-color: var(--color-ruby);
+ box-shadow: 0 2px 8px rgba(204, 52, 45, 0.1);
}
-.reference > img {
- height: 10vh;
- width: 10vh;
- margin-right: 1em;
- vertical-align: middle;
+.reference img {
+ width: 2.5rem;
+ height: 2.5rem;
+ flex-shrink: 0;
}
-.reference > h2 {
- display: inline;
+.reference h3 {
+ font-size: 1rem;
+ font-weight: 600;
margin: 0;
- text-decoration: underline;
+ color: var(--color-ruby);
}
-.reference:hover {
- background-color: rgba(255, 255, 255, 0.1);
+.reference span {
+ display: block;
+ font-size: 0.8125rem;
+ color: var(--color-text-light);
+ margin-top: 0.125rem;
}
-#logo {
+/* Guide links */
+.guide {
display: block;
- height: 50vh;
- margin: 0 auto;
+ padding: 0.625rem 0.875rem;
+ border: 1px solid var(--color-border);
+ border-radius: 0.375rem;
+ text-decoration: none;
+ color: var(--color-text);
+ font-size: 0.875rem;
+ transition: border-color 150ms ease, color 150ms ease;
+}
+
+.guide:hover {
+ border-color: var(--color-ruby);
+ color: var(--color-ruby);
+}
+
+/* Footer */
+footer {
+ margin-top: 3rem;
+ padding: 1.5rem 2rem;
+ text-align: center;
+ border-top: 1px solid var(--color-border);
+ display: flex;
+ justify-content: center;
+ gap: 2rem;
+}
+
+footer a {
+ color: var(--color-text-light);
+ font-size: 0.875rem;
+}
+
+footer a:hover {
+ color: var(--color-ruby);
}
diff --git a/doc/index.html b/doc/index.html
index 9e0e38cb60..b3af99e367 100644
--- a/doc/index.html
+++ b/doc/index.html
@@ -2,37 +2,86 @@
-
- Prism
-
-
+
+
+ Prism Ruby Parser
+
+
+
+
-
-
- C reference
-
-
-
- Ruby reference
-
-
-
- Rust reference
-
-
-
- Java reference
-
+
+ Try it
+ Parse Ruby code in your browser — no installation required.
+ Open playground →
+
+
+
+ Getting started
+ Prism is bundled with CRuby 3.3+ and available as a gem for earlier versions.
+ gem install prism
+
+
+
+
+
diff --git a/doc/playground.css b/doc/playground.css
new file mode 100644
index 0000000000..9666ae4bfe
--- /dev/null
+++ b/doc/playground.css
@@ -0,0 +1,336 @@
+:root {
+ --color-ruby: #CC342D;
+ --color-ruby-light: rgba(204, 52, 45, 0.15);
+ --color-bg: #FFFFFF;
+ --color-bg-alt: #F7F7F7;
+ --color-text: #3E4451;
+ --color-text-light: #6B7280;
+ --color-border: #E5E7EB;
+ --color-highlight: rgba(204, 52, 45, 0.12);
+}
+
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+body {
+ font-family: "Noto Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
+ color: var(--color-text);
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+:focus-visible {
+ outline: 2px solid var(--color-ruby);
+ outline-offset: 2px;
+}
+
+.skip-link {
+ position: absolute;
+ left: -9999px;
+ top: 0;
+ background: var(--color-ruby);
+ color: white;
+ padding: 0.5rem 1rem;
+ z-index: 200;
+ font-size: 0.875rem;
+ text-decoration: none;
+}
+
+.skip-link:focus {
+ left: 0;
+}
+
+nav {
+ background: var(--color-ruby);
+ color: white;
+ padding: 0.5rem 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-shrink: 0;
+ gap: 0.5rem;
+}
+
+nav h1 { font-size: 1.1rem; font-weight: 600; }
+nav a { color: white; text-decoration: none; font-size: 0.8125rem; }
+nav a:hover { text-decoration: underline; }
+nav a:focus-visible { outline-color: white; }
+
+.nav-left {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ min-width: 0;
+}
+
+.nav-right {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+@media (max-width: 540px) {
+ .nav-version, .nav-right a { display: none; }
+}
+
+.nav-version {
+ font-size: 0.75rem;
+ color: rgba(255,255,255,0.9);
+}
+
+.nav-select, .nav-button {
+ font-family: inherit;
+ font-size: 0.8125rem;
+ height: 1.75rem;
+ padding: 0 0.5rem;
+ border: 1px solid rgba(255,255,255,0.4);
+ border-radius: 4px;
+ background: rgba(255,255,255,0.15);
+ color: white;
+ cursor: pointer;
+}
+
+.nav-select:hover, .nav-button:hover {
+ background: rgba(255,255,255,0.25);
+}
+
+.nav-select:focus-visible, .nav-button:focus-visible {
+ outline-color: white;
+}
+
+.nav-select option {
+ color: var(--color-text);
+ background: white;
+}
+
+main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+#loading {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-text-light);
+}
+
+#loading.hidden {
+ display: none;
+}
+
+.editor {
+ flex: 1;
+ display: none;
+ grid-template-columns: 1fr 1fr;
+ min-height: 0;
+}
+
+.editor.ready { display: grid; }
+
+@media (max-width: 1024px) {
+ .editor {
+ grid-template-columns: 1fr;
+ grid-template-rows: minmax(40vh, 1fr) minmax(40vh, 1fr);
+ overflow: auto;
+ }
+}
+
+#monaco-container {
+ border-right: 1px solid var(--color-border);
+ min-width: 0;
+ overflow: hidden;
+}
+
+.output-panel {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.tabs {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ border-bottom: 1px solid var(--color-border);
+ background: var(--color-bg);
+ flex-shrink: 0;
+}
+
+.tabs button[role="tab"] {
+ padding: 0.625rem 1rem;
+ border: none;
+ background: none;
+ font-family: inherit;
+ font-size: 0.8125rem;
+ color: var(--color-text-light);
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+}
+
+.tabs button[role="tab"]:hover { color: var(--color-text); }
+.tabs button[role="tab"].active {
+ color: var(--color-ruby);
+ border-bottom-color: var(--color-ruby);
+}
+
+.tabs-spacer {
+ flex: 1;
+}
+
+.tab-action {
+ font-family: inherit;
+ font-size: 0.75rem;
+ padding: 0.25rem 0.5rem;
+ margin-right: 0.375rem;
+ border: 1px solid var(--color-border);
+ border-radius: 3px;
+ background: var(--color-bg);
+ color: var(--color-text-light);
+ cursor: pointer;
+}
+
+.tab-action:hover {
+ color: var(--color-text);
+ border-color: var(--color-text-light);
+}
+
+#output {
+ flex: 1;
+ overflow: auto;
+ background: var(--color-bg-alt);
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
+ font-size: 13px;
+ line-height: 1.5;
+ padding: 0.75rem;
+}
+
+/* AST tree styles */
+.tree-node {
+ white-space: pre;
+}
+
+.tree-type {
+ color: var(--color-ruby);
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.tree-type:hover {
+ text-decoration: underline;
+}
+
+.tree-loc {
+ color: var(--color-text-light);
+ cursor: pointer;
+}
+
+.tree-loc:hover {
+ color: var(--color-ruby);
+}
+
+.tree-toggle {
+ display: inline-block;
+ cursor: pointer;
+ user-select: none;
+ color: var(--color-text-light);
+ background: none;
+ border: none;
+ padding: 0.125em 0.25em;
+ margin-right: 0.125em;
+ font: inherit;
+ line-height: inherit;
+ border-radius: 2px;
+}
+
+.tree-toggle:hover {
+ color: var(--color-ruby);
+ background: var(--color-highlight);
+}
+
+.tree-children.collapsed {
+ display: none;
+}
+
+.tree-field {
+ color: var(--color-text-light);
+}
+
+.tree-value {
+ color: var(--color-text);
+}
+
+.tree-string {
+ color: #953800;
+}
+
+.tree-null {
+ color: var(--color-text-light);
+ font-style: italic;
+}
+
+.tree-connector {
+ color: #d1d5db;
+}
+
+.tree-flag {
+ display: inline-block;
+ padding: 0 0.375em;
+ border-radius: 3px;
+ font-size: 0.85em;
+ vertical-align: middle;
+ background: rgba(204, 52, 45, 0.08);
+ color: var(--color-ruby);
+}
+
+.tree-highlight {
+ background: var(--color-highlight);
+ border-radius: 2px;
+}
+
+.prism-highlight {
+ background: var(--color-ruby-light) !important;
+}
+
+.diagnostics-line {
+ padding: 0.25rem 0;
+}
+
+.diagnostics-line[data-sl] {
+ cursor: pointer;
+}
+
+.error-text { color: var(--color-ruby); }
+.warning-text { color: #b45309; }
+
+.noscript-message {
+ padding: 2rem;
+ text-align: center;
+ color: var(--color-text-light);
+}
+
+.empty-message {
+ padding: 0.5rem;
+}
+
+.share-toast {
+ position: fixed;
+ bottom: 1rem;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 0.5rem 1rem;
+ background: var(--color-text);
+ color: white;
+ border-radius: 4px;
+ font-size: 0.8125rem;
+ z-index: 100;
+ opacity: 0;
+ transition: opacity 200ms;
+}
+
+.share-toast.visible {
+ opacity: 1;
+}
diff --git a/doc/playground.html b/doc/playground.html
new file mode 100644
index 0000000000..cc1e17367f
--- /dev/null
+++ b/doc/playground.html
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+ Prism - Playground
+
+
+
+
+
+
+
+ Skip to editor
+
+
+
+
+ Loading…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/doc/playground.js b/doc/playground.js
new file mode 100644
index 0000000000..f593b96afe
--- /dev/null
+++ b/doc/playground.js
@@ -0,0 +1,499 @@
+import { WASI } from "https://unpkg.com/@bjorn3/browser_wasi_shim@latest/dist/index.js";
+import { parsePrism } from "https://unpkg.com/@ruby/prism@latest/src/parsePrism.js";
+
+const output = document.getElementById("output");
+const editorDiv = document.getElementById("editor");
+const loading = document.getElementById("loading");
+const toast = document.getElementById("toast");
+
+// Load Prism WASM and Monaco, show error if either fails
+let instance, monaco;
+try {
+ const [wasmResult] = await Promise.all([
+ WebAssembly.compileStreaming(fetch("https://unpkg.com/@ruby/prism@latest/src/prism.wasm"))
+ .then(wasm => {
+ const wasi = new WASI([], [], []);
+ return WebAssembly.instantiate(wasm, { wasi_snapshot_preview1: wasi.wasiImport })
+ .then(inst => { wasi.initialize(inst); return inst; });
+ }),
+ fetch("https://unpkg.com/@ruby/prism@latest/package.json")
+ .then(r => r.json())
+ .then(pkg => { document.getElementById("version").textContent = `v${pkg.version}`; })
+ .catch(() => {})
+ ]);
+ instance = wasmResult;
+
+ monaco = await new Promise((resolve, reject) => {
+ require.config({ paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs" } });
+ require(["vs/editor/editor.main"], resolve, reject);
+ });
+} catch (error) {
+ loading.textContent = `Failed to load: ${error.message}`;
+ throw error;
+}
+
+// Example snippets
+const EXAMPLES = {
+ fibonacci: `def fibonacci(n)
+ case n
+ when 0
+ 0
+ when 1
+ 1
+ else
+ fibonacci(n - 1) + fibonacci(n - 2)
+ end
+end
+`,
+ pattern: `case [1, [2, 3]]
+in [Integer => a, [Integer => b, Integer => c]]
+ puts "matched: \#{a}, \#{b}, \#{c}"
+in [Integer => a, *rest]
+ puts "first: \#{a}, rest: \#{rest}"
+end
+
+config = { name: "prism", version: "1.0" }
+
+case config
+in { name: /^pr/ => name, version: }
+ puts "\#{name} v\#{version}"
+end
+`,
+ heredoc: `name = "World"
+
+message = <<~HEREDOC
+ Hello, \#{name}!
+ Today is \#{Time.now}.
+HEREDOC
+
+query = <<~SQL.strip
+ SELECT *
+ FROM users
+ WHERE active = true
+SQL
+`,
+ blocks: `numbers = [1, 2, 3, 4, 5]
+
+squares = numbers.map { |n| n ** 2 }
+evens = numbers.select(&:even?)
+
+doubler = ->(x) { x * 2 }
+
+numbers.each do |n|
+ puts doubler.call(n)
+end
+
+def with_logging(&block)
+ puts "start"
+ result = block.call
+ puts "end"
+ result
+end
+`,
+ class: `class Person
+ attr_accessor :name, :age
+
+ def initialize(name, age)
+ @name = name
+ @age = age
+ end
+
+ def greeting = "Hi, I'm \#{name}!"
+
+ def <=>(other)
+ age <=> other.age
+ end
+
+ private
+
+ def validate!
+ raise ArgumentError, "Invalid age" unless age&.positive?
+ end
+end
+`
+};
+
+// URL-safe base64 encode/decode (RFC 4648 §5)
+function encodeSource(str) {
+ const bytes = new TextEncoder().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(/=+$/, "");
+}
+
+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)));
+}
+
+// Read initial source from URL hash or use default
+function sourceFromHash() {
+ const hash = location.hash.slice(1);
+ if (!hash) return null;
+ try { return decodeSource(hash); } catch { return null; }
+}
+
+const initialSource = sourceFromHash() || EXAMPLES.fibonacci;
+
+monaco.editor.defineTheme("prism", {
+ base: "vs",
+ inherit: true,
+ rules: [],
+ colors: { "editorLineNumber.foreground": "#6B7280" }
+});
+
+const monacoEditor = monaco.editor.create(document.getElementById("monaco-container"), {
+ theme: "prism",
+ value: initialSource,
+ language: "ruby",
+ minimap: { enabled: false },
+ fontSize: 14,
+ lineHeight: 21,
+ scrollBeyondLastLine: false,
+ automaticLayout: true,
+ tabSize: 2
+});
+
+let currentTab = "ast";
+let lastResult = null;
+let lastSource = "";
+let currentDecorations = [];
+
+// Tab switching
+const tabs = Array.from(document.querySelectorAll("[role=tab]"));
+
+function activateTab(tab) {
+ tabs.forEach(t => {
+ t.classList.remove("active");
+ t.setAttribute("aria-selected", "false");
+ t.setAttribute("tabindex", "-1");
+ });
+ tab.classList.add("active");
+ tab.setAttribute("aria-selected", "true");
+ tab.setAttribute("tabindex", "0");
+ tab.focus();
+ currentTab = tab.dataset.tab;
+ render();
+}
+
+document.querySelector(".tabs").addEventListener("click", (event) => {
+ const tab = event.target.closest("[role=tab]");
+ if (tab) activateTab(tab);
+});
+
+document.querySelector(".tabs").addEventListener("keydown", (event) => {
+ const tab = event.target.closest("[role=tab]");
+ if (!tab) return;
+ const index = tabs.indexOf(tab);
+ let next = -1;
+ if (event.key === "ArrowRight") next = (index + 1) % tabs.length;
+ else if (event.key === "ArrowLeft") next = (index - 1 + tabs.length) % tabs.length;
+ else if (event.key === "Home") next = 0;
+ else if (event.key === "End") next = tabs.length - 1;
+ if (next >= 0) {
+ event.preventDefault();
+ activateTab(tabs[next]);
+ }
+});
+
+// Examples dropdown
+document.getElementById("examples").addEventListener("change", (event) => {
+ const key = event.target.value;
+ if (key && EXAMPLES[key]) {
+ monacoEditor.setValue(EXAMPLES[key]);
+ monacoEditor.focus();
+ }
+});
+
+// Share button
+document.getElementById("share").addEventListener("click", async () => {
+ try {
+ await navigator.clipboard.writeText(location.href);
+ showToast("Link copied to clipboard");
+ } catch {
+ showToast("Copy the URL from the address bar");
+ }
+});
+
+let toastTimeout = null;
+function showToast(message) {
+ if (toastTimeout) clearTimeout(toastTimeout);
+ toast.textContent = message;
+ toast.classList.add("visible");
+ toastTimeout = setTimeout(() => toast.classList.remove("visible"), 2000);
+}
+
+// Shared toggle helper
+function setToggleState(toggle, collapsed) {
+ const treeitem = toggle.closest(".tree-node");
+ const children = treeitem.nextElementSibling;
+ if (!children || !children.classList.contains("tree-children")) return;
+ children.classList.toggle("collapsed", collapsed);
+ toggle.textContent = collapsed ? "▶" : "▼";
+ treeitem.setAttribute("aria-expanded", String(!collapsed));
+}
+
+document.getElementById("collapse-all").addEventListener("click", () => {
+ output.querySelectorAll(".tree-toggle").forEach(toggle => setToggleState(toggle, true));
+});
+
+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) {
+ let line = 1, col = 0;
+ for (let i = 0; i < offset && i < source.length; i++) {
+ if (source[i] === "\n") { line++; col = 0; }
+ else { col++; }
+ }
+ return { line, col };
+}
+
+
+function formatLoc(source, loc) {
+ 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}` };
+}
+
+function locDataAttrs(loc) {
+ if (!loc) return "";
+ return ` data-sl="${loc.start.line}" data-sc="${loc.start.col}" data-el="${loc.end.line}" data-ec="${loc.end.col}"`;
+}
+
+// Highlight a range in Monaco
+function highlightRange(startLine, startCol, endLine, endCol) {
+ currentDecorations = monacoEditor.deltaDecorations(currentDecorations, [{
+ range: new monaco.Range(startLine, startCol + 1, endLine, endCol + 1),
+ options: {
+ className: "prism-highlight",
+ isWholeLine: false
+ }
+ }]);
+}
+
+function clearHighlight() {
+ currentDecorations = monacoEditor.deltaDecorations(currentDecorations, []);
+}
+
+// Detect whether a value is a Prism AST node (class instance with location)
+function isNode(value) {
+ return value && typeof value === "object" && !Array.isArray(value) && value.location && value.constructor && value.constructor.name !== "Object";
+}
+
+// Get the node type name from the class name
+function nodeType(node) {
+ return node.constructor?.name || "Unknown";
+}
+
+// Get the enumerable field names, skipping internal ones
+const SKIP_FIELDS = new Set(["nodeID", "location", "flags"]);
+function nodeFields(node) {
+ const fields = [];
+ for (const key of Object.getOwnPropertyNames(node)) {
+ if (!SKIP_FIELDS.has(key) && !key.startsWith("#")) fields.push(key);
+ }
+ return fields;
+}
+
+// Decode flags by calling is*() predicate methods on the node's prototype
+const flagNamesCache = new WeakMap();
+function flagPredicateNames(proto) {
+ let names = flagNamesCache.get(proto);
+ if (!names) {
+ names = Object.getOwnPropertyNames(proto).filter(n => n.startsWith("is") && typeof proto[n] === "function");
+ flagNamesCache.set(proto, names);
+ }
+ return names;
+}
+
+function activeFlags(node) {
+ const proto = Object.getPrototypeOf(node);
+ if (!proto) return [];
+ const flags = [];
+ for (const name of flagPredicateNames(proto)) {
+ try { if (node[name]()) flags.push(name.slice(2)); } catch (e) {}
+ }
+ return flags;
+}
+
+// Check if a node has child nodes (not just scalar fields)
+function hasChildNodes(fields, node) {
+ for (const field of fields) {
+ const value = node[field];
+ if (isNode(value)) return true;
+ if (Array.isArray(value) && value.some(isNode)) return true;
+ }
+ return false;
+}
+
+const CONNECTOR = { last: "└── ", mid: "├── ", lastPad: " ", midPad: "│ " };
+
+// Build the AST tree as interactive HTML
+function renderNode(node, source, prefix, isLast, isRoot) {
+ if (!isNode(node)) return "";
+
+ const type = nodeType(node);
+ const childPrefix = isRoot ? "" : prefix + (isLast ? CONNECTOR.lastPad : CONNECTOR.midPad);
+ const fields = nodeFields(node);
+ const foldable = hasChildNodes(fields, node);
+ const escapedType = escapeHtml(type);
+
+ let html = ``;
+ if (!isRoot) html += `${prefix}${isLast ? CONNECTOR.last : CONNECTOR.mid}`;
+ if (foldable) html += ``;
+
+ const loc = formatLoc(source, node.location);
+ const locAttrs = locDataAttrs(loc);
+
+ html += `@ ${escapedType}`;
+ if (loc) html += ` (${loc.text})`;
+ html += `
`;
+
+ html += ``;
+ const flags = activeFlags(node);
+ const hasFields = fields.length > 0;
+ if (flags.length > 0) {
+ const flagConnector = hasFields ? CONNECTOR.mid : CONNECTOR.last;
+ html += `
${childPrefix}${flagConnector}flags: ${flags.map(f => `${escapeHtml(f)}`).join(" ")}
`;
+ }
+ fields.forEach((field, idx) => {
+ const value = node[field];
+ const fieldIsLast = idx === fields.length - 1;
+ const fieldConnector = fieldIsLast ? CONNECTOR.last : CONNECTOR.mid;
+ const fieldChildPrefix = childPrefix + (fieldIsLast ? CONNECTOR.lastPad : CONNECTOR.midPad);
+
+ if (value === null || value === undefined) {
+ html += `
${childPrefix}${fieldConnector}${escapeHtml(field)}: ∅
`;
+ } else if (Array.isArray(value)) {
+ if (value.length === 0) {
+ html += `
${childPrefix}${fieldConnector}${escapeHtml(field)}: []
`;
+ } else {
+ 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);
+ } else {
+ const itemConnector = i === value.length - 1 ? CONNECTOR.last : CONNECTOR.mid;
+ html += `
${fieldChildPrefix}${itemConnector}${escapeHtml(JSON.stringify(item))}
`;
+ }
+ });
+ }
+ } else if (isNode(value)) {
+ html += `
${childPrefix}${fieldConnector}${escapeHtml(field)}:
`;
+ html += renderNode(value, source, fieldChildPrefix, true);
+ } else if (typeof value === "object" && value.startOffset !== undefined) {
+ const fieldLoc = formatLoc(source, value);
+ if (fieldLoc) {
+ html += `
${childPrefix}${fieldConnector}${escapeHtml(field)}: ${fieldLoc.text}
`;
+ }
+ } else if (typeof value === "string") {
+ html += `
${childPrefix}${fieldConnector}${escapeHtml(field)}: ${escapeHtml(JSON.stringify(value))}
`;
+ } else {
+ html += `
${childPrefix}${fieldConnector}${escapeHtml(field)}: ${escapeHtml(String(value))}
`;
+ }
+ });
+ html += `
`;
+
+ return html;
+}
+
+const ESCAPE_MAP = { "&": "&", "<": "<", ">": ">", '"': """ };
+function escapeHtml(str) {
+ return str.replace(/[&<>"]/g, ch => ESCAPE_MAP[ch]);
+}
+
+// Render a single diagnostic line
+function renderDiagnostic(source, item, kind) {
+ const loc = formatLoc(source, item.location);
+ const cssClass = kind === "Error" ? "error-text" : "warning-text";
+ return `${kind}: ${escapeHtml(item.message)}${loc ? ` (${loc.text})` : ""}
`;
+}
+
+// Delegated event handlers on #output (attached once, survive re-renders)
+output.addEventListener("click", (event) => {
+ const toggle = event.target.closest(".tree-toggle");
+ if (toggle) {
+ const treeitem = toggle.closest(".tree-node");
+ const collapsed = treeitem.getAttribute("aria-expanded") === "true";
+ setToggleState(toggle, collapsed);
+ return;
+ }
+
+ const locEl = event.target.closest("[data-sl]");
+ if (locEl) {
+ const sl = parseInt(locEl.dataset.sl);
+ const sc = parseInt(locEl.dataset.sc);
+ monacoEditor.revealLineInCenter(sl);
+ monacoEditor.setPosition({ lineNumber: sl, column: sc + 1 });
+ monacoEditor.focus();
+ }
+});
+
+output.addEventListener("mouseenter", (event) => {
+ const locEl = event.target.closest("[data-sl]");
+ if (!locEl) return;
+ highlightRange(parseInt(locEl.dataset.sl), parseInt(locEl.dataset.sc), parseInt(locEl.dataset.el), parseInt(locEl.dataset.ec));
+ (locEl.closest(".tree-node") || locEl).classList.add("tree-highlight");
+}, true);
+
+output.addEventListener("mouseleave", (event) => {
+ const locEl = event.target.closest("[data-sl]");
+ if (!locEl) return;
+ clearHighlight();
+ (locEl.closest(".tree-node") || locEl).classList.remove("tree-highlight");
+}, true);
+
+
+function render() {
+ if (!lastResult) return;
+
+ output.setAttribute("aria-labelledby", currentTab === "ast" ? "tab-ast" : "tab-diagnostics");
+
+ switch (currentTab) {
+ case "ast":
+ const tree = renderNode(lastResult.value, lastSource, "", true, true);
+ output.innerHTML = tree
+ ? `${tree}
`
+ : `${escapeHtml(lastResult.error || "Failed to parse.")}
`;
+ break;
+
+ case "diagnostics":
+ const errors = lastResult.errors || [];
+ const warnings = lastResult.warnings || [];
+ if (errors.length === 0 && warnings.length === 0) {
+ 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");
+ output.innerHTML = html;
+ }
+ break;
+ }
+}
+
+let timeout = null;
+function parse() {
+ if (timeout) clearTimeout(timeout);
+ timeout = setTimeout(() => {
+ lastSource = monacoEditor.getValue();
+ history.replaceState(null, "", `#${encodeSource(lastSource)}`);
+ try {
+ lastResult = parsePrism(instance.exports, lastSource);
+ } catch (e) {
+ lastResult = { value: null, error: e.message, errors: [], warnings: [] };
+ }
+ render();
+ }, 200);
+}
+
+monacoEditor.onDidChangeModelContent(parse);
+
+// Ready
+loading.classList.add("hidden");
+editorDiv.classList.add("ready");
+parse();
diff --git a/doc/robots.txt b/doc/robots.txt
new file mode 100644
index 0000000000..c2a49f4fb8
--- /dev/null
+++ b/doc/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Allow: /