Add playground example loader
This commit is contained in:
parent
f928028007
commit
c26f9ca50a
|
|
@ -9,6 +9,11 @@ import ManualLayout from '../layouts/ManualLayout.astro';
|
||||||
<p>Virtual files are evaluated in the browser through WebAssembly. Use import paths such as <code>./schemas/service.dcdl</code>.</p>
|
<p>Virtual files are evaluated in the browser through WebAssembly. Use import paths such as <code>./schemas/service.dcdl</code>.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="playground-actions">
|
<div class="playground-actions">
|
||||||
|
<label class="example-picker">
|
||||||
|
<span>Example</span>
|
||||||
|
<select id="example-select" aria-label="Playground example"></select>
|
||||||
|
</label>
|
||||||
|
<button id="load-example" type="button">Load</button>
|
||||||
<p id="status" class="status">Loading WASM...</p>
|
<p id="status" class="status">Loading WASM...</p>
|
||||||
<button id="run" disabled>Run</button>
|
<button id="run" disabled>Run</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
174
site/decodal-site/src/scripts/playground-examples.js
Normal file
174
site/decodal-site/src/scripts/playground-examples.js
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
export const playgroundExamples = [
|
||||||
|
{
|
||||||
|
id: 'service-deployment',
|
||||||
|
title: 'Service deployment',
|
||||||
|
activePath: 'main.dcdl',
|
||||||
|
files: {
|
||||||
|
'main.dcdl': `let
|
||||||
|
schema = import "./schemas/service.dcdl";
|
||||||
|
prod = import "./env/prod.dcdl";
|
||||||
|
shared = import "./shared/tags.dcdl";
|
||||||
|
in
|
||||||
|
schema.Service & prod.ServicePatch & {
|
||||||
|
name = "api";
|
||||||
|
image = "registry.example.com/api:2026-06-24";
|
||||||
|
port = 9000 + 443;
|
||||||
|
public = prod.is_public && 9000 + 443 > 9000;
|
||||||
|
tags = shared.common ++ ["api", "edge"];
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
'schemas/service.dcdl': `Service = {
|
||||||
|
name = String;
|
||||||
|
image = String;
|
||||||
|
port = Int & > 443 default 8443;
|
||||||
|
replicas = Int & > 0 default 2;
|
||||||
|
public = Bool default false;
|
||||||
|
feature.metrics = Bool default true;
|
||||||
|
feature.tracing = Bool default false;
|
||||||
|
};
|
||||||
|
`,
|
||||||
|
'env/prod.dcdl': `is_public = true;
|
||||||
|
|
||||||
|
ServicePatch = {
|
||||||
|
replicas = 3;
|
||||||
|
feature.tracing = true;
|
||||||
|
};
|
||||||
|
`,
|
||||||
|
'shared/tags.dcdl': `common = ["decodal", "prod"];
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'access-policy',
|
||||||
|
title: 'Access policy',
|
||||||
|
activePath: 'main.dcdl',
|
||||||
|
files: {
|
||||||
|
'main.dcdl': `let
|
||||||
|
policy = import "./policy/base.dcdl";
|
||||||
|
teams = import "./policy/teams.dcdl";
|
||||||
|
env = "prod";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
service = "payments";
|
||||||
|
readers = policy.defaultReaders ++ teams.observability;
|
||||||
|
writers = policy.defaultWriters ++ teams.payments;
|
||||||
|
admins = match env {
|
||||||
|
"prod": policy.breakglassAdmins;
|
||||||
|
_: teams.platform;
|
||||||
|
};
|
||||||
|
audit.required = env == "prod";
|
||||||
|
audit.retention_days = match env {
|
||||||
|
"prod": 365;
|
||||||
|
_: 30;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
'policy/base.dcdl': `defaultReaders = ["group:engineering", "group:support"];
|
||||||
|
defaultWriters = ["group:platform"];
|
||||||
|
breakglassAdmins = ["user:oncall-primary", "user:oncall-secondary"];
|
||||||
|
`,
|
||||||
|
'policy/teams.dcdl': `platform = ["group:platform-admins"];
|
||||||
|
payments = ["group:payments-api", "group:payments-sre"];
|
||||||
|
observability = ["group:observability"];
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'edge-worker',
|
||||||
|
title: 'Edge worker config',
|
||||||
|
activePath: 'main.dcdl',
|
||||||
|
files: {
|
||||||
|
'main.dcdl': `let
|
||||||
|
schema = import "./schemas/worker.dcdl";
|
||||||
|
routes = import "./routes.dcdl";
|
||||||
|
production = true;
|
||||||
|
in
|
||||||
|
schema.Worker & {
|
||||||
|
name = "decodal-docs";
|
||||||
|
compatibility_date = "2026-06-15";
|
||||||
|
workers_dev = !production;
|
||||||
|
route.host = routes.primary_host;
|
||||||
|
route.paths = routes.docs_paths ++ routes.playground_paths;
|
||||||
|
cache.ttl_seconds = 60 * 60;
|
||||||
|
cache.bypass = !production;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
'schemas/worker.dcdl': `Worker = {
|
||||||
|
name = String;
|
||||||
|
compatibility_date = String;
|
||||||
|
workers_dev = Bool default false;
|
||||||
|
route.host = String;
|
||||||
|
cache.ttl_seconds = Int & >= 0 default 300;
|
||||||
|
cache.bypass = Bool default false;
|
||||||
|
};
|
||||||
|
`,
|
||||||
|
'routes.dcdl': `primary_host = "decodal.example.com";
|
||||||
|
docs_paths = ["/docs/*", "/assets/*"];
|
||||||
|
playground_paths = ["/playground/*"];
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'data-pipeline',
|
||||||
|
title: 'Data pipeline',
|
||||||
|
activePath: 'main.dcdl',
|
||||||
|
files: {
|
||||||
|
'main.dcdl': `let
|
||||||
|
schema = import "./schemas/pipeline.dcdl";
|
||||||
|
presets = import "./presets/batch.dcdl";
|
||||||
|
env = "staging";
|
||||||
|
in
|
||||||
|
schema.Pipeline & presets.Batch & {
|
||||||
|
name = "events-rollup";
|
||||||
|
source.topic = "events.raw";
|
||||||
|
sink.table = "analytics.events_daily";
|
||||||
|
workers = match env {
|
||||||
|
"prod": 8;
|
||||||
|
"staging": 3;
|
||||||
|
_: 1;
|
||||||
|
};
|
||||||
|
alerts.enabled = env != "dev";
|
||||||
|
transforms = presets.standardTransforms ++ ["dedupe", "aggregate_daily"];
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
'schemas/pipeline.dcdl': `Pipeline = {
|
||||||
|
name = String;
|
||||||
|
source.topic = String;
|
||||||
|
sink.table = String;
|
||||||
|
workers = Int & > 0 default 1;
|
||||||
|
batch.size = Int & >= 100 default 1000;
|
||||||
|
batch.timeout_seconds = Int & > 0 default 60;
|
||||||
|
alerts.enabled = Bool default false;
|
||||||
|
};
|
||||||
|
`,
|
||||||
|
'presets/batch.dcdl': `standardTransforms = ["parse_json", "validate_schema", "enrich_metadata"];
|
||||||
|
|
||||||
|
Batch = {
|
||||||
|
batch.size = 5000;
|
||||||
|
batch.timeout_seconds = 30 + 30;
|
||||||
|
};
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'diagnostics',
|
||||||
|
title: 'Diagnostics: invalid port',
|
||||||
|
activePath: 'main.dcdl',
|
||||||
|
files: {
|
||||||
|
'main.dcdl': `let
|
||||||
|
schema = import "./schemas/service.dcdl";
|
||||||
|
in
|
||||||
|
schema.Service & {
|
||||||
|
name = "broken-api";
|
||||||
|
port = 442;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
'schemas/service.dcdl': `Service = {
|
||||||
|
name = String;
|
||||||
|
port = Int & > 443 default 8443;
|
||||||
|
replicas = Int & > 0 default 2;
|
||||||
|
};
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -1,26 +1,9 @@
|
||||||
import init, { evaluateProject } from '../wasm/decodal_wasm.js';
|
import init, { evaluateProject } from '../wasm/decodal_wasm.js';
|
||||||
import { highlightDecodal } from '../lib/highlight.js';
|
import { highlightDecodal } from '../lib/highlight.js';
|
||||||
|
import { playgroundExamples } from './playground-examples.js';
|
||||||
|
|
||||||
const STORAGE_KEY = 'decodal-playground-project-v1';
|
const STORAGE_KEY = 'decodal-playground-project-v1';
|
||||||
|
const starterProject = playgroundExamples[0];
|
||||||
const starterFiles = {
|
|
||||||
'main.dcdl': `let
|
|
||||||
schema = import "./schemas/service.dcdl";
|
|
||||||
in
|
|
||||||
schema.Service & {
|
|
||||||
name = "api";
|
|
||||||
port = 9000 + 443;
|
|
||||||
tags = ["web"] ++ ["prod"];
|
|
||||||
feature.enable = 9000 + 443 > 9000 && true;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
'schemas/service.dcdl': `Service = {
|
|
||||||
name = String;
|
|
||||||
port = Int & > 443 default 8443;
|
|
||||||
feature.enable = Bool default true;
|
|
||||||
};
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const source = document.getElementById('source');
|
const source = document.getElementById('source');
|
||||||
const sourceHighlight = document.getElementById('source-highlight');
|
const sourceHighlight = document.getElementById('source-highlight');
|
||||||
|
|
@ -31,9 +14,19 @@ const fileTree = document.getElementById('file-tree');
|
||||||
const activeFile = document.getElementById('active-file');
|
const activeFile = document.getElementById('active-file');
|
||||||
const newFile = document.getElementById('new-file');
|
const newFile = document.getElementById('new-file');
|
||||||
const deleteFile = document.getElementById('delete-file');
|
const deleteFile = document.getElementById('delete-file');
|
||||||
|
const exampleSelect = document.getElementById('example-select');
|
||||||
|
const loadExample = document.getElementById('load-example');
|
||||||
|
|
||||||
const project = loadProject();
|
const project = loadProject();
|
||||||
|
|
||||||
|
for (const example of playgroundExamples) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = example.id;
|
||||||
|
option.textContent = example.title;
|
||||||
|
exampleSelect.append(option);
|
||||||
|
}
|
||||||
|
exampleSelect.value = starterProject.id;
|
||||||
|
|
||||||
setActiveFile(project.activePath);
|
setActiveFile(project.activePath);
|
||||||
renderFileTree();
|
renderFileTree();
|
||||||
updateHighlight();
|
updateHighlight();
|
||||||
|
|
@ -49,7 +42,11 @@ function loadProject() {
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Fall back to the starter project.
|
// Fall back to the starter project.
|
||||||
}
|
}
|
||||||
return { files: { ...starterFiles }, activePath: 'main.dcdl' };
|
return { files: cloneFiles(starterProject.files), activePath: starterProject.activePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneFiles(files) {
|
||||||
|
return Object.fromEntries(Object.entries(files).map(([path, source]) => [path, source]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFiles(files) {
|
function normalizeFiles(files) {
|
||||||
|
|
@ -65,6 +62,17 @@ function saveProject() {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(project));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(project));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadExampleProject(exampleId) {
|
||||||
|
const example = playgroundExamples.find((item) => item.id === exampleId) ?? starterProject;
|
||||||
|
project.files = cloneFiles(example.files);
|
||||||
|
project.activePath = example.activePath;
|
||||||
|
exampleSelect.value = example.id;
|
||||||
|
setActiveFile(project.activePath);
|
||||||
|
output.textContent = '';
|
||||||
|
output.classList.remove('error');
|
||||||
|
execute();
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePath(path) {
|
function normalizePath(path) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
for (const part of String(path).replaceAll('\\', '/').split('/')) {
|
for (const part of String(path).replaceAll('\\', '/').split('/')) {
|
||||||
|
|
@ -165,6 +173,12 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
run.addEventListener('click', execute);
|
run.addEventListener('click', execute);
|
||||||
|
loadExample.addEventListener('click', () => {
|
||||||
|
const example = playgroundExamples.find((item) => item.id === exampleSelect.value);
|
||||||
|
if (!example) return;
|
||||||
|
if (!confirm(`Load example "${example.title}"? This replaces the current virtual files.`)) return;
|
||||||
|
loadExampleProject(example.id);
|
||||||
|
});
|
||||||
newFile.addEventListener('click', () => {
|
newFile.addEventListener('click', () => {
|
||||||
const path = normalizePath(prompt('New virtual file path', 'schemas/types.dcdl') ?? '');
|
const path = normalizePath(prompt('New virtual file path', 'schemas/types.dcdl') ?? '');
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
|
|
@ -172,7 +186,7 @@ newFile.addEventListener('click', () => {
|
||||||
setActiveFile(path);
|
setActiveFile(path);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
project.files[path] = path.endsWith('.dcdl') ? '' : '// Decodal source\n';
|
project.files[path] = 'value = "new";\n';
|
||||||
setActiveFile(path);
|
setActiveFile(path);
|
||||||
});
|
});
|
||||||
deleteFile.addEventListener('click', () => {
|
deleteFile.addEventListener('click', () => {
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,30 @@ main.playground {
|
||||||
.playground-actions {
|
.playground-actions {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-picker {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
gap: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-picker select {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user