Skip to content

Writing interpreters

Structile understands JSON, Python-repr and CSV (including tab-separated text) natively — see CSV and tab-separated text for that one, which needs nothing from this page. Everything else — XML, HTML, INI, TOML, YAML, or any in-house format — goes through a small interpreter: a JavaScript file that says how to turn that format's text into a plain value, and (optionally) back again. This page covers writing one, trying it out, and then the four ways to get it to whoever else needs it — from "just send the file" up to "install a package and it's automatic."

This one page applies to all three ways of running Structile — the standalone viewer, the Python library, and the VS Code extension — since all three run the exact same interpreter script the exact same way.

Already have a script and just need to know which obj/format=/interpreter= combination to pass it with? See Choosing obj, format, interpreter instead — this page is about writing the script itself.

The contract

An interpreter defines one or two functions, depending on the kind of data:

  • Any text format that isn't markup (INI, TOML, YAML, a custom DSL, …): interpretText(text), given the raw file text, returning a plain JS value. Optionally also serializeText(value), the inverse — when present, Save writes edits back to this exact text shape instead of falling back to a different format.
  • Markup (XML, or HTML — HTML is just XML-like markup with a looser parsing mode): interpretXML(xmlDocument), given a browser-parsed DOM Document, returning a plain JS value. Optionally also serializeXML(value).

Only interpretText/interpretXML is required — read-only support (view but not edit-and-save) is a perfectly reasonable place to stop.

One rule matters more than the shape of the code: fail loudly. If the text doesn't actually look like your format, throw — don't guess and return something meaningless. This is what makes it safe to hand Structile a list of candidate interpreters for the same file: it tries each in order and uses the first one that doesn't throw, so two mutually-incompatible schemas can coexist without anyone having to say up front which file is which.

An interpreter can also declare two optional constants, to self-identify:

const INTERPRETER_NAME = "Acme props format";
const INTERPRETER_VERSION = "1.0.0";

Neither is required. When present, the standalone viewer's [?] About popover shows them for whichever interpreter actually resolved the currently loaded file — and, for a custom/branded distribution, lists every bundled interpreter's own name/version too, so a colleague can tell what a given build actually contains without opening a file first.

Selection sync

JSON and Python-repr always sync: clicking a value in the graph highlights the matching text in the source pane, and vice versa (the VS Code extension also folds/unfolds and edits in sync, live). Any interpreter can opt in to the same position map, but it must identify where its output values came from — there is no fixed grammar for the viewer to re-derive positions from on its own.

For markup (interpretXML), call the global __structileTagSource(container, key, role, node, mode) right where you already assign a value into its container:

out[key] = interpretNode(kid);
__structileTagSource(out, key, "value", kid);        // whole element
__structileTagSource(out, key, "key", kid, "id");     // one attribute
__structileTagSource(out, key, "value", kid, "$text"); // trimmed text content
  • container / key — the object/array/Set and the key/index the value was just assigned under.
  • role — "key" or "value", matching what's being tagged.
  • node — the DOM element the value came from.
  • mode — omit for the whole element's span, pass an attribute name for just that attribute's value, or "$text" for the element's trimmed text content.

For a raw-text (interpretText) format, your parser already knows the character offsets it consumed. Record those directly with __structileTagSourceRange(container, key, role, from, to):

out[key] = parsedValue;
__structileTagSourceRange(out, key, "key", keyFrom, keyTo);
__structileTagSourceRange(out, key, "value", valueFrom, valueTo);

from is inclusive and to is exclusive, in the LF-normalized text passed to interpretText. The container, key, and role meanings are the same as for markup. Tag both roles when your format has distinct key and value spans; an array item commonly needs only a value span.

This is entirely optional — an interpreter that skips it still works exactly as before, just graph-only, no error, no configuration needed elsewhere. Every bundled XML/HTML interpreter under interpreters/ opts in — generic_xml.js's own header comment walks through a complete worked example — except for the *_no_sync.js variants alongside them, kept specifically as a deliberately non-syncing comparison point, not meant to be copied from. generic_toml.js and generic_yaml.js are the complete raw-text examples.

