jsonview

Modules

Click Modules in the toolbar to open the module manager. Modules are plain JavaScript objects that can inject buttons, styles, and logic into the viewer at runtime.

Built-in modules

Module Description
Show Levels Adds a levels input to the toolbar. Enter depth levels (e.g. 0-2,4) and press Enter or click the button to show only nodes at those depths; ancestors are dimmed.
Schema Folding Adds an Apply Folding ▶ button to the Schema panel header. Clicking it copies the Schema panel’s expand/fold state into the Data panel.
Protobuf Converter Adds a Protobuf button to the toolbar. Opens a two-pane editor: paste text proto on the left, click Convert (or Ctrl/⌘+Enter) to see the JSON on the right, then Copy JSON and paste it into the viewer.

Create a module with an AI agent

Instead of hand-writing a module, you can ask an AI coding agent (e.g. Claude Code) to do it. This repo ships a skill that teaches the agent the module API, conventions, and constraints:

Add it to your agent as a skill — for example by pointing the agent at the repo file, or fetching the raw URL into your local skills folder — then describe the module you want. The agent will follow the skill and produce a copy-pasteable module object you can drop into the Modules popup.

Writing a module

A module is a JS object literal returned from a self-contained expression:

({
  name: 'My Module',
  description: 'What it does',

  // Functions exported to moduleShared — callable by other modules
  functions: {
    myHelper() { /* ... */ }
  },

  // CSS injected while the module is enabled
  styles: `
    .my-button { color: var(--accent); }
  `,

  // Buttons injected into slots
  // Slots: toolbar, schema.header.left, schema.header.right, data.header.left, data.header.right
  buttons: {
    'data.header.right': `<button id="myBtn">Do thing</button>`
  },

  // Called when the module is enabled; wire up event listeners here
  init(schemaDom, dataDom, shared) {
    this._handler = () => shared.myHelper();
    document.getElementById('myBtn')?.addEventListener('click', this._handler);
  },

  // Called when the module is disabled; clean up listeners here
  destroy() {
    document.getElementById('myBtn')?.removeEventListener('click', this._handler);
  }
})

The shared object (moduleShared) provides:

Module lifecycle

Action Effect
Add Creates a blank module in disabled state; its inline editor opens automatically
Edit / Apply Hot-reloads the module code without a page refresh
Enable / Disable Toggle via the switch; injected buttons and styles are added/removed accordingly
Remove Destroys the module and removes all its contributions

Module examples

Schema Folding module

Schema Folding adds an Apply Folding ▶ button to the Schema panel header. Clicking it copies the Schema panel’s expand/fold state into the Data panel.

Module code (click to expand then copy/paste as a new module in the Modules popup):
({
  name: 'Schema Folding',
  description: 'Apply Folding copies the Schema panel expand/fold state into the Data panel',
  functions: {
    applySchemaFoldingToData() {
      this.dataNodes.forEach(function(dn, dataPath) {
        if (!dn.childrenEl) return;
        var sn = this.schemaNodes.get(this.dataPathToSchemaPath(dataPath));
        if (!sn?.childrenEl) return;
        var folded = sn.childrenEl.classList.contains('hidden');
        this.setFolded(dn, folded);
      }.bind(this));
    },
  },
  styles: `
    .btn-apply-schema-folding {
      padding: 3px 9px;
      font-size: 11px;
      letter-spacing: 0.3px;
      text-transform: none;
      border-color: var(--accent-border);
      color: var(--accent);
      background: var(--accent-dim);
    }
    .btn-apply-schema-folding:hover { background: var(--accent-hover); }
  `,
  buttons: {
    'schema.header.left': `
      <button class="btn-apply-schema-folding" id="btnApplySchemaFolding"
        title="Copy the Schema panel's expand/fold state into the Data panel">Apply Folding &#x25B6;</button>
    `
  },
  init(schemaDom, dataDom, shared) {
    this._handler = () => shared.applySchemaFoldingToData();
    document.getElementById('btnApplySchemaFolding')?.addEventListener('click', this._handler);
  },
  destroy() {
    document.getElementById('btnApplySchemaFolding')?.removeEventListener('click', this._handler);
  }
})


Show Levels module

Show Levels adds a levels input to the toolbar. Enter depth levels (e.g. 0-2,4) and press Enter or click levels to show only nodes at those depths; ancestor nodes are dimmed.

