Skip to content

Custom formats

A format teaches Jx a new file type. Jx is JSON, so .json is the single native built-in; every other extension a project opens, saves, builds, or loads content from ( .md , .csv , your .toml ) is dispatched through a format class. A class participates in format dispatch iff its .class.json descriptor carries a top-level format object.

The format block

The parser's Markdown.class.json declares, verbatim:

"format": {
  "extensions": [".md"],
  "mediaType": "text/markdown; variant=GFM",
  "documentKinds": ["page", "component", "content"],
  "exportTarget": true,
  "remote": false
}
Key Type Default Meaning
extensions string[] (required) File extensions claimed, with leading dot.
mediaType string Media type; validated , so a malformed value fails the registry build. Used for icons, labels and HTTP responses.
documentKinds ("page"|"component"|"content")[] [] page / component admit the extension into pages/components discovery globs; content admits it as a content source.
exportTarget boolean false When true, site builds emit a serialized sidecar per page in this format (requires a serialize capability).
remote boolean false When true, the load capability accepts http(s) URLs as sources. Remote content sources must name a remote-capable format explicitly.

Two classes may claim the same extension only with disjoint capabilities . The registry build fails on an ambiguous (extension, capability) pair. A registry never claims .json .

mediaType is checked

Your mediaType reaches an HTTP header, a file-picker filter and a Studio label, so it's parsed rather than passed through. Get the grammar wrong and the build tells you:

Format "Toml" declares an invalid mediaType: "applicationtoml" has no "/" — a media type is type/subtype (RFC 6838 §4.2)

The check is on the syntax , not the IANA registry: application/x.my-format is fine, and so is any subtype nobody has registered.

Parameters are welcome and carry meaning. @jxsuite/parser declares text/markdown; variant=GFM , which is how RFC 7763 says which markdown a format speaks:

"format": {
  "extensions": [".md"],
  "mediaType": "text/markdown; variant=GFM"
}
Note

A file-picker accept map or an editor language id keys on a media type. In code like that, use the essence ( text/markdown ), not the declared string. mediaTypeEssence on the registry entry gives you that. Two Studio call sites broke the moment the variant parameter was declared, which is why the distinction exists.

What a static file server sends

Your format block only reaches code that went through the registry. A .md file served straight off disk (by the dev server, or by jx preview ) never touches it, and the platform's own table answers instead.

For most extensions that's fine. For two it isn't, so Jx overrides them:

Extension Platform says Jx sends Why
.md text/markdown text/markdown; variant=GFM Bare text/markdown doesn't say which markdown ( )
.yaml , .yml text/yaml , or nothing application/yaml text/yaml is the pre-registration spelling ( §5)

Everything else keeps whatever the host already decided. This list corrects a lookup, it isn't a second MIME table. A test asserts that the .md entry and the parser's declared mediaType agree, because they live in files that can't import each other.

Format capabilities

The block declares what the class handles; the class's capability methods declare how . Five roles belong to the format block:

Role Signature Consumers
parse (source, options?) → JxDocument compiler, server, Studio (open file)
serialize (doc, options?) → string Studio (save), site build (export sidecars)
rewrite (source, edits) → string the rename refactor (repair a reference)
discover (source, { baseDir }) → string[] content loading (list entry files)
load (path, { schema, directiveOptions }) → ContentLoaderEntry[] content loading (parse one source)

A format implements the subset it needs: a read-only format can ship parse without serialize (Studio then opens files in this format read-only in structural modes); a data-only format like Csv needs discover / load but has no reason to be a page format.

rewrite : repairing a reference without round-tripping

serialize promises a lot: that a parsed document turns back into source your author would recognize. A data format often cannot promise it. Re-emitting a CSV means picking a quoting style, a line ending and a column order the author already picked, and the loader has no opinion about any of them.

But a rename does not need a document back. It needs one cell's text changed. That is what rewrite is:

static rewrite(source: string, edits: readonly { from: string; to: string }[]): string

