Add multi-file playground imports
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import { nav } from '../lib/docs.js';
|
||||
import '../style.css';
|
||||
|
||||
const { title = 'Decodal', active = '' } = Astro.props;
|
||||
const { title = 'Decodal', active = '', playground = false } = Astro.props;
|
||||
|
||||
function renderNav(items) {
|
||||
return `<ul class="nav-tree">${items
|
||||
@@ -30,12 +30,14 @@ function renderNav(items) {
|
||||
<a href="/playground/">Playground</a>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<a class="sidebar-title" href="/docs/">Manual</a>
|
||||
<nav set:html={renderNav(nav)} />
|
||||
</aside>
|
||||
<main class={Astro.props.playground ? 'playground' : ''}>
|
||||
<div class={playground ? 'layout playground-layout' : 'layout'}>
|
||||
{!playground && (
|
||||
<aside class="sidebar">
|
||||
<a class="sidebar-title" href="/docs/">Manual</a>
|
||||
<nav set:html={renderNav(nav)} />
|
||||
</aside>
|
||||
)}
|
||||
<main class={playground ? 'playground' : ''}>
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -6,19 +6,32 @@ import ManualLayout from '../layouts/ManualLayout.astro';
|
||||
<div class="playground-header">
|
||||
<div>
|
||||
<h1>Playground</h1>
|
||||
<p>Evaluate Decodal directly in your browser through WebAssembly.</p>
|
||||
<p>Virtual files are evaluated in the browser through WebAssembly. Use import paths such as <code>./schemas/service.dcdl</code>.</p>
|
||||
</div>
|
||||
<div class="playground-actions">
|
||||
<p id="status" class="status">Loading WASM...</p>
|
||||
<button id="run" disabled>Run</button>
|
||||
</div>
|
||||
<button id="run" disabled>Run</button>
|
||||
</div>
|
||||
<p id="status" class="status">Loading WASM...</p>
|
||||
<div class="playground-grid">
|
||||
|
||||
<div class="playground-shell">
|
||||
<aside class="file-panel">
|
||||
<div class="panel-header">
|
||||
<span>Files</span>
|
||||
<button id="new-file" type="button">New</button>
|
||||
</div>
|
||||
<div id="file-tree" class="file-tree"></div>
|
||||
<button id="delete-file" class="danger-button" type="button">Delete file</button>
|
||||
</aside>
|
||||
|
||||
<label class="pane input-pane">
|
||||
<span>Input</span>
|
||||
<span id="active-file">Input</span>
|
||||
<div class="editor-wrap">
|
||||
<pre id="source-highlight" aria-hidden="true"></pre>
|
||||
<textarea id="source" spellcheck="false"></textarea>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<section class="pane output-pane">
|
||||
<span>Output</span>
|
||||
<pre id="output"></pre>
|
||||
|
||||
@@ -1,28 +1,91 @@
|
||||
import init, { evaluate } from '../wasm/decodal_wasm.js';
|
||||
import init, { evaluateProject } from '../wasm/decodal_wasm.js';
|
||||
import { highlightDecodal } from '../lib/highlight.js';
|
||||
|
||||
const starter = `let
|
||||
Service = {
|
||||
name = String;
|
||||
port = Int & > 443 default 8443;
|
||||
feature.enable = Bool default true;
|
||||
};
|
||||
const STORAGE_KEY = 'decodal-playground-project-v1';
|
||||
|
||||
const starterFiles = {
|
||||
'main.dcdl': `let
|
||||
schema = import "./schemas/service.dcdl";
|
||||
in
|
||||
Service & {
|
||||
schema.Service & {
|
||||
name = "api";
|
||||
port = 9443;
|
||||
}
|
||||
`;
|
||||
`,
|
||||
'schemas/service.dcdl': `Service = {
|
||||
name = String;
|
||||
port = Int & > 443 default 8443;
|
||||
feature.enable = Bool default true;
|
||||
};
|
||||
`,
|
||||
};
|
||||
|
||||
const source = document.getElementById('source');
|
||||
const sourceHighlight = document.getElementById('source-highlight');
|
||||
const output = document.getElementById('output');
|
||||
const run = document.getElementById('run');
|
||||
const status = document.getElementById('status');
|
||||
const fileTree = document.getElementById('file-tree');
|
||||
const activeFile = document.getElementById('active-file');
|
||||
const newFile = document.getElementById('new-file');
|
||||
const deleteFile = document.getElementById('delete-file');
|
||||
|
||||
source.value = starter;
|
||||
const project = loadProject();
|
||||
|
||||
setActiveFile(project.activePath);
|
||||
renderFileTree();
|
||||
updateHighlight();
|
||||
|
||||
function loadProject() {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null');
|
||||
if (stored && stored.files && typeof stored.activePath === 'string') {
|
||||
const files = normalizeFiles(stored.files);
|
||||
const activePath = files[stored.activePath] === undefined ? Object.keys(files)[0] : stored.activePath;
|
||||
if (activePath) return { files, activePath };
|
||||
}
|
||||
} catch (_error) {
|
||||
// Fall back to the starter project.
|
||||
}
|
||||
return { files: { ...starterFiles }, activePath: 'main.dcdl' };
|
||||
}
|
||||
|
||||
function normalizeFiles(files) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(files)
|
||||
.filter(([_path, value]) => typeof value === 'string')
|
||||
.map(([path, value]) => [normalizePath(path), value])
|
||||
.filter(([path]) => path),
|
||||
);
|
||||
}
|
||||
|
||||
function saveProject() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(project));
|
||||
}
|
||||
|
||||
function normalizePath(path) {
|
||||
const parts = [];
|
||||
for (const part of String(path).replaceAll('\\', '/').split('/')) {
|
||||
if (!part || part === '.') continue;
|
||||
if (part === '..') parts.pop();
|
||||
else parts.push(part);
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function setActiveFile(path) {
|
||||
const normalized = normalizePath(path);
|
||||
if (project.files[normalized] === undefined) return;
|
||||
project.activePath = normalized;
|
||||
source.value = project.files[normalized];
|
||||
activeFile.textContent = normalized;
|
||||
deleteFile.disabled = Object.keys(project.files).length <= 1;
|
||||
updateHighlight();
|
||||
syncHighlightScroll();
|
||||
renderFileTree();
|
||||
saveProject();
|
||||
}
|
||||
|
||||
function updateHighlight() {
|
||||
sourceHighlight.innerHTML = `${highlightDecodal(source.value)}\n`;
|
||||
}
|
||||
@@ -33,11 +96,63 @@ function syncHighlightScroll() {
|
||||
}
|
||||
|
||||
function execute() {
|
||||
const result = JSON.parse(evaluate(source.value));
|
||||
project.files[project.activePath] = source.value;
|
||||
saveProject();
|
||||
const result = JSON.parse(evaluateProject(project.activePath, JSON.stringify(project.files)));
|
||||
output.textContent = result.ok ? result.output : result.error;
|
||||
output.classList.toggle('error', !result.ok);
|
||||
}
|
||||
|
||||
function renderFileTree() {
|
||||
const tree = buildTree(Object.keys(project.files).sort());
|
||||
fileTree.replaceChildren(renderTreeList(tree.children));
|
||||
}
|
||||
|
||||
function buildTree(paths) {
|
||||
const root = { name: '', children: new Map(), path: '' };
|
||||
for (const path of paths) {
|
||||
const parts = path.split('/');
|
||||
let node = root;
|
||||
let currentPath = '';
|
||||
parts.forEach((part, index) => {
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||
if (!node.children.has(part)) {
|
||||
node.children.set(part, { name: part, children: new Map(), path: currentPath, file: index + 1 === parts.length });
|
||||
}
|
||||
node = node.children.get(part);
|
||||
});
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function renderTreeList(children) {
|
||||
const list = document.createElement('ul');
|
||||
for (const child of [...children.values()].sort(compareNodes)) {
|
||||
const item = document.createElement('li');
|
||||
if (child.file) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = child.path === project.activePath ? 'file active' : 'file';
|
||||
button.textContent = child.name;
|
||||
button.title = child.path;
|
||||
button.addEventListener('click', () => setActiveFile(child.path));
|
||||
item.append(button);
|
||||
} else {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'folder';
|
||||
label.textContent = `${child.name}/`;
|
||||
item.append(label, renderTreeList(child.children));
|
||||
}
|
||||
list.append(item);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function compareNodes(a, b) {
|
||||
if (a.file !== b.file) return a.file ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
try {
|
||||
await init();
|
||||
run.disabled = false;
|
||||
@@ -48,9 +163,27 @@ try {
|
||||
}
|
||||
|
||||
run.addEventListener('click', execute);
|
||||
newFile.addEventListener('click', () => {
|
||||
const path = normalizePath(prompt('New virtual file path', 'schemas/types.dcdl') ?? '');
|
||||
if (!path) return;
|
||||
if (project.files[path] !== undefined) {
|
||||
setActiveFile(path);
|
||||
return;
|
||||
}
|
||||
project.files[path] = path.endsWith('.dcdl') ? '' : '// Decodal source\n';
|
||||
setActiveFile(path);
|
||||
});
|
||||
deleteFile.addEventListener('click', () => {
|
||||
if (Object.keys(project.files).length <= 1) return;
|
||||
if (!confirm(`Delete ${project.activePath}?`)) return;
|
||||
delete project.files[project.activePath];
|
||||
setActiveFile(Object.keys(project.files).sort()[0]);
|
||||
});
|
||||
source.addEventListener('input', () => {
|
||||
project.files[project.activePath] = source.value;
|
||||
updateHighlight();
|
||||
syncHighlightScroll();
|
||||
saveProject();
|
||||
});
|
||||
source.addEventListener('scroll', syncHighlightScroll);
|
||||
source.addEventListener('keydown', (event) => {
|
||||
|
||||
+106
-12
@@ -49,6 +49,10 @@ a:hover {
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
.playground-layout {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
@@ -94,7 +98,7 @@ main {
|
||||
}
|
||||
|
||||
main.playground {
|
||||
padding: 24px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.markdown {
|
||||
@@ -162,25 +166,41 @@ main.playground {
|
||||
}
|
||||
|
||||
.playground-page {
|
||||
height: calc(100vh - 104px);
|
||||
height: calc(100vh - 76px);
|
||||
}
|
||||
|
||||
.playground-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.playground-header h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.playground-header p {
|
||||
color: #4b5563;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.playground-header code {
|
||||
background: #eef2ff;
|
||||
border-radius: 4px;
|
||||
color: #3730a3;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.playground-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #2563eb;
|
||||
border: 0;
|
||||
@@ -188,7 +208,7 @@ button {
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
padding: 10px 18px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@@ -196,32 +216,103 @@ button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.playground-grid {
|
||||
.playground-shell {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
height: calc(100% - 82px);
|
||||
gap: 10px;
|
||||
grid-template-columns: 220px minmax(360px, 1.25fr) minmax(320px, 1fr);
|
||||
height: calc(100% - 52px);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.file-panel,
|
||||
.pane {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.pane > span {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
color: #374151;
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 10px 12px;
|
||||
justify-content: space-between;
|
||||
min-height: 38px;
|
||||
padding: 0 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panel-header button {
|
||||
font-size: 12px;
|
||||
padding: 5px 8px;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.file-tree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.file-tree ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.file-tree > ul {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.file-tree li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.file-tree .folder {
|
||||
color: #64748b;
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.file-tree .file {
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
color: #334155;
|
||||
display: block;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 5px 7px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-tree .file:hover,
|
||||
.file-tree .file.active {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.danger-button {
|
||||
background: transparent;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
border-radius: 0;
|
||||
color: #b91c1c;
|
||||
padding: 9px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.editor-wrap {
|
||||
background: #0f172a;
|
||||
flex: 1;
|
||||
@@ -237,7 +328,7 @@ button:disabled {
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
padding: 12px;
|
||||
position: absolute;
|
||||
tab-size: 2;
|
||||
white-space: pre;
|
||||
@@ -273,6 +364,9 @@ button:disabled {
|
||||
|
||||
.status {
|
||||
color: #4b5563;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
|
||||
export function evaluate(source: string): string;
|
||||
|
||||
export function evaluateProject(entry: string, files_json: string): string;
|
||||
|
||||
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||
|
||||
export interface InitOutput {
|
||||
readonly memory: WebAssembly.Memory;
|
||||
readonly evaluate: (a: number, b: number, c: number) => void;
|
||||
readonly evaluateProject: (a: number, b: number, c: number, d: number, e: number) => void;
|
||||
readonly __wbindgen_add_to_stack_pointer: (a: number) => number;
|
||||
readonly __wbindgen_export: (a: number, b: number) => number;
|
||||
readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
|
||||
|
||||
@@ -22,6 +22,32 @@ export function evaluate(source) {
|
||||
wasm.__wbindgen_export3(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} entry
|
||||
* @param {string} files_json
|
||||
* @returns {string}
|
||||
*/
|
||||
export function evaluateProject(entry, files_json) {
|
||||
let deferred3_0;
|
||||
let deferred3_1;
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
const ptr0 = passStringToWasm0(entry, wasm.__wbindgen_export, wasm.__wbindgen_export2);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passStringToWasm0(files_json, wasm.__wbindgen_export, wasm.__wbindgen_export2);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
wasm.evaluateProject(retptr, ptr0, len0, ptr1, len1);
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
deferred3_0 = r0;
|
||||
deferred3_1 = r1;
|
||||
return getStringFromWasm0(r0, r1);
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
wasm.__wbindgen_export3(deferred3_0, deferred3_1, 1);
|
||||
}
|
||||
}
|
||||
function __wbg_get_imports() {
|
||||
const import0 = {
|
||||
__proto__: null,
|
||||
|
||||
Binary file not shown.
@@ -2,6 +2,7 @@
|
||||
/* eslint-disable */
|
||||
export const memory: WebAssembly.Memory;
|
||||
export const evaluate: (a: number, b: number, c: number) => void;
|
||||
export const evaluateProject: (a: number, b: number, c: number, d: number, e: number) => void;
|
||||
export const __wbindgen_add_to_stack_pointer: (a: number) => number;
|
||||
export const __wbindgen_export: (a: number, b: number) => number;
|
||||
export const __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
|
||||
|
||||
Reference in New Issue
Block a user