Module code (click to expand then copy/paste as a new module in the Modules popup):
({
  name: 'Show Levels',
  description: 'Filter both panels to show only nodes at specified depth levels (e.g. 0-2,4)',
  functions: {
    parseLevels(input) {
      var levels = new Set();
      input.split(',').forEach(function(part) {
        var trimmed = part.trim();
        if (!trimmed) return;
        var range = trimmed.split('-').map(function(s) { return parseInt(s.trim(), 10); });
        if (range.length === 1 && !isNaN(range[0])) {
          levels.add(range[0]);
        } else if (range.length === 2 && !isNaN(range[0]) && !isNaN(range[1])) {
          for (var i = range[0]; i <= range[1]; i++) levels.add(i);
        }
      });
      return levels;
    },
    applyLevels(nodes, levels) {
      this.activeLevels = levels;
      var activePaths = new Set();
      nodes.forEach(function(n, path) { if (levels.has(n.depth)) activePaths.add(path); });
      var ancestorPaths = new Set();
      nodes.forEach(function(n, path) {
        if (!n.childrenEl) return;
        for (var ap of activePaths) {
          if (this.isAncestorPath(path, ap)) { ancestorPaths.add(path); break; }
        }
      }.bind(this));
      nodes.forEach(function(n, path) {
        var isActive   = activePaths.has(path);
        var isAncestor = ancestorPaths.has(path);
        this.setDimmed(n, isAncestor && !isActive);
        if (n.childrenEl) this.setFolded(n, !isAncestor);
        var wrapper = n.el.parentElement;
        if (wrapper?.classList.contains('node')) wrapper.style.display = (isActive || isAncestor) ? '' : 'none';
      }.bind(this));
    },
  },
  styles: `
    .level-group {
      display: flex;
      align-items: stretch;
    }
    .level-input {
      font-family: var(--mono);
      font-size: 12px;
      width: 100px;
      padding: 6px 10px;
      border: 1px solid var(--border);
      border-right: none;
      border-radius: var(--radius) 0 0 var(--radius);
      background: var(--surface2);
      color: var(--text);
      outline: none;
      transition: border-color 0.15s;
    }
    .level-input:focus { border-color: var(--accent-border); }
    .level-input::placeholder { color: var(--text-dim); }
    .btn-levels {
      border-radius: 0 var(--radius) var(--radius) 0;
      border-left: none;
    }
    .level-group:focus-within .level-input,
    .level-group:focus-within .btn-levels { border-color: var(--accent-border); }
  `,
  buttons: {
    'toolbar': `
      <div class="level-group">
        <input class="level-input" id="levelInput" placeholder="e.g. 0-2,4">
        <button id="btnLevels" class="btn-levels">levels</button>
      </div>
    `
  },
  init(schemaDom, dataDom, shared) {
    this._shared = shared;
    this._input  = document.getElementById('levelInput');
    this._btn    = document.getElementById('btnLevels');
    this._click  = () => {
      var levels = shared.parseLevels(this._input.value);
      if (levels.size === 0) return;
      shared.applyLevels(shared.schemaNodes, levels);
      shared.applyLevels(shared.dataNodes,   levels);
    };
    this._keydown = (e) => { if (e.key === 'Enter') this._btn.click(); };
    this._btn.addEventListener('click',   this._click);
    this._input.addEventListener('keydown', this._keydown);
    this._postLoadFn = () => { this._input.value = ''; };
    shared.postLoad.push(this._postLoadFn);
  },
  destroy() {
    this._btn?.removeEventListener('click',   this._click);
    this._input?.removeEventListener('keydown', this._keydown);
    var arr = this._shared?.postLoad;
    if (arr) { var i = arr.indexOf(this._postLoadFn); if (i !== -1) arr.splice(i, 1); }
    delete this._shared; delete this._input; delete this._btn;
  },
})


Protobuf Converter module

Protobuf Converter adds a Protobuf button to the toolbar. Clicking it opens a two-pane editor:

The built-in parser handles nested messages ({} and <> syntax), repeated fields (converted to arrays), quoted strings (including concatenation), booleans, numbers, null, Inf/NaN, extension fields ([package.Field]), and # comments.

