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"
}
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.jpgmust not rewrite the middle ofmy-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.
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
.jsonplus every extension whose format declares the matchingdocumentKind, then callparseon non-JSON matches. This is why adding a Markdown page is just droppingpages/about.mdin a parser-enabled project.Content loading : a
contentsection entry names a format (or derives it from the source's file extension); the loader callsdiscoverto list entry files, thenloadper file, validating each entry against the content type's schema. See Content collections .Studio editing : opening a claimed file calls
parseto get the Jx tree the canvas edits; saving callsserialize. When a capability'stimingexcludes the browser, Studio round-trips through the dev server'sPOST /__studio/formatendpoint 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 hyphenatedtagNamemeans "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
parseandserializefor it. Both are needed: withoutparsethe file cannot be opened after it is created, and withoutserializeits first save falls through to another format and writes that format's bytes into it. (This is why the parser extension'sCsv, which parses rows and has no serializer, is not offered; a.csvis still creatable through the picker's Other… row.Csvdeclaresrewriteinstead, which buys it the refactor and not this picker.)Convert Format… offers your format as a conversion endpoint when it declares both capabilities and
pageorcomponentindocumentKinds. That membership is what saysparsereturns 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.
Related
Tutorial: a TOML format extension : build a working format end to end
Capability methods : timing and the options contract
Content collections : the consumer of
discover/loadJx Markdown : what the reference format's dialect looks like