noyalib-wasm 0.0.12

WebAssembly (wasm-bindgen) bindings for the noyalib YAML library — browser-ready parse / serialise / lossless edit
Documentation
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>noyalib · WASM playground</title>
  <style>
    :root {
      --bg: #0a0a0a;
      --panel: #161616;
      --panel-2: #1c1c1c;
      --line: #2a2a2a;
      --fg: #e8e8e8;
      --dim: #888;
      --accent: #4ade80;
      --warn: #fbbf24;
      --err: #f87171;
      --diff-add: rgba(74, 222, 128, 0.18);
      --diff-del: rgba(248, 113, 113, 0.18);
    }
    * { margin: 0; padding: 0; box-sizing: border-box; }
    html, body { height: 100%; }
    body {
      font: 14px/1.45 ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", monospace;
      background: var(--bg);
      color: var(--fg);
      padding: 1.25rem;
    }
    header { margin-bottom: 1rem; }
    header h1 { font-size: 1rem; font-weight: 600; letter-spacing: 0.02em; color: #fff; }
    header p { font-size: 0.78rem; color: var(--dim); margin-top: 0.25rem; }
    header a { color: var(--accent); text-decoration: none; }

    main { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
    .panel {
      background: var(--panel);
      border: 1px solid var(--line);
      border-radius: 8px;
      padding: 0.75rem;
      display: flex;
      flex-direction: column;
      min-height: 320px;
    }
    .panel h2 {
      font-size: 0.7rem;
      font-weight: 600;
      text-transform: uppercase;
      letter-spacing: 0.08em;
      color: var(--dim);
      margin-bottom: 0.5rem;
    }
    .source, .output, .json-view {
      flex: 1;
      width: 100%;
      background: var(--panel-2);
      color: var(--fg);
      border: 1px solid var(--line);
      border-radius: 5px;
      padding: 0.6rem;
      font: inherit;
      resize: none;
      white-space: pre;
      overflow: auto;
    }
    .source:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
    .output, .json-view { white-space: pre-wrap; }
    .output mark.add { background: var(--diff-add); padding: 0 2px; border-radius: 2px; }
    .output mark.del { background: var(--diff-del); padding: 0 2px; border-radius: 2px; text-decoration: line-through; }

    .demo-bar {
      grid-column: 1 / -1;
      background: var(--panel);
      border: 1px solid var(--line);
      border-radius: 8px;
      padding: 0.75rem;
      display: flex;
      gap: 0.5rem;
      flex-wrap: wrap;
      align-items: center;
    }
    .demo-bar label { font-size: 0.72rem; color: var(--dim); margin-right: -0.3rem; }
    .demo-bar input {
      flex: 1 1 160px;
      min-width: 120px;
      background: var(--panel-2);
      color: var(--fg);
      border: 1px solid var(--line);
      border-radius: 4px;
      padding: 0.4rem 0.55rem;
      font: inherit;
    }
    .demo-bar input:focus { outline: none; border-color: var(--accent); }
    button {
      background: var(--panel-2);
      color: var(--accent);
      border: 1px solid var(--line);
      border-radius: 4px;
      padding: 0.45rem 0.85rem;
      font: inherit;
      cursor: pointer;
      transition: border-color 0.1s ease, background 0.1s ease;
    }
    button:hover { background: #222; border-color: var(--accent); }
    button.primary { color: var(--bg); background: var(--accent); border-color: var(--accent); }
    button.primary:hover { background: #6ee79a; }

    .status {
      grid-column: 1 / -1;
      font-size: 0.78rem;
      min-height: 1em;
    }
    .status.ok  { color: var(--accent); }
    .status.warn { color: var(--warn); }
    .status.err { color: var(--err); }

    .more-actions {
      grid-column: 1 / -1;
      display: flex;
      gap: 0.4rem;
      flex-wrap: wrap;
      padding-top: 0.25rem;
      border-top: 1px dashed var(--line);
    }
    .more-actions h3 { font-size: 0.7rem; color: var(--dim); text-transform: uppercase; letter-spacing: 0.08em; align-self: center; margin-right: 0.5rem; }

    .comments {
      grid-column: 1 / -1;
      background: var(--panel);
      border: 1px solid var(--line);
      border-radius: 8px;
      padding: 0.75rem;
    }
    .comments h2 { font-size: 0.7rem; color: var(--dim); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 0.4rem; }
    .comments .row { display: flex; gap: 0.4rem; align-items: baseline; padding: 0.15rem 0; }
    .comments .pos { color: var(--dim); width: 70px; flex-shrink: 0; font-size: 0.78rem; }
    .comments .text { color: var(--fg); }
    .comments .text:before { content: "#"; color: var(--accent); margin-right: 0.2rem; }
  </style>
</head>
<body>
  <header>
    <h1>noyalib · WASM playground</h1>
    <p>
      Edit YAML losslessly in the browser. Powered by
      <a href="https://github.com/sebastienrousseau/noyalib">noyalib</a>'s green-tree CST —
      every untouched byte (comments, indentation, blank lines) is preserved verbatim across edits.
    </p>
  </header>

  <main>
    <section class="panel">
      <h2>YAML source</h2>
      <textarea class="source" id="src" spellcheck="false"># noyalib service config
# (edited losslessly via the CST below)

name: noyalib  # the project
version: 0.0.1  # bumped by Renovate

# server section is read by the bootstrapper
server:
  host: localhost  # bind address
  port: 8080       # main HTTP port

features:
  - auth   # OIDC + passkeys
  - api    # public REST surface
</textarea>
    </section>

    <section class="panel">
      <h2>After lossless edit</h2>
      <pre class="output" id="out"></pre>
    </section>

    <section class="demo-bar">
      <label>path</label>
      <input id="path" value="version" />
      <label>=</label>
      <input id="value" value="0.0.2" />
      <button class="primary" id="btn-set">set (lossless)</button>
      <button id="btn-get">get</button>
      <button id="btn-comments">comments_at</button>
      <button id="btn-reset">reset</button>
    </section>

    <div class="status" id="status">ready.</div>

    <section class="comments" id="comments-panel" hidden>
      <h2>comments at <code id="comments-path"></code></h2>
      <div id="comments-body"></div>
    </section>

    <section class="panel" style="grid-column: 1 / -1;">
      <h2>parsed value (Value-as-JSON)</h2>
      <pre class="json-view" id="json"></pre>
    </section>

    <section class="more-actions">
      <h3>more</h3>
      <button id="btn-parse">parse</button>
      <button id="btn-stringify">stringify (re-emit)</button>
      <button id="btn-validate">validate (YAML-1.2 JSON schema)</button>
      <button id="btn-bench">bench parse × 1000</button>
    </section>
  </main>

  <script type="module">
    import init, {
      WasmDocument,
      parse, stringify, validate_json,
    } from './pkg/noyalib_wasm.js';

    await init();
    say('ok', 'WASM module loaded.');

    const $src   = document.getElementById('src');
    const $out   = document.getElementById('out');
    const $json  = document.getElementById('json');
    const $path  = document.getElementById('path');
    const $value = document.getElementById('value');

    const ORIGINAL = $src.value;

    function say(level, msg) {
      const el = document.getElementById('status');
      el.className = 'status ' + level;
      el.textContent = msg;
    }

    /** Render a unified-style highlight: any byte position different
     *  from ORIGINAL gets the .add style; surrounding bytes are plain.
     *  For visual emphasis only — the actual edit is a span replace. */
    function renderDiff(before, after) {
      // Find longest common prefix and suffix.
      let i = 0;
      while (i < before.length && i < after.length && before[i] === after[i]) i++;
      let j = 0;
      while (j < before.length - i && j < after.length - i &&
             before[before.length - 1 - j] === after[after.length - 1 - j]) j++;
      const prefix = after.slice(0, i);
      const middle = after.slice(i, after.length - j);
      const suffix = after.slice(after.length - j);
      $out.innerHTML = escapeHtml(prefix) +
        '<mark class="add">' + escapeHtml(middle) + '</mark>' +
        escapeHtml(suffix);
    }

    function escapeHtml(s) {
      return s
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;');
    }

    function refreshJson() {
      try {
        const v = parse($src.value);
        $json.textContent = JSON.stringify(v, null, 2);
      } catch (e) {
        $json.textContent = '(parse error: ' + e.message + ')';
      }
    }
    refreshJson();
    $src.addEventListener('input', refreshJson);

    document.getElementById('btn-set').addEventListener('click', () => {
      try {
        const before = $src.value;
        const doc = new WasmDocument(before);
        doc.set($path.value, $value.value);
        const after = doc.to_string();
        $src.value = after;
        renderDiff(before, after);
        refreshJson();
        say('ok',
          `set ${$path.value} = ${$value.value}. ` +
          `Bytes changed: ${after.length - before.length >= 0 ? '+' : ''}${after.length - before.length}. ` +
          `Comments preserved.`);
      } catch (e) { say('err', e.message); }
    });

    document.getElementById('btn-get').addEventListener('click', () => {
      try {
        const doc = new WasmDocument($src.value);
        const v = doc.get($path.value);
        $out.textContent = JSON.stringify(v, null, 2) ?? 'null';
        say('ok', `get ${$path.value}: ${JSON.stringify(v)}`);
      } catch (e) { say('err', e.message); }
    });

    document.getElementById('btn-comments').addEventListener('click', () => {
      try {
        const doc = new WasmDocument($src.value);
        const b = doc.comments_at($path.value);
        const panel = document.getElementById('comments-panel');
        document.getElementById('comments-path').textContent = $path.value;
        const body = document.getElementById('comments-body');
        body.innerHTML = '';
        const before = b.before || [];
        for (const text of before) {
          body.insertAdjacentHTML('beforeend',
            `<div class="row"><span class="pos">before</span><span class="text">${escapeHtml(text)}</span></div>`);
        }
        if (b.inline != null) {
          body.insertAdjacentHTML('beforeend',
            `<div class="row"><span class="pos">inline</span><span class="text">${escapeHtml(b.inline)}</span></div>`);
        }
        if (before.length === 0 && b.inline == null) {
          body.innerHTML = '<div class="row"><span class="pos">·</span><span style="color:var(--dim)">(no comments at this path)</span></div>';
        }
        panel.hidden = false;
        say('ok', `read comments at ${$path.value}.`);
      } catch (e) { say('err', e.message); }
    });

    document.getElementById('btn-reset').addEventListener('click', () => {
      $src.value = ORIGINAL;
      $out.textContent = '';
      refreshJson();
      document.getElementById('comments-panel').hidden = true;
      say('ok', 'source reset.');
    });

    document.getElementById('btn-parse').addEventListener('click', () => {
      try {
        const v = parse($src.value);
        $out.textContent = JSON.stringify(v, null, 2);
        say('ok', 'parsed.');
      } catch (e) { say('err', e.message); }
    });

    document.getElementById('btn-stringify').addEventListener('click', () => {
      try {
        const v = parse($src.value);
        $out.textContent = stringify(v);
        say('ok', 're-emitted via the data-binding path (no CST — comments dropped).');
      } catch (e) { say('err', e.message); }
    });

    document.getElementById('btn-validate').addEventListener('click', () => {
      try {
        validate_json($src.value);
        say('ok', 'valid against YAML 1.2 JSON-compatible schema.');
      } catch (e) { say('err', `schema: ${e.message}`); }
    });

    document.getElementById('btn-bench').addEventListener('click', () => {
      const yaml = $src.value;
      const t0 = performance.now();
      for (let i = 0; i < 1000; i++) parse(yaml);
      const t = performance.now() - t0;
      say('ok', `1000× parse in ${t.toFixed(1)} ms (${(t).toFixed(2)} µs/op).`);
    });
  </script>
</body>
</html>