// Import Shiki from CDN import { createHighlighter } from 'https://esm.sh/shiki@1.22.2'; // WASM module let wasm; let highlighter; let editorContainer; // MLF language grammar const mlfGrammar = { name: 'mlf', scopeName: 'source.mlf', patterns: [ { include: '#comments' }, { include: '#keywords' }, { include: '#types' }, { include: '#strings' }, { include: '#numbers' }, { include: '#operators' } ], repository: { comments: { patterns: [ { name: 'comment.line.documentation.mlf', match: '///.*$' }, { name: 'comment.line.double-slash.mlf', match: '//.*$' } ] }, keywords: { patterns: [ { name: 'keyword.control.mlf', match: '\\b(use|record|inline|def|type|token|query|procedure|subscription|throws|constrained)\\b' }, { name: 'keyword.other.mlf', match: '\\b(main|defs)\\b' } ] }, types: { patterns: [ { name: 'storage.type.builtin.mlf', match: '\\b(null|boolean|integer|string|bytes|blob|unknown)\\b' }, { name: 'storage.type.format.mlf', match: '\\b(Datetime|Uri|AtUri|Did|Handle|Nsid|Cid|AtIdentifier|Language|Tid|RecordKey)\\b' }, { name: 'entity.name.type.mlf', match: '\\b[A-Z][a-zA-Z0-9_]*\\b' } ] }, strings: { patterns: [ { name: 'string.quoted.double.mlf', begin: '"', end: '"', patterns: [ { name: 'constant.character.escape.mlf', match: '\\\\.' } ] } ] }, numbers: { patterns: [ { name: 'constant.numeric.integer.mlf', match: '\\b\\d+\\b' } ] }, operators: { patterns: [ { name: 'punctuation.section.mlf', match: '[{}()\\[\\]]' }, { name: 'punctuation.separator.mlf', match: '[,;:]' }, { name: 'keyword.operator.optional.mlf', match: '\\?' }, { name: 'keyword.operator.union.mlf', match: '\\|' }, { name: 'keyword.operator.assignment.mlf', match: '=' }, { name: 'keyword.operator.arrow.mlf', match: '->' } ] } } }; // Load WASM module and Shiki async function init() { try { // Load WASM const wasmModule = await import('./pkg/mlf_wasm.js'); await wasmModule.default(); wasm = wasmModule; // Load Shiki with custom MLF grammar and all output languages highlighter = await createHighlighter({ themes: ['dracula'], langs: ['json', 'typescript', 'go', 'rust', mlfGrammar] }); // Initialize the editor initEditor(); setupEventListeners(); // Load from URL if share parameter exists loadFromUrl(); // Generate initial output handleCheck(); } catch (error) { console.error('Failed to initialize:', error); hidePlayground(); showError('Failed to load playground. Error: ' + error.message); } } function initEditor() { const textarea = document.getElementById('mlf-editor'); const initialCode = textarea.value; // Create editor container with line numbers and highlighting editorContainer = document.createElement('div'); editorContainer.className = 'editor-container-with-lines'; // Create line numbers const lineNumbers = document.createElement('div'); lineNumbers.className = 'line-numbers'; lineNumbers.id = 'line-numbers'; // Create wrapper for textarea and highlight layer const editorWrapper = document.createElement('div'); editorWrapper.className = 'editor-wrapper'; // Create highlight backdrop const highlightBackdrop = document.createElement('div'); highlightBackdrop.className = 'highlight-backdrop'; highlightBackdrop.id = 'highlight-backdrop'; // Style the textarea textarea.className = 'mlf-textarea'; // Insert container before textarea, then build the structure const parent = textarea.parentNode; parent.insertBefore(editorContainer, textarea); editorWrapper.appendChild(highlightBackdrop); editorWrapper.appendChild(textarea); editorContainer.appendChild(lineNumbers); editorContainer.appendChild(editorWrapper); // Set initial line numbers and highlighting updateLineNumbers(initialCode); updateHighlighting(initialCode); // Convert generated output textarea to highlighted div with line numbers const generateTextarea = document.getElementById('generate-result'); const generateOuterContainer = document.createElement('div'); generateOuterContainer.className = 'shiki-output-outer-container'; generateOuterContainer.id = 'generate-result-outer-container'; const generateLineNumbers = document.createElement('div'); generateLineNumbers.className = 'line-numbers'; generateLineNumbers.id = 'generate-line-numbers'; const generateWrapper = document.createElement('div'); generateWrapper.className = 'output-wrapper'; const generateContainer = document.createElement('div'); generateContainer.className = 'shiki-output-container'; generateContainer.id = 'generate-result-container'; generateWrapper.appendChild(generateContainer); generateOuterContainer.appendChild(generateLineNumbers); generateOuterContainer.appendChild(generateWrapper); generateTextarea.style.display = 'none'; generateTextarea.parentNode.insertBefore(generateOuterContainer, generateTextarea); } function updateHighlighting(code) { if (!highlighter) return; const backdrop = document.getElementById('highlight-backdrop'); if (!backdrop) return; // Update highlighted content in backdrop const html = highlighter.codeToHtml(code, { lang: 'mlf', theme: 'dracula' }); // Extract just the code content (remove pre/code wrapper) const temp = document.createElement('div'); temp.innerHTML = html; const codeElement = temp.querySelector('code'); if (codeElement) { backdrop.innerHTML = codeElement.innerHTML; } else { backdrop.textContent = code; } } function updateLineNumbers(code) { const lineNumbers = document.getElementById('line-numbers'); if (!lineNumbers) return; const lines = code.split('\n').length; const lineNumbersHtml = Array.from({ length: lines }, (_, i) => `
${i + 1}
` ).join(''); lineNumbers.innerHTML = lineNumbersHtml; } function getCaretPosition(element) { let caretOffset = 0; const selection = window.getSelection(); if (selection.rangeCount > 0) { const range = selection.getRangeAt(0); const preCaretRange = range.cloneRange(); preCaretRange.selectNodeContents(element); preCaretRange.setEnd(range.endContainer, range.endOffset); caretOffset = preCaretRange.toString().length; } return caretOffset; } function setCaretPosition(element, offset) { const range = document.createRange(); const selection = window.getSelection(); let charCount = 0; let found = false; function traverseNodes(node) { if (found) return; if (node.nodeType === Node.TEXT_NODE) { const nextCharCount = charCount + node.length; if (offset <= nextCharCount) { range.setStart(node, Math.min(offset - charCount, node.length)); range.collapse(true); found = true; return; } charCount = nextCharCount; } else { for (let i = 0; i < node.childNodes.length; i++) { traverseNodes(node.childNodes[i]); if (found) return; } } } traverseNodes(element); if (found) { selection.removeAllRanges(); selection.addRange(range); } } function getEditorContent() { const textarea = document.getElementById('mlf-editor'); return textarea ? textarea.value : ''; } function updateGeneratedOutput(code, generatorType) { const container = document.getElementById('generate-result-container'); if (!container || !highlighter) return; // Map generator types to Shiki language identifiers const langMap = { 'json': 'json', 'typescript': 'typescript', 'go': 'go', 'rust': 'rust' }; const lang = langMap[generatorType] || 'text'; // Format JSON if it's JSON output let formattedCode = code; if (generatorType === 'json') { try { formattedCode = JSON.stringify(JSON.parse(code), null, 2); } catch (e) { // If JSON parsing fails, use the original code formattedCode = code; } } try { // Highlight with Shiki const html = highlighter.codeToHtml(formattedCode, { lang: lang, theme: 'dracula' }); // Extract code content const temp = document.createElement('div'); temp.innerHTML = html; const codeElement = temp.querySelector('code'); if (codeElement) { container.innerHTML = codeElement.innerHTML; } else { container.textContent = formattedCode; } // Update line numbers for output updateGenerateLineNumbers(formattedCode); } catch (e) { // Fallback if highlighting fails container.textContent = formattedCode; updateGenerateLineNumbers(formattedCode); } } function updateGenerateLineNumbers(code) { const lineNumbers = document.getElementById('generate-line-numbers'); if (!lineNumbers) return; const lines = code.split('\n').length; const lineNumbersHtml = Array.from({ length: lines }, (_, i) => `
${i + 1}
` ).join(''); lineNumbers.innerHTML = lineNumbersHtml; } function hidePlayground() { const playground = document.querySelector('.playground-container'); if (playground) { playground.style.display = 'none'; } } function setupEventListeners() { // Tab switching const tabs = document.querySelectorAll('.tab'); tabs.forEach(tab => { tab.addEventListener('click', () => { const tabName = tab.dataset.tab; switchTab(tabName); }); }); // Editor input with debounce const textarea = document.getElementById('mlf-editor'); if (textarea) { let checkTimeout; textarea.addEventListener('input', () => { const code = textarea.value; // Update line numbers and highlighting immediately updateLineNumbers(code); updateHighlighting(code); // Clear previous timeout clearTimeout(checkTimeout); // Debounce validation/check only checkTimeout = setTimeout(() => { handleCheck(); }, 500); }); // Synchronize scroll between textarea, line numbers, and backdrop const lineNumbers = document.getElementById('line-numbers'); const backdrop = document.getElementById('highlight-backdrop'); if (lineNumbers && backdrop) { textarea.addEventListener('scroll', () => { lineNumbers.scrollTop = textarea.scrollTop; // Use transform to scroll the backdrop in sync backdrop.style.transform = `translate(-${textarea.scrollLeft}px, -${textarea.scrollTop}px)`; }); } } // Synchronize scroll between generated output and line numbers const generateWrapper = document.querySelector('.output-wrapper'); const generateLineNumbers = document.getElementById('generate-line-numbers'); if (generateWrapper && generateLineNumbers) { generateWrapper.addEventListener('scroll', () => { generateLineNumbers.scrollTop = generateWrapper.scrollTop; }); } // Generator dropdown change listener const generatorSelect = document.getElementById('generator-select'); if (generatorSelect) { generatorSelect.addEventListener('change', () => { handleCheck(); }); } // Auto-validate on record input change (debounced) const recordInput = document.getElementById('record-input'); if (recordInput) { let timeout; recordInput.addEventListener('input', () => { clearTimeout(timeout); timeout = setTimeout(handleValidate, 500); }); } // File path input change triggers re-generation const filePathInput = document.getElementById('file-path'); if (filePathInput) { let timeout; filePathInput.addEventListener('input', () => { clearTimeout(timeout); timeout = setTimeout(handleCheck, 500); }); } // Share button const shareBtn = document.getElementById('share-btn'); if (shareBtn) { shareBtn.addEventListener('click', handleShare); } } function switchTab(tabName) { // Update tab buttons document.querySelectorAll('.tab').forEach(tab => { tab.classList.toggle('active', tab.dataset.tab === tabName); }); // Update content document.querySelectorAll('.output-content').forEach(content => { content.classList.toggle('active', content.id === `${tabName}-output`); }); } function extractNamespaceFromPath(filePath) { // Remove leading/trailing slashes filePath = filePath.trim().replace(/^\/+|\/+$/g, ''); // Validate it ends with .mlf if (!filePath.endsWith('.mlf')) { return null; } // Remove the .mlf extension const withoutExt = filePath.slice(0, -4); // Replace slashes with dots to get the namespace const namespace = withoutExt.replace(/\//g, '.'); // Validate namespace format (should be valid NSID segments) // Basic validation: only alphanumeric, dots, and hyphens if (!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(namespace)) { return null; } return namespace; } function handleCheck() { if (!wasm) { return; } const source = getEditorContent(); const filePath = document.getElementById('file-path').value; // Extract namespace from file path const namespace = extractNamespaceFromPath(filePath); if (!namespace) { showError('Invalid file path. Must be a valid path ending in .mlf (e.g., com/example/app/thread.mlf)'); return; } try { // Check the MLF source const checkResult = wasm.check(source); if (checkResult.success) { hideError(); // Get selected generator const generatorSelect = document.getElementById('generator-select'); const selectedGenerator = generatorSelect ? generatorSelect.value : 'json'; // Generate code with selected generator const generateResult = wasm.generate_code(source, namespace, selectedGenerator); if (generateResult.success) { updateGeneratedOutput(generateResult.code, selectedGenerator); } else { showError(generateResult.error || 'Failed to generate code'); } } else { const errors = checkResult.errors || ['Unknown error']; showError(Array.isArray(errors) ? errors.join('\n') : errors); } } catch (error) { showError(`Error: ${error.message}`); } } function handleValidate() { if (!wasm) { return; } const lexiconSource = getEditorContent(); const recordJson = document.getElementById('record-input').value; if (!recordJson.trim()) { showValidateError('Please enter a JSON record to validate'); return; } try { const result = wasm.validate_record(lexiconSource, recordJson); if (result.success) { showValidateSuccess('✓ Record is valid'); } else { const errors = result.errors || ['Validation failed']; showValidateError(Array.isArray(errors) ? errors.join('\n') : errors); } } catch (error) { showValidateError(`Error: ${error.message}`); } } function showError(message) { const errorDiv = document.getElementById('errors'); errorDiv.textContent = message; errorDiv.classList.add('show'); } function hideError() { const errorDiv = document.getElementById('errors'); errorDiv.classList.remove('show'); } function showValidateSuccess(message) { const resultDiv = document.getElementById('validate-result'); resultDiv.textContent = message; resultDiv.className = 'success'; resultDiv.style.display = 'block'; } function showValidateError(message) { const resultDiv = document.getElementById('validate-result'); resultDiv.textContent = message; resultDiv.className = 'error'; resultDiv.style.display = 'block'; } // Base64url encoding (URL-safe base64) function base64urlEncode(str) { // Convert string to base64 const base64 = btoa(unescape(encodeURIComponent(str))); // Make it URL-safe by replacing +/= with -_~ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '~'); } function base64urlDecode(str) { // Convert URL-safe base64 back to regular base64 const base64 = str.replace(/-/g, '+').replace(/_/g, '/').replace(/~/g, '='); // Decode base64 to string return decodeURIComponent(escape(atob(base64))); } function loadFromUrl() { const urlParams = new URLSearchParams(window.location.search); const shareParam = urlParams.get('share'); if (shareParam) { try { const decoded = base64urlDecode(shareParam); // Split at the first newline character const [filePath, source] = decoded.split(/\n([\s\S]*)$/); document.getElementById('file-path').value = filePath; const textarea = document.getElementById('mlf-editor'); if (textarea) { textarea.value = source; updateLineNumbers(source); updateHighlighting(source); } } catch (error) { console.error('Failed to load shared code:', error); showError('Failed to load shared code. The link may be corrupted.'); } } } function handleShare() { const filePath = document.getElementById('file-path').value; const source = getEditorContent(); const encoded = base64urlEncode(`${filePath}\n${source}`); // Build share URL const baseUrl = window.location.origin + window.location.pathname; const shareUrl = `${baseUrl}?share=${encoded}`; // Copy to clipboard navigator.clipboard.writeText(shareUrl).then(() => { // Show success feedback const shareBtn = document.getElementById('share-btn'); const originalText = shareBtn.textContent; shareBtn.textContent = 'Copied!'; shareBtn.classList.add('copied'); setTimeout(() => { shareBtn.textContent = originalText; shareBtn.classList.remove('copied'); }, 2000); }).catch(err => { console.error('Failed to copy to clipboard:', err); // Fallback: show the URL in a prompt prompt('Copy this URL to share:', shareUrl); }); } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); }