Skip to content

Schema composition

A project's effective schema is a plain JSON Schema 2020-12 document composed from the core schema and each enabled extension's shipped schema fragment. There is no jx-specific composition runtime: any compliant validator can validate a project offline, and editors get autocomplete for extension-contributed sections through an ordinary "$schema" binding.

Fragments

Two kinds of fragment feed the composition:

  • Core ships @jxsuite/schema/schemas/project.core.schema.json : the core project properties ( name , url , build , imports , extensions , …) plus two published $defsJxFieldSchema (the JSON-Schema-subset field shape used by content and table schemas) and RelationshipRef (see Relationships ). The core fragment is open — closure happens in the generated entry document.

  • Each extension ships the fragments named in its manifest 's schemas map. A project fragment contributes plain properties for its section keys. Fragments must be standalone-valid 2020-12 documents with their own $id .

The parser's project fragment, abbreviated — it contributes the content section:

{
  "$id": "https://jxsuite.com/schema/ext/parser/project/v1",
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "content": {
      "type": "object",
      "additionalProperties": {
        "type": "object",
        "properties": {
          "source": { "type": "string" },
          "format": { "type": "string" },
          "schema": {
            "type": "object",
            "properties": {
              "properties": {
                "additionalProperties": { "$ref": "https://jxsuite.com/schema/project/fields/v2" }
              }
            }
          }
        }
      }
    }
  }
}

Note the one non-local reference: field-schema positions point at the canonical URI https://jxsuite.com/schema/project/fields/v2 rather than a local shape. That is the open recursion point described below.

Generated entry documents

jx schema writes two committed files into the project root, composing core with every fragment listed by the project's extensions . Both files are self-contained single-resource schemas : every referenced resource is embedded under $defs , and every $ref is a root-relative JSON Pointer into the same file — no relative ./node_modules/… paths and no canonical URIs, so every editor and validator resolves them offline and identically. The generated project.schema.json of a project using only the parser (embedded resource bodies elided):

{
  "$comment": "Generated by `jx schema` from project.json#/extensions — do not edit.",
  "$defs": {
    "Fields": {
      "anyOf": [
        { "$ref": "#/$defs/project-core-v2/$defs/JxFieldSchema" },
        { "$ref": "#/$defs/project-core-v2/$defs/RelationshipRef" }
      ]
    },
    "project-core-v2": { "…": "embedded core fragment" },
    "ext-parser-project-v1": { "…": "embedded parser fragment" }
  },
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "allOf": [{ "$ref": "#/$defs/project-core-v2" }, { "$ref": "#/$defs/ext-parser-project-v1" }],
  "type": "object",
  "unevaluatedProperties": false
}

Top-level section keys combine via allOf + unevaluatedProperties: false — 2020-12 unevaluatedProperties sees annotations from adjacent allOf branches, so the entry document closes the object without any fragment needing to know its siblings. A misspelled or un-contributed top-level key fails validation.

Each $defs key is a slug of the embedded resource's $idhttps://jxsuite.com/schema/project/core/v2 becomes project-core-v2 .

document.schema.json is the same move for documents: the core document schema embedded as #/$defs/v1 and referenced through "allOf": [{ "$ref": "#/$defs/v1" }] , with the paths union resource re-embedded as the union of extension-contributed $paths shapes (the parser contributes ContentPathsSource from its document fragment).

project.json binds via "$schema": "./project.schema.json" .

The two union resources

Two positions are open recursion points where a fragment must reference the effective union without knowing what other extensions contribute:

Resource $id Position Shipped default → entry-document union
https://jxsuite.com/schema/project/fields/v2 Field-schema values inside section entry schemas (content frontmatter fields, table columns). Core JxFieldSchema + RelationshipRef ; the entry adds extension field extras.
https://jxsuite.com/schema/document/paths/v2 The $paths value of a document. Core source shapes plus an unknown-source branch; the entry drops that branch and unions each extension's paths shape.

The mechanism is $id shadowing : core ships a default resource under each well-known $id , and the generated entry document re-embeds the same $id with the effective union — outermost wins, with plain $ref s and no jx-specific runtime. Fragments just write { "$ref": "https://jxsuite.com/schema/project/fields/v2" } at field positions; no local fallbacks needed.

An entry embed shadows the shipped default rather than extending it, so it restates the core members — otherwise they would stop validating the moment an extension contributed one. The paths union is the one place the two differ: the shipped default also accepts "a source shape from an extension I cannot see", because a default has no way to know which extensions a project enables and would otherwise report { "contentType": "blog" } as an error. Your generated document.schema.json knows, so it drops that branch and checks $paths exactly — a misspelled contentType , or a source belonging to an extension you have not enabled, is an error instead of a route that silently builds zero pages. The override is settled while the entry document is generated, not while it is validated: each canonical- $id reference is rewritten to the root pointer of the entry's own embed, so the committed file states the winner outright.

Note

$dynamicRef looks like the textbook fit, but ajv 8.x supports $dynamicAnchor only at schema-resource roots — and the recursion unit here (a field schema) is not the document root. $id shadowing achieves the identical outermost-wins override; the behavior is verified against ajv in packages/schema/tests/project-schemas.test.ts .

Validation

  • Every consumer resolves the committed entry documents offline with no file access at all : each $ref is a root-relative JSON Pointer into the same document. No node_modules , no network, no editor configuration.

  • jx validate validates the whole project tree: both committed entry documents for self-containment (any $ref that is a relative path, a URI, or a pointer to nothing fails), project.json against ./project.schema.json , every component/page/layout against the bundled document schema, every project-local *.class.json against the class schema, and each fragment standalone. CI runs this over every project root in the repo.

  • Where a client fetches the canonical URLs instead (they are served from jxsuite.com), it gets the shipped defaults — the degradation is under-suggestion of extension field extras, never false errors.

Note

Root pointers rather than a $id -keyed compound document, because VS Code's JSON language service — which Monaco embeds, so this covers Studio too — implements neither half of compound-document resolution. It resolves #/… against the document root instead of the enclosing $id , and it fetches anything with a URI before the # over the network rather than matching the resource embedded in the same file. Root pointers sidestep both and cost nothing under ajv.

Regenerating

Run jx schema whenever the extensions list changes (and re-run jx validate after). Regeneration also happens without the CLI:

  • Studio regenerates the entry documents when settings are saved.

  • The dev server's Studio API regenerates them on demand when they are missing or older than project.json , serving the same self-contained single-resource form to the browser (Monaco registers them as inline objects and never resolves a ref itself).

The outputs are committed artifacts — check project.schema.json and document.schema.json into the repo so editors and CI validate without a build step.