Ready-made interpreters

Before writing one, check whether the format you have is already covered. Every interpreter Structile ships is downloadable here, one file each — they are plain .js files with no build step and no dependencies, so "download" and "use" are the same act.

Interpreter Reads Sample
generic_ini.js INI: flat [section] blocks of key = value pairs .ini · demo
generic_toml.js TOML: tables, dotted keys, arrays of tables, inline tables and arrays .toml · demo
generic_yaml.js YAML: block mappings and sequences, nested by indentation, single-line flow collections .yaml · demo
generic_xml.js XML: the general-purpose element/attribute mapping —
html.js HTML documents (markup is markup) —
tagged_xml.js An XML schema keyed by a tag attribute —
keyed_xml.js An XML schema keyed by a key element —
flat_attrs_xml.js XML where every value lives in an attribute —
implicit_xml.js XML with implicit repetition (repeated sibling elements as a list) —
ref_xml.js XML with cross-references between elements —
isda_ir_curve.js ISDA interest-rate curve XML FpML · demo

A *_no_sync.js variant sits next to several of the XML ones (generic_xml_no_sync.js, html_no_sync.js, keyed_xml_no_sync.js, tagged_xml_no_sync.js). Those exist as a deliberate comparison point for selection sync — what the same interpreter looks like without it — not as a starting point to copy.

Prefer everything in one archive? All samples and interpreters, as a zip.

The viewer offers these for you

Dropping an .ini, .toml, .yaml, .xml or .html file into the standalone viewer without an interpreter alongside it offers the matching file from this page directly (a .csv/.tsv file never gets this far — it simply opens):

  • Use Structile's … fetches it and runs it for that session only, nothing written to disk;
  • Download … hands you the same file to keep next to your data;
  • or paste any URL of your own — your interpreter doesn't have to be one of these.

Nothing is fetched until you click: the viewer never reaches the network on its own.

CSV needs no interpreter

There used to be a generic_csv.js here. As of 6.27.0 the viewer parses CSV and tab-separated text itself — one header row plus uniform data rows become the array-of-records shape it renders as a table, with RFC 4180 quoting (including a quoted cell that spans lines), scalar coercion, editable null cells, saving back to the same dialect, and two-way graph/source selection sync. The sample is still here; nothing goes with it:

See CSV and tab-separated text for what the viewer does with it, including pasting a table straight out of Excel.

Writing an interpreter for your OWN delimited dialect still works exactly as this page describes, and takes precedence over the built-in parser.

A worked example

A minimal "flat properties" format — one key = value pair per line, # for comments:

// props-interpreter.js — one key=value pair per line.
//
// interpretText(text) is the required half: raw text in, a plain JS
// value out. serializeText(value) is optional: the inverse, needed only
// if you want Save to write back to this exact shape.

function interpretText(text) {
  const result = {};
  const lines = text.split(/\r\n|\r|\n/);
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i].trim();
    if (line === "" || line.startsWith("#")) continue;
    const eq = line.indexOf("=");
    if (eq === -1) {
      // Fail loudly: this line isn't "key = value", so this text
      // probably isn't a props file at all.
      throw new Error(`props interpreter: line ${i + 1} (${JSON.stringify(line)}) has no "="`);
    }
    result[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
  }
  return result;
}

function serializeText(value) {
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
    throw new Error("props interpreter: can only serialize a flat object of key/value pairs");
  }
  return Object.keys(value).map((key) => `${key} = ${value[key]}`).join("\n") + "\n";
}

A markup interpreter has the same shape, just walking a DOM instead of splitting lines:

function interpretXML(xmlDocument) {
  const root = xmlDocument.documentElement;
  // ... read root's tag/attributes/children, return a plain JS value ...
  // throw if root isn't the shape you expect.
}

function serializeXML(value) {
  // ... the inverse: build an XML string from a plain JS value ...
}

Trying it out

Standalone viewer: drop (or paste) the data file and the interpreter .js file onto the page together — either order. See the standalone viewer guide.

Python:

import structile as st

st.open("settings.props", interpreter="props-interpreter.js", format="ini")

