// 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();
}