|
|
|
@@ -0,0 +1,230 @@
|
|
|
|
|
import { compile, generateBrowserModule } from "@wrnexus/compiler";
|
|
|
|
|
import type { ViewNode } from "@wrnexus/syntax";
|
|
|
|
|
|
|
|
|
|
const escapeHtml = (value: string) =>
|
|
|
|
|
value.replace(
|
|
|
|
|
/[&<>"']/g,
|
|
|
|
|
(character) =>
|
|
|
|
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export interface PlaygroundCompilation {
|
|
|
|
|
source: string;
|
|
|
|
|
generated: string;
|
|
|
|
|
client: string;
|
|
|
|
|
html: string;
|
|
|
|
|
interactiveHtml: string;
|
|
|
|
|
diagnostics: Array<{ code: string; message: string; severity: string }>;
|
|
|
|
|
version: string;
|
|
|
|
|
}
|
|
|
|
|
export interface PlaygroundVersionAdapter {
|
|
|
|
|
version: string;
|
|
|
|
|
compile(source: string): Promise<Omit<PlaygroundCompilation, "source" | "version">>;
|
|
|
|
|
}
|
|
|
|
|
const DEFAULT_SOURCE = `component Counter {
|
|
|
|
|
state count = 0
|
|
|
|
|
functions { client function increment(): void { count++ } }
|
|
|
|
|
view { <button @click="increment">Count: {count}</button> }
|
|
|
|
|
}`;
|
|
|
|
|
|
|
|
|
|
function preview(nodes: ViewNode[]): string {
|
|
|
|
|
return nodes
|
|
|
|
|
.map((node) => {
|
|
|
|
|
if (node.type === "text")
|
|
|
|
|
return escapeHtml(node.value).replace(
|
|
|
|
|
/\{([^{}]+)\}/g,
|
|
|
|
|
'<span data-expression="$1">{$1}</span>',
|
|
|
|
|
);
|
|
|
|
|
if (node.type === "each")
|
|
|
|
|
return `<template data-each="${escapeHtml(node.list)}">${preview(node.body)}</template>`;
|
|
|
|
|
if (node.type === "if")
|
|
|
|
|
return `<template data-if="${escapeHtml(node.branches[0]?.cond ?? "else")}">${preview(node.branches[0]?.body ?? [])}</template>`;
|
|
|
|
|
const tag = /^[a-z][a-z0-9-]*$/.test(node.tag) ? node.tag : "div";
|
|
|
|
|
const attrs = node.attrs
|
|
|
|
|
.filter((attribute) => !/^(?:srcdoc|on\w+)$/i.test(attribute.name))
|
|
|
|
|
.map((attribute) =>
|
|
|
|
|
attribute.event
|
|
|
|
|
? ` data-play-event-${attribute.name}="${escapeHtml(attribute.value)}"`
|
|
|
|
|
: attribute.boolean
|
|
|
|
|
? ` ${attribute.name}`
|
|
|
|
|
: ` ${attribute.name}="${escapeHtml(attribute.value)}"`,
|
|
|
|
|
)
|
|
|
|
|
.join("");
|
|
|
|
|
return `<${tag}${attrs}>${preview(node.children)}</${tag}>`;
|
|
|
|
|
})
|
|
|
|
|
.join("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function literalState(ast: ReturnType<typeof compile>["ast"]): Record<string, unknown> {
|
|
|
|
|
const values: Record<string, unknown> = {};
|
|
|
|
|
for (const state of ast.states) {
|
|
|
|
|
const value = state.expr.trim();
|
|
|
|
|
if (/^-?\d+(?:\.\d+)?$/.test(value)) values[state.name] = Number(value);
|
|
|
|
|
else if (value === "true" || value === "false") values[state.name] = value === "true";
|
|
|
|
|
else if (value === "null") values[state.name] = null;
|
|
|
|
|
else if (/^(["']).*\1$/.test(value)) values[state.name] = value.slice(1, -1);
|
|
|
|
|
}
|
|
|
|
|
return values;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function safeOperations(ast: ReturnType<typeof compile>["ast"]): Record<string, unknown> {
|
|
|
|
|
const output: Record<string, unknown> = {};
|
|
|
|
|
const states = new Set(ast.states.map((state) => state.name));
|
|
|
|
|
for (const fn of ast.runtimeFunctions.filter((entry) => entry.runtime !== "server")) {
|
|
|
|
|
const body = fn.body.trim().replace(/;$/, "");
|
|
|
|
|
const unary = /^([A-Za-z_$][\w$]*)(\+\+|--)$/.exec(body);
|
|
|
|
|
const assignment =
|
|
|
|
|
/^([A-Za-z_$][\w$]*)\s*(\+=|-=|=)\s*(-?\d+(?:\.\d+)?|true|false|(["']).*\4)$/.exec(body);
|
|
|
|
|
const match = unary ?? assignment;
|
|
|
|
|
if (!match || !states.has(match[1]!)) continue;
|
|
|
|
|
const raw = assignment?.[3];
|
|
|
|
|
const value =
|
|
|
|
|
raw === undefined
|
|
|
|
|
? undefined
|
|
|
|
|
: /^-?\d/.test(raw)
|
|
|
|
|
? Number(raw)
|
|
|
|
|
: raw === "true" || raw === "false"
|
|
|
|
|
? raw === "true"
|
|
|
|
|
: raw.slice(1, -1);
|
|
|
|
|
output[fn.name] = {
|
|
|
|
|
state: match[1]!,
|
|
|
|
|
operation: match[2]!,
|
|
|
|
|
...(value !== undefined ? { value } : {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return output;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function interactiveDocument(fragment: string, ast: ReturnType<typeof compile>["ast"]): string {
|
|
|
|
|
const data = JSON.stringify({
|
|
|
|
|
state: literalState(ast),
|
|
|
|
|
operations: safeOperations(ast),
|
|
|
|
|
}).replace(/</g, "\\u003c");
|
|
|
|
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"><style>body{font:16px system-ui;padding:1rem}</style></head><body>${fragment}<script>const model=${data};function draw(){document.querySelectorAll('[data-expression]').forEach(n=>{const k=n.dataset.expression.trim();if(Object.prototype.hasOwnProperty.call(model.state,k))n.textContent=String(model.state[k])})}document.addEventListener('click',e=>{const n=e.target.closest('[data-play-event-click]');if(!n)return;const op=model.operations[n.dataset.playEventClick];if(!op)return;if(op.operation==='++')model.state[op.state]++;else if(op.operation==='--')model.state[op.state]--;else if(op.operation==='+=')model.state[op.state]+=op.value;else if(op.operation==='-=')model.state[op.state]-=op.value;else model.state[op.state]=op.value;draw()});draw()</script></body></html>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function compilePlayground(source: string, version = "0.8.0"): PlaygroundCompilation {
|
|
|
|
|
if (new TextEncoder().encode(source).byteLength > 128 * 1024)
|
|
|
|
|
throw new RangeError("WRN-PLAYGROUND-SOURCE-LIMIT");
|
|
|
|
|
const result = compile(source, "playground.wrn");
|
|
|
|
|
const html = preview(result.ast.view);
|
|
|
|
|
return {
|
|
|
|
|
source,
|
|
|
|
|
generated: result.code,
|
|
|
|
|
client: generateBrowserModule(result.ast),
|
|
|
|
|
html,
|
|
|
|
|
interactiveHtml: interactiveDocument(html, result.ast),
|
|
|
|
|
diagnostics: result.richDiagnostics.map(({ code, message, severity }) => ({
|
|
|
|
|
code,
|
|
|
|
|
message,
|
|
|
|
|
severity,
|
|
|
|
|
})),
|
|
|
|
|
version,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function encodePlaygroundShare(source: string): string {
|
|
|
|
|
const bytes = new TextEncoder().encode(source);
|
|
|
|
|
if (bytes.byteLength > 64 * 1024) throw new RangeError("WRN-PLAYGROUND-SHARE-LIMIT");
|
|
|
|
|
let binary = "";
|
|
|
|
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
|
|
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
|
|
|
}
|
|
|
|
|
export function decodePlaygroundShare(value: string): string {
|
|
|
|
|
if (!/^[A-Za-z0-9_-]{1,100000}$/.test(value))
|
|
|
|
|
throw new Error("WRN-PLAYGROUND-SHARE: invalid payload");
|
|
|
|
|
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
|
|
|
|
|
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
|
|
|
if (bytes.byteLength > 64 * 1024) throw new RangeError("WRN-PLAYGROUND-SHARE-LIMIT");
|
|
|
|
|
return new TextDecoder().decode(bytes);
|
|
|
|
|
}
|
|
|
|
|
export async function comparePlaygroundVersions(
|
|
|
|
|
source: string,
|
|
|
|
|
adapters: PlaygroundVersionAdapter[],
|
|
|
|
|
) {
|
|
|
|
|
return {
|
|
|
|
|
current: compilePlayground(source),
|
|
|
|
|
comparisons: await Promise.all(
|
|
|
|
|
adapters.map(async (adapter) => ({
|
|
|
|
|
version: adapter.version,
|
|
|
|
|
...(await adapter.compile(source)),
|
|
|
|
|
})),
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const CLIENT = String.raw`
|
|
|
|
|
const source=document.querySelector('#source'),status=document.querySelector('#status'),examples=document.querySelector('#examples');
|
|
|
|
|
async function run(versions=false){status.textContent=versions?'Comparing':'Compiling';const response=await fetch('/api/compile',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({source:source.value,versions})});const value=await response.json(),current=value.current||value;status.textContent=response.ok?(versions?'Compared':'Compiled'):'Failed';for(const name of ['html','generated','client','diagnostics'])document.querySelector('#'+name).textContent=typeof current[name]==='string'?current[name]:JSON.stringify(current[name]||[],null,2);document.querySelector('#versions').textContent=JSON.stringify(value.comparisons||[],null,2);document.querySelector('#preview').srcdoc=current.interactiveHtml||current.html||'';if(value.share)history.replaceState(null,'','?code='+value.share)}
|
|
|
|
|
async function loadExamples(){const response=await fetch('/api/examples'),value=await response.json();for(const [name,code] of Object.entries(value)){const option=document.createElement('option');option.value=name;option.textContent=name;examples.append(option);examples.dataset[name]=code}if(examples.options.length)examples.dispatchEvent(new Event('change'))}
|
|
|
|
|
document.querySelector('#run').addEventListener('click',()=>run());
|
|
|
|
|
document.querySelector('#compare').addEventListener('click',()=>run(true));
|
|
|
|
|
examples.addEventListener('change',()=>{const value=examples.dataset[examples.value];if(value)source.value=value});
|
|
|
|
|
document.querySelector('#share').addEventListener('click',()=>navigator.clipboard&&navigator.clipboard.writeText(location.href));
|
|
|
|
|
loadExamples().catch(()=>{status.textContent='Examples unavailable'});
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
function page(source: string) {
|
|
|
|
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>WRNexus Playground</title><style>body{font:14px system-ui;margin:0;padding:1rem;background:#111827;color:#f9fafb}main{display:grid;grid-template-columns:1fr 1fr;gap:1rem}textarea,pre,iframe{box-sizing:border-box;width:100%;min-height:18rem;background:#fff;color:#111;padding:.75rem;overflow:auto}button,select{padding:.6rem 1rem;margin:.25rem}section{min-width:0}@media(max-width:800px){main{grid-template-columns:1fr}}</style></head><body><h1>WRNexus Playground</h1><p>Write WRN, inspect SSR HTML/client/generated output, test components, share the URL, or attach it to a report.</p><label for="examples">Example</label><select id="examples" aria-label="Playground example"></select><button id="run">Compile</button><button id="compare">Compare versions</button><button id="share">Copy share URL</button> <span id="status" role="status"></span><main><section><h2>WRN source</h2><textarea id="source">${escapeHtml(source)}</textarea><h2>Sandboxed preview</h2><iframe id="preview" title="WRNexus component preview" sandbox="allow-scripts"></iframe></section><section><h2>SSR HTML</h2><pre id="html"></pre><h2>Generated JavaScript</h2><pre id="generated"></pre><h2>Client output</h2><pre id="client"></pre><h2>Diagnostics</h2><pre id="diagnostics"></pre><h2>Version comparisons</h2><pre id="versions"></pre></section></main><script src="/playground.js" defer></script></body></html>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createPlaygroundHandler(
|
|
|
|
|
options: { versions?: PlaygroundVersionAdapter[]; examples?: Record<string, string> } = {},
|
|
|
|
|
) {
|
|
|
|
|
return async (request: Request): Promise<Response> => {
|
|
|
|
|
const url = new URL(request.url);
|
|
|
|
|
if (url.pathname === "/playground.js")
|
|
|
|
|
return new Response(CLIENT, {
|
|
|
|
|
headers: {
|
|
|
|
|
"content-type": "text/javascript; charset=utf-8",
|
|
|
|
|
"cache-control": "public, max-age=3600",
|
|
|
|
|
"x-content-type-options": "nosniff",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (url.pathname === "/api/examples")
|
|
|
|
|
return Response.json(options.examples ?? { counter: DEFAULT_SOURCE });
|
|
|
|
|
if (url.pathname === "/api/compile" && request.method === "POST") {
|
|
|
|
|
try {
|
|
|
|
|
const text = await request.text();
|
|
|
|
|
if (new TextEncoder().encode(text).byteLength > 140 * 1024)
|
|
|
|
|
return Response.json({ error: "Payload too large" }, { status: 413 });
|
|
|
|
|
const body = JSON.parse(text) as { source?: unknown; versions?: boolean };
|
|
|
|
|
if (typeof body.source !== "string")
|
|
|
|
|
return Response.json({ error: "source is required" }, { status: 400 });
|
|
|
|
|
const result =
|
|
|
|
|
body.versions && options.versions?.length
|
|
|
|
|
? await comparePlaygroundVersions(body.source, options.versions)
|
|
|
|
|
: compilePlayground(body.source);
|
|
|
|
|
return Response.json(
|
|
|
|
|
{ ...result, share: encodePlaygroundShare(body.source) },
|
|
|
|
|
{ headers: { "cache-control": "no-store" } },
|
|
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return Response.json(
|
|
|
|
|
{ error: error instanceof Error ? error.message : "Compilation failed" },
|
|
|
|
|
{ status: 400 },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (url.pathname !== "/") return new Response("Not Found", { status: 404 });
|
|
|
|
|
let source = DEFAULT_SOURCE;
|
|
|
|
|
const encoded = url.searchParams.get("code");
|
|
|
|
|
if (encoded) {
|
|
|
|
|
try {
|
|
|
|
|
source = decodePlaygroundShare(encoded);
|
|
|
|
|
} catch {
|
|
|
|
|
source = DEFAULT_SOURCE;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return new Response(page(source), {
|
|
|
|
|
headers: {
|
|
|
|
|
"content-type": "text/html; charset=utf-8",
|
|
|
|
|
"content-security-policy":
|
|
|
|
|
"default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; frame-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'",
|
|
|
|
|
"x-frame-options": "SAMEORIGIN",
|
|
|
|
|
"x-content-type-options": "nosniff",
|
|
|
|
|
"referrer-policy": "no-referrer",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
}
|