You are handed the file's own text and a list of authored values to replace, and you return the text with exactly those values replaced and every other byte preserved. Two rules make it safe:

  • Match whole values, never substrings. hero.jpg must not rewrite the middle of my-hero.jpg .

  • Change nothing else. Padding, quoting and line endings are the author's.

Declare it and the rename refactor repairs references living in your format instead of reporting them as something the author has to fix by hand. Declare both and the refactor prefers serialize , because a full round trip can express a change a list of value edits cannot, such as renaming a custom-element tag.

Note

rewrite is not a weaker serialize , and the two are not ranked. Declare either, both, or neither. Only serialize makes your format creatable and convertible in Studio; rewrite earns nothing but the refactor, which is exactly what a load-only format wants.

The Markdown class implements all four, plus the standard instance resolve() , so { "$prototype": "Markdown", "src": "./about.md" } works as runtime state, satisfying the same external class contract as every other class.

How the pipeline dispatches

Hosts never hard-code file types. Each one builds a format registry from the enabled extensions' manifests and routes by extension:

  • Pages and components discovery : the site build and dev server glob for .json plus every extension whose format declares the matching documentKind , then call parse on non-JSON matches. This is why adding a Markdown page is just dropping pages/about.md in a parser-enabled project.

  • Content loading : a content section entry names a format (or derives it from the source's file extension); the loader calls discover to list entry files, then load per file, validating each entry against the content type's schema. See Content collections .

  • Studio editing : opening a claimed file calls parse to get the Jx tree the canvas edits; saving calls serialize . When a capability's timing excludes the browser, Studio round-trips through the dev server's POST /__studio/format endpoint instead of importing the implementation ( Studio routes ).

  • Export sidecars : with exportTarget: true , the build serializes each page into the format next to its HTML output.

Studio hints

Format classes describe their Studio control surface declaratively in a top-level $studio block. Studio interprets this data generically and never hard-codes per-format element sets. From Markdown.class.json , abbreviated:

"$studio": {
  "icon": "markdown",
  "modes": ["edit", "design", "preview", "source"],
  "documentMode": {
    "default": "content",
    "componentWhen": { "frontmatterKey": "tagName", "matches": ".+-.+" }
  },
  "newFileTemplate": "---\ntitle: Untitled\n---\n\n",
  "elements": {
    "block": ["h1", "h2", "h3", "p", "blockquote", "ul", "ol", "li", "pre", "…"],
    "inline": ["em", "strong", "del", "code", "a", "img", "br"],
    "void": ["hr", "br", "img"],
    "textOnly": ["code"],
    "nesting": {
      "h1": { "block": false, "inline": true, "directive": false },
      "ul": { "only": ["li"] },
      "…": {}
    }
  }
}
  • icon : file icon in the Files panel.

  • modes : which canvas modes the format supports.

  • documentMode : whether files open as prose content or as components, with an optional frontmatter-based override (here: a hyphenated tagName means "this .md file defines a custom element").

  • newFileTemplate : the seed content for New File in this format.

  • elements : the allowlist and nesting constraints gating structural editing, covering which tags the element picker offers, what may nest where, and which are void or text-only.

What declaring both capabilities buys you

Studio derives two of its surfaces from parse and serialize rather than from any list of format names, so a format extension reaches both by declaring them:

  • The New File format picker offers your extension when some installed class declares BOTH parse and serialize for it. Both are needed: without parse the file cannot be opened after it is created, and without serialize its first save falls through to another format and writes that format's bytes into it. (This is why the parser extension's Csv , which parses rows and has no serializer, is not offered; a .csv is still creatable through the picker's Other… row. Csv declares rewrite instead, which buys it the refactor and not this picker.)

  • Convert Format… offers your format as a conversion endpoint when it declares both capabilities and page or component in documentKinds . That membership is what says parse returns a Jx document rather than content entries, and the compiler already relies on it to build its page and component globs, so there is no separate declaration to make.

.json is the endpoint both surfaces share, because no registry ever claims it.

One more generic hint applies to any class, not just formats: $studio.stateDefaults , an object merged into state entries Studio creates for the prototype. The connector's TableQuery sets { "timing": "client" } so Studio-created queries default to browser resolution.