(format= picks the text-vs-markup contract; for a file whose extension you've registered — see below — it's inferred automatically.)

VS Code: name it settings.props.interpreter.js, right next to settings.props — a sibling file always wins with no configuration at all. See the VS Code extension guide.

Sharing one already-interpreted document

Everything below this point is about distributing the interpreter script so someone else's structile can resolve future files in your format. Sometimes that's the wrong problem — you just interpreted ONE document and want to hand the result to a colleague who has neither structile installed nor your interpreter file, with nothing else to set up on their end.

out= writes a self-contained standalone HTML file for any renderer — the data, the interpreter's own source text, and the current display settings all inlined into one file (window.__STRUCTILE_SNAPSHOT__ — the same shape a custom/branded distribution below injects for a whole set of interpreters, just for one document's own single interpreter here):

import structile as st

st.open(
    "settings.props", interpreter="props-interpreter.js", format="ini",
    renderer="none", out="settings.html",
)

Open settings.html directly — double-click it, attach it to an email, host it anywhere — no structile install, no companion .js file, no repository checkout needed on the receiving end. renderer="browser" (the default) does the same thing and also opens a tab; handle.save(path) does it after the fact for a RenderHandle you already have (from any renderer, including one that hasn't written a file yet — see the Python library guide).

Standalone viewer: the Save as HTML… item next to the Save button does the same thing client-side, for whatever's currently loaded in the page — see the standalone viewer guide.

Distributing it

Just send the file

The simplest option, and often the right one: share the .js file however you'd share any other file (chat, email, a shared drive, a repo alongside the data it describes). Every environment can point at a bare file path with no packaging step:

  • Standalone viewer: drop it alongside the data, as above.
  • Python: interpreter="path/to/props-interpreter.js", or register it once so nobody has to remember the path again — st.register_interpreter(".props", "path/to/props-interpreter.js").
  • VS Code: a sibling <name>.interpreter.js file, or the structile.interpreter setting.

This stops scaling once more than a couple of people need the same format — everyone has to independently know the file exists and keep their own copy in sync. The three options below solve that.

Python: an installable plugin

Distribute it as an ordinary pip install-able package, via a standard entry point in the group structile.interpreters — the same mechanism pytest and Sphinx use for their own plugins. Once installed, it just works — no import, no registration call, no wrapper API, for anyone who installs it:

import structile as st
st.open("settings.props")   # correct interpreter chosen automatically

Fastest path: download a working copy and rename it. structile-demo-plugin.zip is a complete, real package — the exact three files below, already wired up and already proven to install and register correctly (it's a fixture the structile test suite itself installs and exercises, not just a made-up sample). Unzip it, then:

  1. Rename the distribution and the importable module. The zip's pyproject.toml calls the distribution structile-demo-plugin; the folder src/structile_demo_plugin/ is the actual Python package (importable names use underscores, distribution names on PyPI conventionally use hyphens — that's normal, not a typo). Rename the folder and update every place its old name appears — every occurrence of structile_demo_plugin/structile-demo-plugin below needs to become your own package's name (acme_structile_formats in this walkthrough).
  2. Replace the interpreter script. Swap src/structile_demo_plugin/interpreters/demo_keyed.js for your own .js file (the props-interpreter.js from the worked example above, or your own) — keep it inside an interpreters/ subfolder of the package so the package-data line below can find it unchanged.
  3. Edit pyproject.toml — the full file, annotated:
[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"

[project]
name = "acme-structile-formats"        # <- your distribution name (PyPI-style, hyphens)
version = "1.0.0"
description = "Acme's structile interpreter(s) — .props file support."
dependencies = ["structile"]           # <- REQUIRED for a real plugin.
# The downloaded template deliberately OMITS this line (see its own
# comment) so it stays installable offline inside structile's own test
# suite. Copying the template without adding this line back is the
# single most common mistake here: your package will still install
# fine, but `from structile import InterpreterSource` in __init__.py
# will only work if something else already installed structile first.

[project.entry-points."structile.interpreters"]
# left of "=" is just a label (shown nowhere, can be anything unique in
# this table); right of "=" is "<your_package>:<entry point function>"
props = "acme_structile_formats:register"

[tool.setuptools]
package-dir = {"" = "src"}

[tool.setuptools.packages.find]
where = ["src"]

[tool.setuptools.package-data]
acme_structile_formats = ["interpreters/*.js"]   # <- must match your renamed package
  1. Edit src/acme_structile_formats/__init__.py (renamed from structile_demo_plugin) — the full file, annotated:
import importlib.resources
from structile import InterpreterSource


def register(registry) -> None:
    """The plugin entry point — must be named exactly what
    pyproject.toml's entry-points table points at ("register" above).
    `registry` is a small facade exposing only register_interpreter()."""
    js_path = importlib.resources.files("acme_structile_formats") / "interpreters" / "props-interpreter.js"
    text = js_path.read_text(encoding="utf-8")
    # First argument: the file extension this interpreter owns, leading dot.
    # name= is a label only, shown in logs/errors and st.plugins() below.
    registry.register_interpreter(".props", InterpreterSource(text, name="acme_structile_formats"))

InterpreterSource(text, name=) wraps JS source loaded directly from the installed package via importlib.resources (not a filesystem path — the file may be inside a wheel/zip at runtime, so a plain open(path) would break for some install methods). One package can register more than one extension: call register_interpreter() more than once inside the same register() function.

  1. Install it locally and verify. From the package's own directory (wherever pyproject.toml is):
pip install -e .
python -c "import structile as st; print(st.plugins())"

Expect your package listed and marked loaded, not failed. Then prove it actually resolves a file:

python -c "import structile as st; st.open('example.props', renderer='text')"

A plugin that fails to load or raises during registration is logged as a warning and never applied, but never breaks anyone else's open() call — and it still shows up in st.plugins() marked as failed, with the error, rather than silently vanishing. python -m structile --plugins prints the same thing from the command line, and STRUCTILE_DISABLE_PLUGINS (any truthy value) skips discovery entirely, for isolating "is my plugin the problem?" from everything else.

  1. Build and distribute it, exactly like any other Python package — nothing about structile.interpreters entry points changes this part:
pip install build twine
python -m build              # writes dist/*.whl and dist/*.tar.gz
twine upload dist/*          # to PyPI — needs a PyPI account/token

For an internal-only format that shouldn't be public, upload to your organisation's own package index instead (Artifactory, devpi, a simple authenticated HTTP index, AWS CodeArtifact, …) and have colleagues pip install --index-url https://your-internal-index/simple/ acme-structile-formats — the entry-point mechanism itself doesn't care where the package came from, only that pip install put it on sys.path.

Discovery is lazy (triggered by the first open()/diff()/convert() call, never by import structile) and runs at most once per process.

Distributing to a VS Code team

Three ways this can go, in order of how often each one is actually the right call:

Most of the time: a personal setting, not a distributed file at all. Structile is mainly for exploring data in ad-hoc projects — plenty of them not yours to add tool-specific config to, the same reason .vscode//.idea/ folders don't usually get committed either. Set structile.interpreter at the User level (Ctrl+Shift+P -> Preferences: Open User Settings (JSON)), not the Workspace level, and it follows you instead of any one repo:

// User Settings (JSON) — personal to your profile, applies in every
// project you open from now on, commits nothing anywhere
"structile.interpreter": ["/home/you/tools/acme-interpreters/blotter.js"]

Configured once, it resolves .axml/whatever in every workspace you open afterward — and it isn't workspace-trust-gated (only the workspace-local option below is), so it works the moment you set it, in an untrusted workspace too. A wiki page with the interpreter .js file attached and this JSON snippet to paste is the whole distribution story for most teams: nothing checked into any repo, no extension to build or install, no coordination beyond "here's the file, here's the setting."

If the format genuinely belongs to one specific repo — its data files just are that schema, the way a .eslintrc belongs to a JS project — checking it in there is reasonable: .structile/interpreters.json in the workspace root, mapping to .js files under .structile/interpreters/:

{ ".props": "props-interpreter.js" }

Only active in a trusted workspace — loading arbitrary JS out of a cloned repository into a webview is exactly the threat Workspace Trust exists for. Anyone who opens and trusts the repo gets it with zero setup of their own — but this commits Structile-specific config into a shared repo, so it's a call for whoever owns that repo to make, not a general distribution mechanism for a format that shows up across many unrelated projects.

If your organisation already builds and ships internal VS Code extensions, a third option exists: any installed extension — including one that does nothing else — can declare interpreters in its own package.json, via the same public contributes extension point every extension can use. Neither this nor the workspace option above needs the Structile extension's own source — its vscode-extension/src/** stays closed, and nothing here touches it; what you're building is a second, independent extension of your own. This is real infrastructure most teams don't have and don't need to build just for one interpreter — reach for it only if your org already ships internal extensions for other tooling; the two options above cover everything else.

Fastest path: download a working copy and rename it. structile-demo-companion-extension.zip is a complete, minimal, real VS Code extension that does nothing except contribute one interpreter — no compile step, no main entry point, just package.json + one .js file, on purpose. Unzip it, then:

  1. Replace interpreters/demo_keyed.js with your own interpreter script.
  2. Edit package.json — the full file, annotated:
{
  "name": "acme-structile-formats-vscode",     // <- rename
  "displayName": "Acme Structile Formats",     // <- rename
  "description": "Ships Acme's .props interpreter for the Structile extension.",
  "version": "1.0.0",
  "publisher": "acme",                          // <- your Marketplace publisher id, if publishing
  "license": "Apache-2.0",
  "engines": { "vscode": "^1.85.0" },
  "categories": ["Other"],
  "contributes": {
    "structileInterpreters": [
      {
        "extensions": [".props"],                 // <- your extension(s), leading dot
        "path": "./interpreters/props-interpreter.js", // <- relative to this file
        "name": "Acme props format",               // <- shown in the two debug commands below
        "format": "text",                          // "text" (interpretText/serializeText) | "xml" (interpretXML/serializeXML)
        "priority": 0                              // only matters if another installed extension also claims .props — higher wins
      }
    ]
  },
  "scripts": {
    "package": "vsce package --allow-missing-repository --no-rewrite-relative-links"
  },
  "devDependencies": {
    "@vscode/vsce": "^3.9.2"
  }
}
  1. Package and install it locally to verify:
npm install
npm run package        # writes acme-structile-formats-vscode-1.0.0.vsix in this folder
code --install-extension acme-structile-formats-vscode-1.0.0.vsix

(Or, without a command line: VS Code -> Ctrl+Shift+P -> Extensions: Install from VSIX... -> pick the .vsix.) Then open a .props file and confirm: Structile: List Contributed Interpreters should list your extension; Structile: Show Interpreter Resolution on the open file should show it as used.

  1. Distribute it. Three options, not mutually exclusive:
  2. Hand out the .vsix directly — exactly what this docs site does for the main extension itself (see the VS Code extension guide): host the file anywhere, colleagues run Install from VSIX.... No account, no review process, works today.
  3. Publish to the VS Code Marketplace (npx vsce publish — needs a Marketplace publisher account and a Personal Access Token) for discoverability via the Extensions view's search.
  4. Publish to Open VSX (npx ovsx publish) alongside or instead of the Marketplace, for VSCodium and other non-Microsoft-marketplace editors.

Building a custom/branded distribution

Every option above still asks a person to supply the interpreter themselves — drop the .js file, pip install a plugin, install a VS Code extension. Opening structile.html directly (no Python, no VS Code — just double-clicking the file, or hosting it on a wiki) has no registry to fall back on at all: dropping a data file with no matching interpreter yet gets a prompt asking for one, every single session.

structile.build_custom_html() closes that gap by baking a curated, build-time-chosen set of interpreters directly into a copy of structile.html. Opening that file and dropping only a data file resolves it immediately — no companion .js, ever, for the extensions the build chose to bundle. A stock, unmodified structile.html is unaffected either way.

Fastest path: download a working copy and rename it. structile-demo-custom-distribution.zip combines this with the installable-plugin pattern above, driven from one shared manifest.json instead of two separate interpreter lists. Unzip it, then:

  1. Rename the package, same as the plugin walkthrough above: src/structile_demo_custom_distribution/ -> src/acme_structile_formats/, and every occurrence of that name in pyproject.toml/__init__.py/build_html.py.
  2. Edit manifest.json — the one file both halves below read from:
{
  "interpreters": [
    { "extensions": [".props"], "path": "interpreters/props-interpreter.js",
      "name": "Acme props format", "format": "text" },
    { "extensions": [".axml"], "path": "interpreters/acme-blotter.js",
      "name": "Acme blotter XML", "format": "xml" },
    { "extensions": [".acfg", ".acmerc"], "path": "interpreters/acme-config.js",
      "name": "Acme config", "format": "acme_config" }
  ]
}

extensions/path/name/format mean exactly what they do in the VS Code contribution shape above — format is "xml"/"html" for the markup contract, anything else ("text", or a more specific string like "ini") for interpretText/serializeText. Replace interpreters/acme_config.js with your own script.

A build takes as many interpreters as you want. "interpreters" is a list, and several formats in one branded viewer is the normal case, not a special one — the example above bakes three. Each entry is independent: its own extensions (one or several), its own format, and the two contracts mix freely in one build. Two entries claiming the same extension is not an error — the later one silently wins, so keep the list unambiguous yourself. Extensions you don't list keep behaving exactly as they do in the stock viewer, and the built-in JSON/Python-repr/CSV formats need no entry at all. 3. Build the baked HTML — build_html.py calls structile.build_custom_html() against manifest.json:

import structile as st
st.build_custom_html("manifest.json", output="acme_structile.html")

Open the result directly (file:// it, or host it anywhere) and drop a .props file with no interpreter attached — it resolves on its own. 4. Wire up the Python side from the same manifest. __init__.py's register() entry point (see "Python: an installable plugin" above for the full mechanics) reads manifest.json too, so a colleague who pip installs this package gets both halves for free: st.open() resolves .props automatically, and (next) every renderer opens the branded HTML. 5. Ship it as a thin wrapper, not a fork. The package depends on structile normally and bundles the baked HTML as package data — one line at import time points every renderer (open(), diff(), .to_html(), the browser/file/widget renderers) at it:

# acme_structile_formats/__init__.py
import importlib.resources
import structile as st

st.set_viewer_html(importlib.resources.files("acme_structile_formats") / "acme_structile.html")

Colleagues who pip install acme-structile-formats get the branded, pre-wired viewer automatically — no separate download, no fork of structile to keep in sync with upstream releases. A full fork (replacing structile's own bundled HTML in a copy of the whole package under a new name) is the alternative if you need a fully independent package identity — more control, at the cost of manually re-merging every upstream change yourself.

Resolution order

When more than one of the above applies to the same file, the most specific one wins — how narrowly a declaration targets this exact file, not which mechanism supplied it:

Python, most to least direct:

Rung Source
1 An explicit interpreter= argument
2 A direct register_interpreter() call
3 A plugin registration (entry points)
4 Nothing — existing fallback, unchanged

VS Code, most to least specific:

Rung Source
1 Sibling <name>.interpreter.js
2 structile.interpreter setting (workspace, then user scope)
3 Workspace .structile/interpreters/ mapping (trusted workspaces only)
4 Interpreters contributed by installed extensions (highest priority first)
5 Built-in .xml/.html fallback — opens with a warning and waits

Within any rung that produces more than one candidate, the same "fail loudly, first non-throwing one wins" trial from above applies.

Debugging what's actually loaded

  • Python: st.plugins() (or python -m structile --plugins from the command line) lists every discovered plugin, valid or failed — a failed one shows its error rather than silently disappearing. Set STRUCTILE_DISABLE_PLUGINS to any truthy value to skip discovery entirely (explicit register_interpreter() calls are unaffected).
  • VS Code: Structile: Show Interpreter Resolution shows every candidate considered for the active file, each marked used, rejected, or not tried. Structile: List Contributed Interpreters lists every contribution from every installed extension. Structile: Reload Contributed Interpreters re-scans installed extensions after you edit a contributed interpreter's own content.