Every post on this blog is written in MDX, and the single most Webeleon-specific
thing in that pipeline is how a chord symbol gets from my keyboard to your
screen. A chord is not plain text. When I write G7
in a sentence, it carries a typeface, a harmonic function, and a colour — none
of which survive if I just type the letters "G7" inline. This is the build note
for how one tag becomes a styled, accessible, theme-aware chord: the <Chord>
component, the way I colour chords by their harmonic role, the lazy-loaded
notation engine, and why I never ship any of this as an image.
The authoring surface is a single tag
When I want a chord in prose, I write one thing and nothing else:
A <Chord fn="dominant">G7</Chord> resolving home.
That is the entire authoring contract. There is no wrapper markup, no class to
remember, no inline style. The fn prop is a small, closed union — tonic,
subdominant, or dominant — so a typo like fn="dominent" fails the
type-check instead of rendering a mystery colour. I would rather the build break
than a chord ship in the wrong function.
Under the hood, <Chord> is thin. It wraps a shared MonoToken atom that owns
the Spline Sans Mono look, adds the optional function colour as a CSS-module
class, and stamps a data-chord marker so tests and downstream tooling can find
a chord without depending on a hashed class name. Its children render as plain
React text — there is no dangerouslySetInnerHTML anywhere near it, so anything
inside the tag is escaped, never parsed as HTML. A chord symbol is content, not
a place to inject markup.
Colour is the harmonic function, not paint
The fn prop exists because a chord's job matters more than its letters. I tint
every chord by its role in the harmony, using the same three brand tokens the
apps use:
- C is the tonic — home, resolution — and wears gold.
- F is the subdominant — movement away — and wears teal.
- G7 is the dominant — tension, drive — and wears rust.
That mapping is not a per-post decision. It is one entry in a token file, so a chord written in a theory post from a year ago looks identical to one written today, and both match the accent a reader sees inside the apps. It is deliberately two axes over one palette: inside an app the triad encodes harmonic function; at brand level the same three hues identify pillars and apps. A chord symbol on this blog is a small, honest instance of that system. If you want to see the colours doing real analytical work rather than sitting still as examples, the ii–V–I post is built almost entirely out of them.
A component map, not hand-written HTML
None of the brand styling lives in the MDX. It lives in a single component map
that every element and custom tag resolves through at compile time. Markdown
headings keep the id that rehype-slug injects, so in-page anchors still
resolve; links are hardened; and Chord, Roman, Notation, and the rest are
exposed by name so authors use them in .mdx without ever writing an import.
Mapping components instead of hand-writing HTML is the whole reason consistency is free. An author describes intent — "this is a dominant chord" — and the renderer owns exactly how that looks. There is no styling surface in the content layer for anyone to get wrong, including me at 11pm.
There is one MDX v3 nuance worth stating plainly, because it drives the next
section. Raw HTML in an MDX body is parsed as JSX, not as sanitisable HTML,
and a raw intrinsic tag like <script> compiles to a literal string tag that
bypasses the component map entirely. So the map is the right place for
styling and for hardening the tags I do render, but it cannot be the place I stop
dangerous tags — a map override never runs for a tag that never consults the map.
The safety pass runs before React sees the tree
Because the post body is author-controlled and the same render helper feeds both
the article page and the RSS/JSON feeds, it runs through a small rehype plugin
before anything is rendered. Walking the hast tree, it removes dangerous elements
outright — <script>, <iframe>, <object>, <embed>, <style> — strips
every on* event-handler attribute, and drops any href or src that uses an
unsafe scheme such as javascript:. It sweeps both node shapes MDX can produce:
ordinary hast elements from Markdown, and the JSX nodes from raw HTML in the body.
Stripping the node from the tree is the only thing that works, precisely because
of the compile nuance above: by the time MDX emits _jsx("script", …) it is too
late for the map. Do the removal earlier, on the tree, and the tag is simply
never emitted. The link override is defence in depth on top of that — external
links still get rel="noopener noreferrer nofollow" and open in a new tab. The
brand components and the heading ids are left untouched; the pass only removes
what has no business rendering.
Notation, without shipping a picture
Chords are inline text. A full staff is not, and this is where authoring in MDX
earns its keep. A <Notation> block takes ABC source and renders a real staff:
The notes of a C major chord, low to high: C, E, G, C.
The engine that draws that staff, abcjs, is about half a megabyte unminified. It
is never in the initial bundle. It is pulled in with a dynamic import('abcjs')
inside an effect, so the bundler splits it into its own async chunk that only
downloads on pages that actually mount a <Notation>. A reader landing on a
prose-only post never pays for it.
The caption and the text fallback are rendered by React, not by abcjs, so they
exist in the server HTML. They stay visible until a staff has genuinely been
drawn, which means no-JS readers, pre-hydration readers, search crawlers, and
anyone whose engine fails to load all still get the music as words. The staff
target is exposed as role="img" with the caption as its accessible name, so a
screen reader announces one labelled image instead of a tangle of SVG paths. And
because abcjs fills with currentColor inside an always-light paper island, the
notation resolves to the pinned paper ink in both the light and dark reading
canvases — it never washes out when the page flips to dark.
Why MDX beats an image every time
The tempting shortcut for all of this is to draw each chord and staff once, export a PNG, and drop the image into the post. I have never regretted refusing to.
Text is searchable and indexable — a chord symbol in a sentence is content a crawler and a reader can both parse. It is diffable, so a chord change is one character in a git diff instead of a re-exported binary. It is accessible by default, because it is real text and real ARIA rather than pixels a screen reader has to guess at. And it is theme-aware for free: the colour of a chord lives in a token, so when I moved this whole site onto the Real Book palette, every chord in every post recoloured itself without my touching a single article. An image would have meant re-exporting every asset by hand, and the alt text would have drifted out of date the first time I edited the prose around it.
That is the same instinct that runs through the apps: the colour of a chord is supposed to mean something, and meaning should be authored once and reused, not baked into pixels. If you want to hear the functions these colours stand for — that gold C pulling a rust G7 home — that pull is exactly what I built Homechord to let you sketch and play.