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 + + + +
-

Prism Ruby parser

+

Prism

+

A portable, error-tolerant Ruby parser

- - C -

C reference

-
- - Ruby -

Ruby reference

-
- - Rust -

Rust reference

-
- - Java -

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
+
+ +
+

API Reference

+ +
+ +
+

Guides

+ +
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 + + + + + + + + + + + +
+
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 += ``; + 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 += `
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 += `
${escapeHtml(field)}:
`; + } else if (Array.isArray(value)) { + if (value.length === 0) { + html += `
${escapeHtml(field)}: []
`; + } else { + html += `
${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 += `
${escapeHtml(JSON.stringify(item))}
`; + } + }); + } + } else if (isNode(value)) { + html += `
${escapeHtml(field)}:
`; + html += renderNode(value, source, fieldChildPrefix, true); + } else if (typeof value === "object" && value.startOffset !== undefined) { + const fieldLoc = formatLoc(source, value); + if (fieldLoc) { + html += `
${escapeHtml(field)}: ${fieldLoc.text}
`; + } + } else if (typeof value === "string") { + html += `
${escapeHtml(field)}: ${escapeHtml(JSON.stringify(value))}
`; + } else { + html += `
${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: /