Module code (click to expand then copy/paste as a new module in the Modules popup):
({
  name: 'Protobuf Converter',
  description: 'Edit text proto, convert to JSON, and copy it to load into the viewer.',

  functions: {},

  styles: `
    #pb-overlay {
      position: fixed;
      inset: 0;
      background: rgba(0,0,0,0.55);
      z-index: 9999;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    #pb-modal {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: var(--radius);
      width: min(820px, 94vw);
      height: min(600px, 90vh);
      display: flex;
      flex-direction: column;
      box-shadow: 0 8px 40px rgba(0,0,0,0.45);
      overflow: hidden;
    }
    #pb-titlebar {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 9px 14px;
      border-bottom: 1px solid var(--border);
      background: var(--surface2);
      flex-shrink: 0;
    }
    #pb-titlebar span {
      font-family: var(--sans);
      font-size: 13px;
      font-weight: 600;
      color: var(--text);
    }
    #pb-close {
      background: none;
      border: none;
      cursor: pointer;
      color: var(--text-dim);
      font-size: 18px;
      line-height: 1;
      padding: 2px 6px;
      border-radius: var(--radius);
    }
    #pb-close:hover { background: var(--bg); color: var(--text); }
    #pb-body {
      display: flex;
      flex: 1;
      min-height: 0;
    }
    .pb-pane {
      flex: 1;
      display: flex;
      flex-direction: column;
      min-width: 0;
    }
    .pb-pane + .pb-pane {
      border-left: 1px solid var(--border);
    }
    .pb-pane-label {
      font-family: var(--sans);
      font-size: 11px;
      font-weight: 600;
      letter-spacing: 0.06em;
      text-transform: uppercase;
      color: var(--text-dim);
      padding: 6px 12px 5px;
      border-bottom: 1px solid var(--border);
      background: var(--surface2);
      flex-shrink: 0;
    }
    .pb-pane textarea {
      flex: 1;
      resize: none;
      border: none;
      outline: none;
      padding: 12px 14px;
      font-family: var(--mono);
      font-size: 12px;
      background: var(--bg);
      color: var(--text);
      line-height: 1.65;
      tab-size: 2;
      min-height: 0;
    }
    #pb-json-out {
      color: var(--type-obj);
    }
    #pb-json-out.pb-has-error {
      color: var(--type-bool);
    }
    #pb-footer {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 9px 14px;
      border-top: 1px solid var(--border);
      background: var(--surface2);
      flex-shrink: 0;
    }
    #pb-footer-hint {
      flex: 1;
      font-family: var(--sans);
      font-size: 11.5px;
      color: var(--text-dim);
    }
    .pb-btn {
      font-family: var(--sans);
      font-size: 12px;
      font-weight: 600;
      border-radius: var(--radius);
      border: 1px solid var(--border);
      cursor: pointer;
      padding: 5px 14px;
      background: var(--surface);
      color: var(--text);
      transition: background 0.12s;
      white-space: nowrap;
    }
    .pb-btn:hover { background: var(--bg); }
    .pb-btn:disabled { opacity: 0.4; cursor: default; }
    .pb-btn--accent {
      background: var(--accent);
      border-color: var(--accent-border);
      color: #fff;
    }
    .pb-btn--accent:hover { background: var(--accent-hover); }
    .pb-btn--accent:disabled { background: var(--accent-dim); }
    #pb-copy-flash {
      font-family: var(--sans);
      font-size: 11.5px;
      color: var(--type-obj);
      opacity: 0;
      transition: opacity 0.2s;
      pointer-events: none;
    }
    #pb-copy-flash.visible { opacity: 1; }
    .pb-editor-wrap {
      flex: 1;
      display: flex;
      overflow: hidden;
      min-height: 0;
    }
    .pb-line-nums {
      padding: 12px 6px 12px 0;
      width: 40px;
      flex-shrink: 0;
      text-align: right;
      font-family: var(--mono);
      font-size: 12px;
      line-height: 1.65;
      color: var(--text-dim);
      background: var(--surface2);
      border-right: 1px solid var(--border);
      overflow: hidden;
      user-select: none;
      white-space: pre;
    }
  `,

  buttons: {
    toolbar: `<button id="pb-open-btn" class="btn" title="Open Protobuf → JSON converter">Protobuf</button>`
  },

  init(schemaDom, dataDom, shared) {
    this._shared = shared;
    this._openBtn = document.getElementById('pb-open-btn');
    this._onOpen = () => this._openModal();
    this._openBtn?.addEventListener('click', this._onOpen);
  },

  destroy() {
    this._openBtn?.removeEventListener('click', this._onOpen);
    this._closeModal();
  },

  _openModal() {
    if (document.getElementById('pb-overlay')) return;

    const overlay = document.createElement('div');
    overlay.id = 'pb-overlay';
    overlay.innerHTML = `
      <div id="pb-modal" role="dialog" aria-modal="true" aria-label="Protobuf converter">
        <div id="pb-titlebar">
          <span>Protobuf → JSON Converter</span>
          <button id="pb-close" title="Close (Esc)">✕</button>
        </div>
        <div id="pb-body">
          <div class="pb-pane">
            <div class="pb-pane-label">Text Proto</div>
            <div class="pb-editor-wrap">
              <div class="pb-line-nums" id="pb-proto-lines">1</div>
              <textarea id="pb-proto-in" spellcheck="false" placeholder="Paste your text proto here…"></textarea>
            </div>
          </div>
          <div class="pb-pane">
            <div class="pb-pane-label">JSON Output</div>
            <div class="pb-editor-wrap">
              <div class="pb-line-nums" id="pb-json-lines">1</div>
              <textarea id="pb-json-out" spellcheck="false" readonly placeholder="Converted JSON will appear here…"></textarea>
            </div>
          </div>
        </div>
        <div id="pb-footer">
          <span id="pb-footer-hint">Edit proto on the left, then click Convert (or Ctrl/⌘+Enter).</span>
          <span id="pb-copy-flash">Copied!</span>
          <button class="pb-btn" id="pb-close-btn">Close</button>
          <button class="pb-btn" id="pb-copy-btn" disabled>Copy JSON</button>
          <button class="pb-btn pb-btn--accent" id="pb-convert-btn">Convert</button>
        </div>
      </div>
    `;
    document.body.appendChild(overlay);
    this._overlay = overlay;

    const protoIn  = document.getElementById('pb-proto-in');
    const jsonOut  = document.getElementById('pb-json-out');
    const copyBtn  = document.getElementById('pb-copy-btn');
    const flash    = document.getElementById('pb-copy-flash');
    const hint     = document.getElementById('pb-footer-hint');

    const protoLines = document.getElementById('pb-proto-lines');
    const jsonLines  = document.getElementById('pb-json-lines');
    const updateLines = (ta, el) => {
      const n = (ta.value.match(/\n/g)?.length ?? 0) + 1;
      el.textContent = Array.from({length: n}, (_, i) => i + 1).join('\n');
    };

    if (this._savedProto) { protoIn.value = this._savedProto; updateLines(protoIn, protoLines); }
    protoIn.focus();

    protoIn.addEventListener('input',  () => updateLines(protoIn, protoLines));
    protoIn.addEventListener('scroll', () => { protoLines.scrollTop = protoIn.scrollTop; });
    jsonOut.addEventListener('scroll', () => { jsonLines.scrollTop  = jsonOut.scrollTop; });

    const doConvert = () => {
      this._savedProto = protoIn.value;
      jsonOut.classList.remove('pb-has-error');
      try {
        const obj  = this._parseTextProto(protoIn.value);
        const json = JSON.stringify(obj, null, 2);
        jsonOut.value = json;
        updateLines(jsonOut, jsonLines);
        copyBtn.disabled = false;
        hint.textContent = 'Conversion successful. Copy JSON and paste into the viewer.';
      } catch (e) {
        jsonOut.classList.add('pb-has-error');
        jsonOut.value = 'Error: ' + (e.message ?? String(e));
        updateLines(jsonOut, jsonLines);
        copyBtn.disabled = true;
        hint.textContent = 'Fix the error on the left and try again.';
      }
    };

    const doCopy = () => {
      const text = jsonOut.value;
      if (!text || copyBtn.disabled) return;
      navigator.clipboard?.writeText(text).catch(() => {
        jsonOut.select();
        document.execCommand('copy');
      });
      flash.classList.add('visible');
      if (this._flashTimer) clearTimeout(this._flashTimer);
      this._flashTimer = setTimeout(() => flash.classList.remove('visible'), 1800);
    };

    const doClose = () => this._closeModal();

    this._onConvert = doConvert;
    this._onCopy    = doCopy;
    this._onClose   = doClose;
    this._onOverlay = (e) => { if (e.target === overlay) doClose(); };
    this._onKeyDown = (e) => {
      if (e.key === 'Escape') { doClose(); return; }
      if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { doConvert(); }
    };

    document.getElementById('pb-convert-btn').addEventListener('click', this._onConvert);
    copyBtn.addEventListener('click', this._onCopy);
    document.getElementById('pb-close-btn').addEventListener('click', this._onClose);
    document.getElementById('pb-close').addEventListener('click', this._onClose);
    overlay.addEventListener('click', this._onOverlay);
    document.addEventListener('keydown', this._onKeyDown);
  },

  _closeModal() {
    const protoIn = document.getElementById('pb-proto-in');
    if (protoIn) this._savedProto = protoIn.value;
    if (this._flashTimer) { clearTimeout(this._flashTimer); this._flashTimer = null; }
    if (this._onKeyDown) { document.removeEventListener('keydown', this._onKeyDown); this._onKeyDown = null; }
    this._overlay?.remove();
    this._overlay = null;
    this._onConvert = this._onCopy = this._onClose = this._onOverlay = null;
  },

  // ── Text Proto parser ─────────────────────────────────────────────────

  _parseTextProto(src) {
    const { tokens, lines } = this._tokenize(src);
    const p = { tokens, lines, pos: 0 };
    const obj = this._parseBody(p, true);
    if (p.pos < p.tokens.length) {
      throw this._err(p, p.pos,
        'Unexpected token: ' + p.tokens.slice(p.pos, p.pos + 3).join(' '));
    }
    return obj;
  },

  _tokenize(src) {
    const DOUBLE_QUOTED_STRING = /"(?:[^"\\]|\\.)*"/;
    const SINGLE_QUOTED_STRING = /'(?:[^'\\]|\\.)*'/;
    const EXTENSION_NAME       = /\[[\w.\s/]+\]/;
    const PUNCTUATION          = /[{}:<>\[\],;]/;
    const WORD                 = /[^\s{}:<>\[\],;"']+/;
    const COMMENT              = /#[^\n]*/g;

    const TOKEN = new RegExp(
      [
        DOUBLE_QUOTED_STRING.source,
        SINGLE_QUOTED_STRING.source,
        EXTENSION_NAME.source,
        PUNCTUATION.source,
        WORD.source,
      ].join('|'),
      'g'
    );

    const stripped = src.replace(COMMENT, '');
    const tokens = [];
    const lines  = [];

    let scanned = 0;
    let line    = 1;
    let m;

    while ((m = TOKEN.exec(stripped)) !== null) {
      const gap = stripped.slice(scanned, m.index);

      // If the lexer skips non-whitespace characters, it means invalid syntax (e.g. unclosed string)
      if (gap.trim().length > 0) {
        const badCharIndex = gap.search(/\S/);
        const beforeBad = gap.slice(0, badCharIndex);
        const nl = beforeBad.match(/\n/g);
        if (nl) line += nl.length;
        throw new Error('[line ' + line + '] Invalid syntax or unclosed string near "' + gap.trim()[0] + '"');
      }

      // Count newlines in the whitespace gap before this token
      const gapNl = gap.match(/\n/g);
      if (gapNl) line += gapNl.length;

      tokens.push(m[0]);
      lines.push(line);

      // Advance line for newlines INSIDE the matched token, so the next token's line is correct
      const tokNl = m[0].match(/\n/g);
      if (tokNl) line += tokNl.length;

      scanned = m.index + m[0].length;
    }

    // Check for trailing invalid characters after the last valid token
    const tail = stripped.slice(scanned);
    if (tail.trim().length > 0) {
       const badCharIndex = tail.search(/\S/);
       const beforeBad = tail.slice(0, badCharIndex);
       const nl = beforeBad.match(/\n/g);
       if (nl) line += nl.length;
       throw new Error('[line ' + line + '] Invalid syntax near "' + tail.trim()[0] + '"');
    }

    return { tokens, lines };
  },

  _lineAt(p, pos) {
    if (pos < p.lines.length) return p.lines[pos];
    return p.lines[p.lines.length - 1] ?? 1;
  },

  _err(p, pos, msg) {
    return new Error('[line ' + this._lineAt(p, pos) + '] ' + msg);
  },

  _parseBody(p, topLevel) {
    const obj = {};
    while (p.pos < p.tokens.length) {
      const t = p.tokens[p.pos];
      if (!topLevel && (t === '}' || t === '>')) break;

      const keyPos = p.pos;
      const raw    = p.tokens[p.pos++];
      let key = raw;

      // Handle extension fields or reject stray punctuation
      if (raw[0] === '[' && raw[raw.length - 1] === ']') {
        key = raw.slice(1, -1).trim();
      } else if (/[{}:<>\[\],;]/.test(raw)) {
        throw this._err(p, keyPos, 'Expected field name but got "' + raw + '"');
      }

      if (p.tokens[p.pos] === ':') p.pos++;

      if (p.pos >= p.tokens.length) {
        throw this._err(p, keyPos, 'Unexpected end of input after field "' + key + '"');
      }

      const val = this._parseValue(p);

      if (Object.prototype.hasOwnProperty.call(obj, key)) {
        if (!Array.isArray(obj[key])) obj[key] = [obj[key]];
        obj[key].push(val);
      } else {
        obj[key] = val;
      }

      if (p.tokens[p.pos] === ',' || p.tokens[p.pos] === ';') p.pos++;
    }
    return obj;
  },

  _parseValue(p) {
    const t = p.tokens[p.pos];
    if (t === '{' || t === '<') {
      p.pos++;
      const close = t === '{' ? '}' : '>';
      const msg = this._parseBody(p, false);
      if (p.tokens[p.pos] !== close) {
        throw this._err(p, p.pos,
          'Expected "' + close + '" but got "' + (p.tokens[p.pos] ?? '<eof>') + '"');
      }
      p.pos++;
      return msg;
    }
    if (t === '[') {
      p.pos++;
      const arr = [];
      while (p.pos < p.tokens.length && p.tokens[p.pos] !== ']') {
        arr.push(this._parseValue(p));
        if (p.tokens[p.pos] === ',') p.pos++;
      }
      if (p.tokens[p.pos] !== ']') {
        throw this._err(p, p.pos, 'Unterminated array');
      }
      p.pos++;
      return arr;
    }
    return this._parseScalar(p);
  },

  _parseScalar(p) {
    const startPos = p.pos;
    const t = p.tokens[p.pos++];
    if (t === undefined) throw this._err(p, startPos, 'Unexpected end of input');

    if (this._isQuoted(t)) {
      let s = this._unescape(t.slice(1, -1));
      while (p.pos < p.tokens.length && this._isQuoted(p.tokens[p.pos])) {
        s += this._unescape(p.tokens[p.pos].slice(1, -1));
        p.pos++;
      }
      return s;
    }

    const lower = t.toLowerCase();
    if (lower === 'true'  || t === 't') return true;
    if (lower === 'false' || t === 'f') return false;
    if (lower === 'null')               return null;
    if (lower === 'inf'   || lower === 'infinity')    return Infinity;
    if (lower === '-inf'  || lower === '-infinity')   return -Infinity;
    if (lower === '+inf'  || lower === '+infinity')   return Infinity;
    if (lower === 'nan')                              return NaN;

    if (/^[-+]?(?:\d|\.\d)/.test(t)) {
      const n = Number(t);
      if (!isNaN(n)) return n;
      const m = t.match(/^([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)/);
      if (m) {
        const n2 = Number(m[1]);
        if (!isNaN(n2)) return n2;
      }
    }

    return t;
  },

  _isQuoted(t) {
    if (!t || t.length < 2) return false;
    const a = t[0], b = t[t.length - 1];
    return (a === '"' && b === '"') || (a === "'" && b === "'");
  },

  _unescape(s) {
    return s.replace(/\\(x[0-9a-fA-F]{1,2}|u[0-9a-fA-F]{4}|[0-7]{1,3}|[^])/g, (_, c) => {
      if (c[0] === 'x') return String.fromCharCode(parseInt(c.slice(1), 16));
      if (c[0] === 'u') return String.fromCharCode(parseInt(c.slice(1), 16));
      if (/^[0-7]+$/.test(c)) return String.fromCharCode(parseInt(c, 8));
      switch (c) {
        case 'n':  return '\n';
        case 't':  return '\t';
        case 'r':  return '\r';
        case 'a':  return '\x07';
        case 'b':  return '\b';
        case 'f':  return '\f';
        case 'v':  return '\v';
        case '\\': return '\\';
        case "'":  return "'";
        case '"':  return '"';
        default:   return c;
      }
    });
  }
})


