Dr. JSON-LDapothecary for linked data

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.

An alembic over a flame, with particles of a graph suspended inside it

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.

CommandWhat it does
ldm checkRun the validation ladder over a model, or every model in a project
ldm emitGenerate the @context for a target
ldm importTurn an existing @context into a model
ldm explainWhy a document means what it means, optionally step by step
ldm vendorThe one command that fetches
ldm idsBackfill element ids as a targeted splice
ldm version newFreeze a model as an immutable, content-addressed version
ldm aliasset, rename, rm, list — move labels over versions
ldm publishWrite the static tree, validated against every named host first
ldm diffCompare two versions and classify every difference
ldm searchFind 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.