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/`);
example projectreal output
$ 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

The demo needs JavaScript.

Nothing leaves the page. The contract is fetched once and compiled in the browser, and every change to the form is decided locally.