Highlight module

Highlight module lets users to highlight by clicking.

Buttons:

Highlights are automatically cleared on every new loadJson call.

Module code (click to expand then copy/paste as a new module in the Modules popup):
({
  name: 'Highlight',
  description: 'Highlight schema nodes (purple) and data nodes (yellow), with cross-panel mirroring and expand helpers',
  styles: `
    :root {
      --clear-hl-color: rgba(252,165,165,0.3);
      --clear-hl-dim:   rgba(252,165,165,0.08);
      --clear-hl-hover: rgba(252,165,165,0.18);
      --hl-schema:        #a78bfa;
      --hl-schema-dim:    rgba(167,139,250,0.1);
      --hl-schema-hover:  rgba(167,139,250,0.18);
      --hl-schema-border: rgba(167,139,250,0.35);
      --highlight:        #fbbf24;
      --highlight-dim:    rgba(251,191,36,0.08);
      --highlight-hover:  rgba(251,191,36,0.18);
      --highlight-border: rgba(251,191,36,0.3);
    }
    .btn-clear-hl {
      padding: 3px 7px;
      font-size: 10px;
      letter-spacing: 0.3px;
      text-transform: none;
      border-color: var(--clear-hl-color);
      color: var(--type-bool);
      background: var(--clear-hl-dim);
    }
    .btn-clear-hl:hover { background: var(--clear-hl-hover); }
    .hl-label {
      font-family: var(--mono);
      font-size: 10px;
      font-weight: 600;
      letter-spacing: 0.5px;
      user-select: none;
      padding: 0 2px;
    }
    .btn-hl-icon {
      padding: 3px 7px;
      font-size: 10px;
      letter-spacing: 0.3px;
      text-transform: none;
      border-color: var(--hl-schema-border);
      color: var(--hl-schema);
      background: var(--hl-schema-dim);
    }
    .btn-hl-icon:hover { background: var(--hl-schema-hover); }
    .hl-label-schema { color: var(--hl-schema); }
    .node-row.highlighted-schema {
      background: var(--hl-schema-dim);
      outline: 1px solid var(--hl-schema-border);
      outline-offset: -1px;
    }
    .node-row.highlighted.highlighted-schema {
      background: linear-gradient(to right, var(--hl-schema-dim) 50%, var(--highlight-dim) 50%);
      outline: 1px solid var(--hl-schema-border);
      outline-offset: -1px;
    }
    .btn-hl-icon-data {
      padding: 3px 7px;
      font-size: 10px;
      letter-spacing: 0.3px;
      text-transform: none;
      border-color: var(--highlight-border);
      color: var(--highlight);
      background: var(--highlight-dim);
    }
    .btn-hl-icon-data:hover { background: var(--highlight-hover); }
    .hl-label-data { color: var(--highlight); }
    .node-row.highlighted {
      background: var(--highlight-dim);
      outline: 1px solid var(--highlight-border);
      outline-offset: -1px;
    }
  `,
  buttons: {
    'schema.header.right': `
      <button class="btn-clear-hl"  id="btnSchemaClearHl"    title="Clear schema highlights">&#x2715;</button>
      <span   class="hl-label hl-label-schema">Highlights</span>
      <button class="btn-hl-icon"   id="btnSchemaHlToSchema" title="Expand highlighted in Schema panel">&#x25BC;</button>
      <button class="btn-hl-icon"   id="btnSchemaHlToData"   title="Expand highlighted in Data panel">&#x25B6;</button>
    `,
    'data.header.left': `
      <button class="btn-hl-icon-data" id="btnDataHlToSchema" title="Expand highlighted in Schema panel">&#x25C4;</button>
      <button class="btn-hl-icon-data" id="btnDataHlToData"   title="Expand highlighted in Data panel">&#x25BC;</button>
      <span   class="hl-label hl-label-data">Highlights</span>
      <button class="btn-clear-hl"     id="btnDataClearHl"    title="Clear data highlights">&#x2715;</button>
    `
  },
  functions: {
    schemaHighlights: new Set(),
    dataHighlights:   new Set(),
    expandToHighlights(nodes, targetPaths) {
      if (targetPaths.size === 0) return;
      var ancestorPaths = new Set();
      nodes.forEach(function(n, p) {
        if (!n.childrenEl) return;
        for (var tp of targetPaths) {
          if (this.isAncestorPath(p, tp)) { ancestorPaths.add(p); break; }
        }
      }.bind(this));
      nodes.forEach(function(n) {
        if (!n.childrenEl) return;
        this.setFolded(n, !ancestorPaths.has(n.path));
      }.bind(this));
    },
    clearSchemaHighlights(schemaDom, dataDom) {
      schemaDom = schemaDom || document.getElementById('schemaTree');
      dataDom   = dataDom   || document.getElementById('dataTree');
      this.schemaHighlights.clear();
      schemaDom.querySelectorAll('.highlighted-schema').forEach(function(el) { el.classList.remove('highlighted-schema'); });
      dataDom.querySelectorAll('.highlighted-schema').forEach(function(el) { el.classList.remove('highlighted-schema'); });
    },
    clearDataHighlights(schemaDom, dataDom) {
      schemaDom = schemaDom || document.getElementById('schemaTree');
      dataDom   = dataDom   || document.getElementById('dataTree');
      this.dataHighlights.clear();
      schemaDom.querySelectorAll('.highlighted').forEach(function(el) { el.classList.remove('highlighted'); });
      dataDom.querySelectorAll('.highlighted').forEach(function(el) { el.classList.remove('highlighted'); });
    },
    toggleSchemaHighlight(schemaPath, row, dataDom) {
      dataDom = dataDom || document.getElementById('dataTree');
      var safePath = schemaPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
      if (this.schemaHighlights.has(schemaPath)) {
        this.schemaHighlights.delete(schemaPath);
        row.classList.remove('highlighted-schema');
        dataDom.querySelectorAll('[data-schema-path="' + safePath + '"]')
          .forEach(function(el) { el.classList.remove('highlighted-schema'); });
      } else {
        this.schemaHighlights.add(schemaPath);
        row.classList.add('highlighted-schema');
        var first = null;
        dataDom.querySelectorAll('[data-schema-path="' + safePath + '"]').forEach(function(el) {
          el.classList.add('highlighted-schema');
          if (!first) first = el;
        });
        first?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
      }
    },
    toggleHighlight(dataPath, schemaPath, row, schemaDom) {
      schemaDom = schemaDom || document.getElementById('schemaTree');
      var safePath = schemaPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
      var dpath = this.dataPathToSchemaPath;
      if (this.dataHighlights.has(dataPath)) {
        this.dataHighlights.delete(dataPath);
        row.classList.remove('highlighted');
        var stillLinked = [...this.dataHighlights].some(function(p) { return dpath(p) === schemaPath; });
        if (!stillLinked) schemaDom.querySelector('[data-schema-path="' + safePath + '"]')?.classList.remove('highlighted');
      } else {
        this.dataHighlights.add(dataPath);
        row.classList.add('highlighted');
        var sn = schemaDom.querySelector('[data-schema-path="' + safePath + '"]');
        if (sn) { sn.classList.add('highlighted'); sn.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }
      }
    },
    applySchemaHlToSchema() {
      this.expandToHighlights(this.schemaNodes, this.schemaHighlights);
    },
    applySchemaHlToData() {
      var targets = new Set();
      var dpath = this.dataPathToSchemaPath;
      var hl = this.schemaHighlights;
      this.dataNodes.forEach(function(_, dp) { if (hl.has(dpath(dp))) targets.add(dp); });
      this.expandToHighlights(this.dataNodes, targets);
    },
    applyDataHlToSchema() {
      var targets = new Set([...this.dataHighlights].map(function(p) { return this.dataPathToSchemaPath(p); }.bind(this)));
      this.expandToHighlights(this.schemaNodes, targets);
    },
    applyDataHlToData() {
      this.expandToHighlights(this.dataNodes, this.dataHighlights);
    },
  },
  init(schemaDom, dataDom, shared) {
    this._schemaDom = schemaDom;
    this._dataDom   = dataDom;
    this._shared    = shared;

    this._postLoadFn = function() {
      shared.schemaHighlights.clear();
      shared.dataHighlights.clear();
    };
    shared.postLoad.push(this._postLoadFn);

    this._schemaClick = (e) => {
      const row = e.target.closest('.node-row');
      if (!row || !schemaDom.contains(row) || !row.dataset.schemaPath) return;
      const isContainer = row.dataset.container === '1';
      if (e.ctrlKey || e.metaKey || !isContainer) {
        e.stopPropagation();
        shared.toggleSchemaHighlight(row.dataset.schemaPath, row, dataDom);
      }
    };
    schemaDom.addEventListener('click', this._schemaClick);

    this._dataClick = (e) => {
      const row = e.target.closest('.node-row');
      if (!row || !dataDom.contains(row) || !row.dataset.path) return;
      const isContainer = row.dataset.container === '1';
      if (e.ctrlKey || e.metaKey || !isContainer) {
        e.stopPropagation();
        shared.toggleHighlight(row.dataset.path, row.dataset.schemaPath, row, schemaDom);
      }
    };
    dataDom.addEventListener('click', this._dataClick);

    const bindBtn = (id, fn) => document.getElementById(id)?.addEventListener('click', fn);
    bindBtn('btnSchemaClearHl',    () => shared.clearSchemaHighlights(schemaDom, dataDom));
    bindBtn('btnSchemaHlToSchema', () => shared.applySchemaHlToSchema());
    bindBtn('btnSchemaHlToData',   () => shared.applySchemaHlToData());
    bindBtn('btnDataClearHl',      () => shared.clearDataHighlights(schemaDom, dataDom));
    bindBtn('btnDataHlToSchema',   () => shared.applyDataHlToSchema());
    bindBtn('btnDataHlToData',     () => shared.applyDataHlToData());
  },
  destroy() {
    if (this._shared) {
      this._shared.clearSchemaHighlights(this._schemaDom, this._dataDom);
      this._shared.clearDataHighlights(this._schemaDom, this._dataDom);
      var arr = this._shared.postLoad;
      var idx = arr.indexOf(this._postLoadFn);
      if (idx !== -1) arr.splice(idx, 1);
    }
    this._schemaDom?.removeEventListener('click', this._schemaClick);
    this._dataDom?.removeEventListener('click', this._dataClick);
    delete this._schemaDom;
    delete this._dataDom;
    delete this._shared;
    delete this._postLoadFn;
  },
})


