A model, two diagrams, and a diagnosis
Your @context is losing your data, and nothing is reporting it.
JSON-LD expansion is total. It does not fail on a key it cannot resolve — it drops it. A document can lose most of its content while every processor involved reports success. This is a workshop built to make that visible.
The complaint
Three symptoms bring people here. They all have the same cause: a
@context is not a schema, and nothing in the stack treats it like one.
Silent loss
A key is misspelled, a prefix is undeclared, a term was never defined. Expansion succeeds. The triple is simply not there, and the only way you find out is when a consumer asks where the field went.
Coercion that did not fire
"author": "https://example.org/ada" looks like a reference and
reads like one. Without @type: @id it is a string. Your graph has
a literal where you drew an edge.
Two pictures, one file
@container restructures the JSON and changes no triple.
@nest vanishes on expansion. A scoped context makes one key mean
two things. You are holding both pictures in your head at once.
The treatment
One canonical file holding semantics only. Everything else is generated from it, checked against it, or drawn from it.
A model, not an artifact
<name>.jsonld.yaml is what you edit and what your reviewer
reads. The @context is emitted from it and carries a header saying
so. Hand-edit the artifact and CI will tell you.
Findings with a line number
L0 well-formedness, L1 context errors, L2 lossiness. Every finding carries a stable rule id, a JSON Pointer and a line and column in a file you wrote — never a position in a generated artifact.
Two coordinated panes
The JSON shape a developer will type, beside the RDF graph it denotes, sharing one selection. A facet that exists on only one side is drawn as visibly absent on the other, rather than as nothing.
Immutable versions
Freeze a model as a content-addressed version with a manifest that checksums every file. Publish a static tree that serves from GitHub Pages, S3, or any file host with no configuration.
A diff that has opinions
Two versions compared from their lockfiles, matched by element id so a rename
reads as a rename. Every difference classified
additive, compatible, breaking,
semantic or illegal.
Offline by construction
Exactly one command touches the network, and a person invokes it. Everything else reads a committed directory of vendored contexts with recorded hashes.
Getting started
Two pieces: ldm, the command line tool, which is what your pull
request runs; and the VS Code extension, which is for authoring. You can use
either alone.
# the CLI
npm install -g ldm
# the extension
code --install-extension pavlyshyn.jsonld-modeler
1. Bring your own context, or start empty
If you already have a @context, import it. Import recovers every
facet the context states, and reports what a context structurally cannot
carry — class membership, documentation, the intent behind
@vocab — rather than inventing it.
ldm import catalogue.jsonld --out catalogue.jsonld.yaml
Import then emit is semantically equal to the input. A second round is byte-identical. If the first emit differs from the file you imported, the difference is formatting or a facet the context expressed in a longer way — the tool will show you which.
2. The model
Semantics only. No presentation, no ordering tricks, nothing that belongs to the generated artifact. Comments are yours and survive every tool-driven edit.
jsonld: "1"
namespace:
prefix: cat
base: https://example.org/catalogue#
mode: "1.1"
terms:
# `@set` is the closest thing JSON-LD has to future-proofing: always an
# array, so a property that becomes multi-valued later does not change the
# shape of every document that already exists.
tags:
id: tag001
"@id": cat:tag
"@container": "@set"
author:
id: aut001
"@id": cat:author
"@type": "@id" # without this, an IRI is a string
examples:
# A document that must survive expansion intact.
- id: exa001
path: documents/ok.json
expect: { ok: true }
# A negative example names the rule ids it must raise. One that merely
# fails passes even when it fails for the wrong reason.
- id: exa002
path: documents/typo.json
expect:
rules: [L2.key-dropped]
id: tag001 is the element id. The JSON key and the IRI are both
mutable attributes of it — which is how a rename can be detected rather than
guessed at from similarity.
3. Check what your documents lose
$ ldm check catalogue.jsonld.yaml
documents/typo.json:4:3 L2.key-dropped
"writtenBy" matched no term and expands to nothing.
Expansion still succeeded; this content is simply gone.
documents/typo.json:7:3 L2.coercion-did-not-fire
"https://example.org/ada" reads as an IRI but "editor" has no
@type: @id, so it expanded as a literal rather than a reference.
2 findings (2 error)
Exit 0 clean, 1 findings at error severity,
2 a usage error. That is the whole gate.
4. Generate the context
ldm emit catalogue.jsonld.yaml --out build
Two targets. context keeps referenced contexts as a live layer —
JSON-LD composes at runtime by design, and that is usually what you want.
context-inline flattens them from the vendored copies and
reports the fork, because a flattened copy of someone else's vocabulary
stops tracking theirs the moment you write it.
Every emitted artifact says it was generated.
A repository that lets someone hand-edit one will lose that edit. Run
ldm emit in CI and compare — the artifact is a build output, and
treating it as source is the failure mode this design exists to prevent.
5. Vendor the contexts you reference
ldm vendor fetches each referenced context once into a committed
directory and records its hash. Every other command then reads only that
directory, with the network off.
ldm vendor catalogue.jsonld.yaml # fetches, records hashes
ldm vendor catalogue.jsonld.yaml --check # verifies, fetches nothing
This is a security boundary, not a convenience. A command that fetches URLs a model names, running against an outside pull request, is a request-forgery primitive. So exactly one command can do it, and a person invokes it.
Projects, versions, publishing
Once more than one model exists, the questions change. Which models belong together? What exactly did we publish last March? Is this change safe? A project file answers the first, and immutable versions answer the rest.
# ldm.project.yaml
project: "1"
name: catalogue-suite
baseUrl: https://vocab.example.org/
models:
core: models/core.jsonld.yaml
catalogue: models/catalogue.jsonld.yaml
published: published
versions: versions
hosts: [plain, github-pages, s3]
ldm check # every model, one report
ldm version new --alias stable # freeze it, label it
ldm publish # write the static tree
ldm diff stable next --fail-on breaking # gate the pull request
ldm search curator # what already exists, offline
A version has no name to rename
A version's identity is a hash of its inputs, so there is deliberately no command
that renames one. Human names like stable are movable labels over
immutable versions — ldm alias rename moves the label,
ldm alias set points it at a corrected version, and both leave every
published byte identical.
The tool does not upload
published/ is a static tree you commit and copy wherever you serve
from. Every host already has a mature tool for the copying. What this owes you is
a tree those tools can copy verbatim — plus a refusal when a name would produce a
path your host silently mangles. GitHub Pages drops
_-prefixed paths unless .nojekyll is present; S3 has its
own opinions about keys without extensions. The host adapters declare those
constraints, and the whole tree is validated against every named host
before the first write.
| Command | What it does |
|---|---|
ldm check | Run the validation ladder over a model, or every model in a project |
ldm emit | Generate the @context for a target |
ldm import | Turn an existing @context into a model |
ldm explain | Why a document means what it means, optionally step by step |
ldm vendor | The one command that fetches |
ldm ids | Backfill element ids as a targeted splice |
ldm version new | Freeze a model as an immutable, content-addressed version |
ldm alias | set, rename, rm, list — move labels over versions |
ldm publish | Write the static tree, validated against every named host first |
ldm diff | Compare two versions and classify every difference |
ldm search | Find a term across published versions and vendored contexts |
Conformance is observed, not claimed
Expansion and compaction are implemented in this project rather than delegated, because the lossiness reporting needs a JSON Pointer from every output back to the input that produced it — and no library exposes that. Writing the algorithms is the cost of the diagnosis.
So the W3C JSON-LD 1.1 test suite is vendored and run with the network off, and
every in-scope case is additionally run through jsonld.js with the
outputs compared. Cases that do not pass are listed with a reason.
frame, toRdf, fromRdf, flatten
and html are out of scope for this release and are reported as such
rather than skipped silently.