How the plugin turns a Sanity Studio schema into a Mermaid classDiagram, and the contract that mapping follows. This is the working reference for contributors (human or AI-assisted) on the rendering pipeline.
The decisions behind it — why a Mermaid class diagram, and why an in-Studio plugin that reads the composed schema — are recorded in ADR 0001 (the export contract) and ADR 0002 (in-Studio plugin form + schema source). This document is the living restatement of the contract; the ADRs are the deeper "why."
Sanity models two genuinely different kinds of thing: documents (entities with their own identity and top-level URL) and objects (compositional values, always embedded in something else). Mermaid's classDiagram is purpose-built to show exactly this: stereotypes mark the document/object distinction, composition diamonds mark embedded objects, association arrows mark references, cardinality sits on the lines, and classDef styles each stereotype. (An earlier OWL/RDFS attempt flattened the document/object distinction into a single class hierarchy — a paradigm mismatch. See ADR 0001.) The emitted Mermaid is also designed to be portable — it should render in any Mermaid host (mermaid.live, GitHub, etc.) without app-specific config.
A pure transform chain with a single impure seam at each end (reading the schema, rendering to the DOM):
useSchema() → readSchemaSource → walk → filterModel → emit → MermaidView
(Studio) schema-adapter walker filter-model emit- (SVG render)
│ mermaid
└── probe (validation introspection)
probe.ts— Proxy-based introspection of a field'svalidationfunction. Records everyRule.*call without importing Sanity's Rule class (coupled to method names only), returning{required, min, max, hasCustom, hasOtherConstraints}. This is what recovers real cardinality.walker.ts— Walks the rawdefineType-shaped schema array and produces aCanonicalModel({classes, edges, warnings}). Uses the probe for cardinality; handles type-name skips, inline-alias resolution, image/file fields (scalar leaf vs. promoted class), inline-anonymous-object naming, edge filtering, and collision warnings.filter-model.ts— A pure transform of theCanonicalModel, applied betweenwalkandemit, that drops hidden classes/edges per the Elements selection. Filtering never lives in the walker or emitter. (elements.tscomputes the selection: reachability, orphans, dependent-object resolution.)emit-mermaid.ts— Renders aCanonicalModelto a MermaidclassDiagramstring. Pure; no I/O. "Attributes" toggle and theme palette areemitoptions, not post-processing.build-diagram.ts— OrchestratesreadSchemaSource → walk → (filter) → emit, returning aDiagramResult.schema-adapter.ts/tool/— the two impure seams (read Studio's schema; render/clipboard/toasts). Kept thin (see ui-design.md guardrails).
The CanonicalModel is the seam: everything upstream produces it, everything downstream consumes it. New capabilities should extend the model or its pure transforms, not thread schema details into the component layer.
The adapter (readSchemaSource in schema-adapter.ts) reads useSchema()._original.types — the raw, authored defineType array, merged from Studio config and every plugin, with validation functions still intact. That combination (raw + validation-preserving + plugin-aware) is what lets the probe recover full cardinality and see plugin-contributed types like skosConcept.
Two rejected alternatives:
- Compiled
get()/getTypeNames()(public API) — sees all plugin types, but validation is already resolved to specs, so the probe can't introspect it and cardinality degrades. - Importing
schemaTypes/index.tsdirectly (the CLI's path) — raw + validation intact, but blind to plugin-contributed types.
_original is tagged @internal, so the adapter guards the access and degrades gracefully (a missing/non-array _original.types yields an empty result + a human-readable warning, never a crash or silent blank). The dependency is isolated to that one ~4-line function. Full risk analysis in ADR 0002. Re-verify the access when widening the sanity peer range.
MermaidView re-runs mermaid.initialize() per render (it's just config) before calling mermaid.render(). Three of those options are load-bearing — changing them changes correctness, not taste:
-
theme—'dark'/'default', tracking Studio's resolved colour scheme. Sets the bg/edge/label base; the class fills come from our ownclassDeflines. -
htmlLabels: false— renders labels as SVG<text>rather than HTML in a<foreignObject>. AforeignObjecttaints a<canvas>, which would break Copy PNG'stoBlob(). For our short labels the visual result is the same. -
class.defaultRenderer: 'dagre-wrapper'— the unified (v2) renderer, pinned deliberately. The legacy'dagre-d3'renderer draws a self-referential relation (article.relatedArticles → article) as three separate paths that trail off into empty space instead of looping back to the class — visible dangling edges. Self-references are common in real models (parent categories, related products, prerequisites, SKOSbroader/related), so this is not an edge case: three of the four dev archetypes have them.Mermaid 11.17 made
dagre-wrapperthe default, but ourmermaiddependency is a caret range — consumers resolve any 11.x — so the choice is pinned rather than inherited, and holds if the default moves again in either direction. Otherwise the two renderers are equivalent: identicalviewBox, identicalclassDeffills, noforeignObjecteither way. Don't drop the pin as part of a version bump — it's a correctness guardrail, covered by a test inMermaidView.test.tsx.
Known renderer limitation: Mermaid draws only one self-edge per class. When a class has several self-references, the parallel edges collapse and the last label wins — so a real relationship is silently absent from the diagram (issue #46). Renderer-independent; parallel edges between different classes render fine.
- Document types:
<<document>>annotation in the class body; blue viaclassDef document fill:#2276FC,stroke:#7AACFD,color:#fff. - Object types:
<<object>>annotation; slate viaclassDef object fill:#7B8CA8,stroke:#AFBACA,color:#fff. - Styling is applied at the class declaration with Mermaid's
:::operator:class Method:::document { … }. The annotation produces the visible«document»label; the:::stereotypeproduces the colour. Both are needed. - Trap — phantom class: a standalone
class Name stereotypeline (no:::) is parsed as a new class with the concatenated name (Methoddocument), rendering an extra empty box. Declare each class exactly once, with:::stereotypeat the declaration site; never emit separate style-assignment lines. - Trap — classDef placement: emit
classDeflines at the end, after all classes and edges. The parser tolerates either order, but some viewers (mermaidviewer.com) drop fills whenclassDefprecedes the classes that reference it. Bottom-placement renders consistently everywhere.
- Primitive field →
+fieldName: type [cardinality]in the class body. - Object field (named composition target or inline anonymous object) → field line plus a composition edge
Parent *-- Child(filled diamond). - Reference field → field line plus an association edge
Parent --> Target(arrow). A multi-target (polymorphic) reference (to: [{type: 'a'}, {type: 'b'}]) emits one edge per target and lists all of them in the field label, pipe-joined (+related: Article|Event) — rendered as authored rather than collapsed to the first target (issue #27). Composition is always single-target; only references can be multi-target (theObjectCharmodel splits the two —targetvstargets). - Portable Text → depends on contents: block-only PT is a scalar label (
+overview: PortableText [0..1]), no class/edge; PT that also carries class-able embeds is promoted to its own class (see Portable Text below). - Image & file fields → shape-dependent (
filefollowsimagethroughout):- A named top-level image/file type (
defineType({type: 'image', …})) is an object-stereotype class (originimage/file), with a synthetic+asset: url [1]prepended (so the primary content is explicit); Sanity-internalhotspot/crop/mediaare skipped; user fields follow in declaration order. A field referencing it composes in (Parent *-- HeroImage). - A bare inline image/file field (
{name: 'avatar', type: 'image'}, no authored sub-fields) is a scalar leaf —+avatar: image [0..1](labelimage/file), no class, no edge: the field holds an asset, not an author-defined object. An array of them is the same leaf with array cardinality (+gallery: image [0..*]). - An inline image/file with its own authored sub-fields (e.g.
alt/caption) is promoted to an object-stereotype class with origininline— synthetic+asset: url [1]lead plus those fields — and a composition edge, under the same naming/collision policy as an inline anonymous object. (The image-internalhotspot/crop/mediadon't count as authored sub-fields, so an image carrying only those stays a scalar leaf.)
- A named top-level image/file type (
- Inline anonymous object → its own object-stereotype class. Naming: bare
pascalCase(fieldName)unless it collides with a named class or another field-derived object of the same name, in which case all colliding ones are qualified by their parent, base-first (Metadata_Method,Metadata_Discipline), with one warning per collision group inmodel.warnings. Base-first keeps the base name readable; the_separator can never clash with a real type's class name, sincepascalCasestrips_/-/.and so never emits one. The same rule disambiguates colliding structural Portable Text fields (twobodyfields →Body_Article/Body_Page) — see Portable Text. - Named top-level type collision → two distinct type names that
pascalCaseto the same class name (blogPost+blog_post→BlogPost) would otherwise emit two same-named classes and merge into one Mermaid box. They're disambiguated by the same base-first scheme, but qualified by their source name (BlogPost_blogPost,BlogPost_blog_post) since a top-level type has no parent — with one warning per collision group. Every reference/composition that targets a colliding type resolves to the qualified class name, so edges stay intact (issue #28). The emitted-class namespace this checks against covers documents, objects, images, files, and named Portable Text aliases alike, so an inline object colliding with any of them is disambiguated too. - Custom-validator marker →
[…, custom]appended to a field's cardinality when validation can't be fully rendered:Rule.custom(…), other constraints (regex/email/unique/length/…), orRule.min/maxon a non-array (value bounds, not cardinality).
A Portable Text array maps one of two ways, by what it contains:
- Block-only (
of: [{type: 'block'}]— the commonoverview/bodyprose field) → a scalar+field: PortableText [0..1]label. No class, no edge; the prose is one opaque value. - Structural (a
blockmember plus ≥1 surfaced non-block member — a class-able embed, or a bare image/file leaf) → promoted to its own«object»class (originportableText), carrying a synthetic+block: PortableText [0..*]field for the prose plus one field per surfaced member. Class-able embeds also get a composition/reference edge, so the relationship is two-hop —Parent *-- BodyClass *-- Embed— keeping embedded types connected instead of orphaned.
Embeds are gathered from all three positions an embed can occupy, each becoming a composition (object) or association (reference) edge from the PT class:
- top-level
ofmembers — block-level inserts (acalloutBox, abodyImage, a reference); - a block's inline
of— inline objects within the text (aninlineHighlight); marks.annotations— objects/references decorating a span (alink).
A named embed composes to (or references) that type's class, labeled by the member's own name when present ({name: 'pre', type: 'code'} → a pre field) and otherwise by the type name. If that type is itself a named alias (richTableBlock → richTable), it's followed to the base class first (see Type-name skips & resolution) — issue #32. An embed declared inline becomes its own class with origin inline, under the same naming/collision policy as inline anonymous objects — so it follows the Elements menu's "inline objects" toggle. This covers inline objects/annotations ({name, type: 'object', fields}, e.g. a link) and inline image/file members carrying their own sub-fields ({type: 'image', fields} — these get the synthetic +asset: url [1] lead, and fall back to the type name when the member is nameless). Because image/file are intrinsic primitive types, the inline-composite check runs before the primitive short-circuit. A bare image/file member (no authored sub-fields) isn't class-able, but it still surfaces as a scalar image/file leaf field on the PT class (no class, no edge) — and counts as a surfaced member, so a [{block}, {image}] field promotes to reveal the image rather than collapsing to an opaque scalar.
Promotion keys on explicitly-authored embeds only: a bare {type: 'block'} carries no marks/of in the raw authored schema (Sanity's default link/decorators live only in the compiled type — see ADR 0002), so ordinary prose fields stay scalar.
Derived from Rule.required() + array status, refined by array Rule.min/Rule.max:
| required | array | extra | cardinality |
|---|---|---|---|
| no | no | — | 0..1 |
| yes | no | — | 1 |
| no | yes | — | 0..* |
| yes | yes | — | 1..* |
| — | yes | Rule.min(n) |
n..* |
| — | yes | Rule.max(m) |
0..m |
| — | yes | Rule.min(n).max(m) |
n..m |
Cardinality here is information design, not runtime validation — the diagram is not a constraint surface.
- Skip patterns:
/^sanity\./,/^assist\./,/^geopoint$/. Matching top-level types are dropped; references pointing at them drop the edge plus a warning, so the diagram stays honest about what's filtered. - Inline-alias resolution: a top-level
referencetype (defineType({name: 'referencedDiscipline', type: 'reference', to: [{type: 'discipline'}]})) is followed through to its target (Discipline) rather than emitted as its own class. The same mechanism resolves named-class composition. - Named-type aliases (type extension): a top-level type whose
typeis the name of another registered type — Sanity's type-extension feature, e.g. the rich-table plugin'sdefineType({name: 'richTableBlock', type: 'richTable'})— is followed through the alias chain (cycle-guarded) to the underlying definition the diagram actually emits. A field, array member, or Portable Text embed referencingrichTableBlockthen composes to theRichTableclass instead of dropping silently and stranding it as an orphan (issue #32). The chain bottoms out at its base: a document/object/image/file → composition to that class; a primitive → that primitive's leaf; a reference/array alias → resolved as above. The alias itself is never emitted as a separate class. slugis treated asstringat field sites; no class emitted.- Platform metadata fields are skipped:
_id,_type,_createdAt,_updatedAt,_rev,_key,_weak.
Reflected: Rule.required(), array Rule.min/max, and the presence of Rule.custom() / other constraints (via the cardinality column + custom marker). Not represented: the bodies of custom validators, severity (warning vs error), hidden/readOnly/initialValue, conditional fields, custom inputs.
Separately from validation, the walker surfaces non-blocking modeling-smell warnings (name collisions, reused field names with different shapes, duplicated inline-object shapes, unreferenced object types, dropped edges) into model.warnings for the "Potential Issues" menu — cataloged in warnings.md.
Mermaid classDiagram has no subgraph. The document-vs-object grouping is conveyed implicitly through stereotype fill colour; no direction hint is added.
```mermaid
classDiagram
class Method:::document {
<<document>>
+title: string [1]
+avatar: image [0..1]
+heroImage: HeroImage [0..1]
+overview: PortableText [0..1]
+tags: string [2..5]
+disciplines: Discipline [0..*]
}
class HeroImage:::object {
<<object>>
+asset: url [1]
+alt: string [1]
}
class Discipline:::document {
<<document>>
+title: string [1]
}
Method --> Discipline : disciplines
Method *-- HeroImage : heroImage
classDef document fill:#2276FC,stroke:#7AACFD,color:#fff
classDef object fill:#7B8CA8,stroke:#AFBACA,color:#fff
```The pure modules — probe, walker, emit-mermaid — began life in a content-model CLI that loaded a Studio's schemaTypes/index.ts directly. That CLI had one hard limitation: a direct import is blind to plugin-contributed types (e.g. skosConcept from sanity-plugin-taxonomy-manager), which are registered at plugin-init time. Running the same walker/emit inside Studio against useSchema() surfaces the fully-composed schema — the reason this plugin exists (see ADR 0002).
That CLI has since been retired, so this plugin is now the sole, canonical implementation of the content-model export — its own test suite is authoritative. The vocabulary-mapping contract documented above is the spec (recorded in ADR 0001).