Regex Filter module

Regex Filter module adds a live text-filter input to each panel header.

Module code (click to expand then copy/paste as a new module in the Modules popup):
({
  name: 'Regex Filter',
  description: 'Live text/regexp filter for schema and data panels; ancestors of matches are dimmed, non-matches are hidden',
  styles: `
    .filter-bar {
      display: flex;
      align-items: center;
      gap: 4px;
    }
    .filter-wrap {
      position: relative;
      display: flex;
      align-items: stretch;
    }
    .filter-wrap:focus-within .filter-kv-btn,
    .filter-wrap:focus-within .filter-input { border-color: var(--accent-border); }
    .filter-input {
      font-family: var(--mono);
      font-size: 11px;
      width: 130px;
      padding: 3px 22px 3px 7px;
      border: 1px solid var(--border);
      border-radius: var(--radius);
      background: var(--surface2);
      color: var(--text);
      outline: none;
      transition: border-color 0.15s;
    }
    .filter-input:focus { border-color: var(--accent-border); }
    .filter-input::placeholder { color: var(--text-dim); }
    .filter-input.filter-invalid { border-color: rgba(252,165,165,0.6); }
    .filter-clear {
      position: absolute;
      right: 4px;
      padding: 1px 4px;
      font-size: 10px;
      border: none;
      background: transparent;
      color: var(--text-dim);
      cursor: pointer;
      line-height: 1;
      visibility: hidden;
    }
    .filter-clear:hover { color: var(--text); background: transparent; border-color: transparent; }
    .filter-clear.visible { visibility: visible; }
    .filter-re-btn {
      padding: 3px 6px;
      font-size: 10px;
      letter-spacing: 0;
      border-color: var(--border);
      color: var(--text-dim);
    }
    .filter-re-btn.active {
      border-color: var(--accent-border);
      color: var(--accent);
      background: var(--accent-dim);
    }
    .filter-kv-btn {
      padding: 3px 6px;
      font-size: 10px;
      letter-spacing: 0;
      border-color: var(--border);
      border-right: none;
      border-radius: var(--radius) 0 0 var(--radius);
      color: var(--text-dim);
      min-width: 28px;
      text-align: center;
    }
    .filter-kv-btn.active {
      color: var(--accent);
      background: var(--accent-dim);
    }
    .filter-input-kv {
      border-radius: 0 var(--radius) var(--radius) 0;
    }
  `,
  buttons: {
    'schema.header.right': `
      <div class="filter-bar" id="schemaFilterBar">
        <button class="filter-re-btn" id="schemaFilterRe" title="Toggle regexp mode">.*</button>
        <div class="filter-wrap">
          <button class="filter-kv-btn" id="schemaFilterKT" title="KT: match key or type&#10;K: match key only&#10;T: match type only">KT</button>
          <input class="filter-input filter-input-kv" id="schemaFilterInput" placeholder="filter schema…" />
          <button class="filter-clear" id="schemaFilterClear" title="Clear filter">&#x2715;</button>
        </div>
      </div>
    `,
    'data.header.right': `
      <div class="filter-bar" id="dataFilterBar">
        <button class="filter-re-btn" id="dataFilterRe" title="Toggle regexp mode">.*</button>
        <div class="filter-wrap">
          <button class="filter-kv-btn" id="dataFilterKV" title="KV: match key or value&#10;K: match key only&#10;V: match value only">KV</button>
          <input class="filter-input filter-input-kv" id="dataFilterInput" placeholder="filter data…" />
          <button class="filter-clear" id="dataFilterClear" title="Clear filter">&#x2715;</button>
        </div>
      </div>
    `,
  },
  functions: {
    applyFilter(nodes, matcher, isSchema, shared, matchTarget) {
      if (!matcher) {
        nodes.forEach(function(n) {
          n.el.classList.remove('dimmed');
          var wrapper = n.el.parentElement;
          if (wrapper && wrapper.classList.contains('node')) wrapper.style.display = '';
        });
        return;
      }
      var mt = matchTarget || 'kv';
      function getRowText(row) {
        var key   = row.querySelector('.key');
        var value = row.querySelector('.value');
        var type  = row.querySelector('[class*="t-"]');
        var parts = [];
        if (isSchema) {
          if (mt !== 't' && key)  parts.push(key.textContent);
          if (mt !== 'k' && type) parts.push(type.textContent);
        } else {
          if (mt !== 'v' && key)   parts.push(key.textContent);
          if (mt !== 'k' && value) parts.push(value.textContent);
        }
        return parts.join(' ');
      }
      var matched = new Set();
      nodes.forEach(function(n, path) {
        if (matcher.test(getRowText(n.el))) matched.add(path);
      });
      var ancestors = new Set();
      nodes.forEach(function(n, path) {
        if (!n.isContainer) return;
        for (var mp of matched) {
          if (shared.isAncestorPath(path, mp)) { ancestors.add(path); break; }
        }
      });
      ancestors.forEach(function(path) {
        var n = nodes.get(path);
        if (n) shared.setFolded(n, false);
      });
      nodes.forEach(function(n, path) {
        var isMatch    = matched.has(path);
        var isAncestor = ancestors.has(path);
        var visible    = isMatch || isAncestor;
        shared.setDimmed(n, isAncestor && !isMatch);
        var wrapper = n.el.parentElement;
        if (wrapper && wrapper.classList.contains('node')) wrapper.style.display = visible ? '' : 'none';
      });
    },
  },
  init(schemaDom, dataDom, shared) {
    this._shared   = shared;
    this._schemaRe = false;
    this._schemaKT = 'kt';   // 'kt' | 'k' | 't'
    this._dataRe   = false;
    this._dataKV   = 'kv';   // 'kv' | 'k' | 'v'
    var self = this;

    function buildMatcher(pattern, useRe) {
      if (!pattern) return null;
      if (useRe) {
        try { return new RegExp(pattern, 'i'); }
        catch(_) { return null; }
      }
      var lower = pattern.toLowerCase();
      return { test: function(s) { return s.toLowerCase().indexOf(lower) !== -1; } };
    }
    function isValidRe(pattern) {
      try { new RegExp(pattern); return true; } catch(_) { return false; }
    }

    // ── schema filter ──────────────────────────────────
    var schemaInput = document.getElementById('schemaFilterInput');
    var schemaClear = document.getElementById('schemaFilterClear');
    var schemaReBtn = document.getElementById('schemaFilterRe');
    var schemaKTBtn = document.getElementById('schemaFilterKT');

    var KT_CYCLE = { kt: 'k', k: 't', t: 'kt' };
    var KT_TITLE = {
      kt: 'KT: match key or type\nK: match key only\nT: match type only',
      k:  'K: match key only\nKT: match key or type\nT: match type only',
      t:  'T: match type only\nKT: match key or type\nK: match key only',
    };

    function runSchemaFilter() {
      shared.setAllExpanded(shared.schemaNodes, true);
      var pat     = schemaInput.value;
      var useRe   = self._schemaRe;
      var invalid = useRe && pat && !isValidRe(pat);
      schemaInput.classList.toggle('filter-invalid', invalid);
      schemaClear.classList.toggle('visible', pat.length > 0);
      shared.applyFilter(shared.schemaNodes, buildMatcher(pat, useRe), true, shared, self._schemaKT);
    }
    this._schemaInput   = function() { runSchemaFilter(); };
    this._schemaEnter   = function(e) { if (e.key === 'Enter') runSchemaFilter(); };
    this._schemaClear   = function() { schemaInput.value = ''; runSchemaFilter(); };
    this._schemaReClick = function() {
      self._schemaRe = !self._schemaRe;
      schemaReBtn.classList.toggle('active', self._schemaRe);
      runSchemaFilter();
    };
    this._schemaKTClick = function() {
      self._schemaKT = KT_CYCLE[self._schemaKT];
      schemaKTBtn.textContent = self._schemaKT.toUpperCase();
      schemaKTBtn.title = KT_TITLE[self._schemaKT];
      schemaKTBtn.classList.toggle('active', self._schemaKT !== 'kt');
      runSchemaFilter();
    };
    schemaInput.addEventListener('input',   this._schemaInput);
    schemaInput.addEventListener('keydown', this._schemaEnter);
    schemaClear.addEventListener('click', this._schemaClear);
    schemaReBtn.addEventListener('click', this._schemaReClick);
    schemaKTBtn.addEventListener('click', this._schemaKTClick);

    // ── data filter ────────────────────────────────────
    var dataInput  = document.getElementById('dataFilterInput');
    var dataClear  = document.getElementById('dataFilterClear');
    var dataReBtn  = document.getElementById('dataFilterRe');
    var dataKVBtn  = document.getElementById('dataFilterKV');

    var KV_CYCLE = { kv: 'k', k: 'v', v: 'kv' };
    var KV_TITLE = {
      kv: 'KV: match key or value\nK: match key only\nV: match value only',
      k:  'K: match key only\nKV: match key or value\nV: match value only',
      v:  'V: match value only\nKV: match key or value\nK: match key only',
    };

    function runDataFilter() {
      shared.setAllExpanded(shared.dataNodes, true);
      var pat     = dataInput.value;
      var useRe   = self._dataRe;
      var invalid = useRe && pat && !isValidRe(pat);
      dataInput.classList.toggle('filter-invalid', invalid);
      dataClear.classList.toggle('visible', pat.length > 0);
      shared.applyFilter(shared.dataNodes, buildMatcher(pat, useRe), false, shared, self._dataKV);
    }
    this._dataInput   = function() { runDataFilter(); };
    this._dataEnter   = function(e) { if (e.key === 'Enter') runDataFilter(); };
    this._dataClear   = function() { dataInput.value = ''; runDataFilter(); };
    this._dataReClick = function() {
      self._dataRe = !self._dataRe;
      dataReBtn.classList.toggle('active', self._dataRe);
      runDataFilter();
    };
    this._dataKVClick = function() {
      self._dataKV = KV_CYCLE[self._dataKV];
      dataKVBtn.textContent = self._dataKV.toUpperCase();
      dataKVBtn.title = KV_TITLE[self._dataKV];
      dataKVBtn.classList.toggle('active', self._dataKV !== 'kv');
      runDataFilter();
    };
    dataInput.addEventListener('input',   this._dataInput);
    dataInput.addEventListener('keydown', this._dataEnter);
    dataClear.addEventListener('click', this._dataClear);
    dataReBtn.addEventListener('click', this._dataReClick);
    dataKVBtn.addEventListener('click', this._dataKVClick);

    // ── postLoad: reset both inputs on new JSON load ───
    this._postLoadFn = function() {
      schemaInput.value = '';
      dataInput.value   = '';
      schemaInput.classList.remove('filter-invalid');
      dataInput.classList.remove('filter-invalid');
      schemaClear.classList.remove('visible');
      dataClear.classList.remove('visible');
      self._schemaKT = 'kt';
      schemaKTBtn.textContent = 'KT';
      schemaKTBtn.classList.remove('active');
      self._dataKV = 'kv';
      dataKVBtn.textContent = 'KV';
      dataKVBtn.classList.remove('active');
      shared.applyFilter(shared.schemaNodes, null, true,  shared);
      shared.applyFilter(shared.dataNodes,   null, false, shared);
    };
    shared.postLoad.push(this._postLoadFn);
  },

  destroy() {
    if (this._shared) {
      this._shared.applyFilter(this._shared.schemaNodes, null, true,  this._shared);
      this._shared.applyFilter(this._shared.dataNodes,   null, false, this._shared);
      var arr = this._shared.postLoad;
      var idx = arr.indexOf(this._postLoadFn);
      if (idx !== -1) arr.splice(idx, 1);
    }
    [
      { id: 'schemaFilterInput', event: 'input',   handler: this._schemaInput },
      { id: 'schemaFilterInput', event: 'keydown', handler: this._schemaEnter },
      { id: 'schemaFilterClear', event: 'click',   handler: this._schemaClear },
      { id: 'schemaFilterRe',    event: 'click', handler: this._schemaReClick },
      { id: 'schemaFilterKT',    event: 'click', handler: this._schemaKTClick },
      { id: 'dataFilterInput',   event: 'input',   handler: this._dataInput },
      { id: 'dataFilterInput',   event: 'keydown', handler: this._dataEnter },
      { id: 'dataFilterClear',   event: 'click',   handler: this._dataClear },
      { id: 'dataFilterRe',      event: 'click', handler: this._dataReClick },
      { id: 'dataFilterKV',      event: 'click', handler: this._dataKVClick }
    ].forEach(({ id, event, handler }) => {
      document.getElementById(id)?.removeEventListener(event, handler);
    });

    delete this._shared;
    delete this._postLoadFn;
  },
})