Guides
Browser
Browsers do not run TypeScript, but ulang-lite uses only erasable TypeScript syntax: removing the types leaves valid JavaScript with the same logic, and Node can do that for you.
Build the modules
scripts/build-browser.mjsexample project// Strip the types from ulang-lite's source and write browser-ready ES modules to public/ulang-lite/.
// Node >= 22.18. The engine's logic is not changed: only type annotations are removed.
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { stripTypeScriptTypes } from 'node:module';
const from = new URL('../vendor/ulang-lite/src/', import.meta.url);
const to = new URL('../public/ulang-lite/', import.meta.url);
mkdirSync(to, { recursive: true });
for (const name of readdirSync(from).filter((n) => n.endsWith('.ts')).sort()) {
const js = stripTypeScriptTypes(readFileSync(new URL(name, from), 'utf8'), { mode: 'strip' })
.replace(/(from\s+['"]\.\/[\w-]+)\.ts(['"])/g, '$1.js$2');
writeFileSync(new URL(name.replace(/\.ts$/, '.js'), to), js);
}
writeFileSync(new URL('LICENSE.txt', to), readFileSync(new URL('../LICENSE', from)));
console.log(`wrote ${readdirSync(to).length} files to public/ulang-lite/`);$ node scripts/build-browser.mjs
wrote 13 files to public/ulang-lite/Serve public/ulang-lite/ as static files and import index.js. The playground and the demo below use modules built exactly this way.
Use them
web/browser-demo.jsexample project// Runs on https://ulang.dev/docs/guides/browser: the deploy contract, decided as you change the form.
import { checkFacts, compileSource } from '/engine/index.js';
const source = await (await fetch('/docs/guides/deploy.ul')).text();
const result = compileSource(source);
if (!result.ok) throw new Error(`${result.diagnostic.line}:${result.diagnostic.column} ${result.diagnostic.message}`);
const [valid] = result.valid;
const [contract] = result.contracts;
const form = document.getElementById('deploy-demo');
const output = document.getElementById('deploy-demo-output');
const field = (name) => form.elements.namedItem(name);
function decide() {
const facts = {
DeployRequest: {
environment: field('environment').value,
tests_passed: field('tests_passed').checked,
approvals: Number(field('approvals').value),
author_approved_own_change: field('author_approved_own_change').checked,
error_rate_pct: Number(field('error_rate_pct').value),
change_freeze: field('change_freeze').checked,
},
};
const problem = checkFacts(valid, facts);
if (problem) {
output.className = 'result result-err';
output.textContent = `facts rejected: ${problem}`;
return;
}
const d = contract.decide(field('action').value, facts.DeployRequest);
output.className = `result ${d.verdict === 'PERMIT' ? 'result-ok' : 'result-err'}`;
output.textContent = `${d.verdict} ${d.clause_id}`;
}
form.addEventListener('input', decide);
form.addEventListener('submit', (event) => event.preventDefault());
decide();Try it
Nothing leaves the page. The contract is fetched once and compiled in the browser, and every change to the form is decided locally.