DEV Community

Cover image for MdBin Levels Up Again: CJK Support, RTL Text, and a CodeMirror Editor
Sivaram
Sivaram

Posted on

MdBin Levels Up Again: CJK Support, RTL Text, and a CodeMirror Editor

The Roadmap Item I Skipped Ahead To

Last time I listed the roadmap: expiry options, edit links, a syntax-aware editor, paste forking.

I did the editor. But that's not the interesting part of this post.

The interesting part is what I found on the way there — a set of bugs that had been quietly breaking MdBin for anyone not writing in English. One of them could permanently lock people out of their own encrypted pastes.

Let's start with that one.

Bug 1: The Password That Was Right, But Wrong

Here's a fun question. Is café equal to café?

Depends. Unicode lets you write é two ways:

  • NFC (composed): one code point, U+00E9
  • NFD (decomposed): two code points, e + U+0301 (combining acute accent)

They look identical. They render identically. === says they're different strings.

Now remember how my encryption works:

const encoder = new TextEncoder()
const passwordBuffer = encoder.encode(password)
// ...derive a key from those bytes
Enter fullscreen mode Exit fullscreen mode

Different bytes → different key → decryption fails.

I reproduced it against my actual crypto parameters:

café (NFC) encrypts → decrypt with café (NFD): FAILS "wrong password"
한국어pass — NFC is 13 bytes, NFD is 28 bytes
Enter fullscreen mode Exit fullscreen mode

Fourteen bytes of difference for a password that looks the same in every font on earth.

This isn't theoretical. macOS routinely produces NFD. So: type an accented password on a Mac, save it to a password manager that normalises to NFC, come back later, and you're locked out of a paste that has no recovery path by design.

The Fix

Normalise before deriving:

const PASSWORD_FORM = 'NFC'

// on encrypt
const key = await deriveKey(password.normalize(PASSWORD_FORM), salt)
Enter fullscreen mode Exit fullscreen mode

Decryption needs more care, because pastes encrypted before this fix used whatever bytes the browser happened to hand over. So decrypt tries both:

const normalized = password.normalize(PASSWORD_FORM)
const candidates =
  normalized === password ? [normalized] : [normalized, password]

for (const candidate of candidates) {
  const key = await deriveKey(candidate, salt)
  try {
    const plaintext = await crypto.subtle.decrypt(
      { name: 'AES-GCM', iv }, key, ciphertext
    )
    return decoder.decode(plaintext)
  } catch {
    // AES-GCM authenticates, so a failure means this isn't the key.
  }
}
throw new Error('Decryption failed')
Enter fullscreen mode Exit fullscreen mode

For an ASCII password the two forms are identical, so the fallback is skipped entirely — a wrong password still costs exactly one PBKDF2 run, not two. That matters when each run is 310,000 iterations.

If you do browser crypto with user-supplied passwords, go and check this right now. Encryption shipped here in February. This bug was live for six months, and I only found it because I went looking at Unicode for an unrelated reason.

Bug 2: Bold Text That Refuses To Be Bold

Try this in almost any markdown renderer:

**重要な変更(破壊的)。**必ずお読みください。
Enter fullscreen mode Exit fullscreen mode

You get literal asterisks. Not bold.

This is a known CommonMark limitation. The spec's emphasis rules use "left-flanking" and "right-flanking" delimiter runs, and they classify ideographic punctuation — 。()、!? — as punctuation in a way that makes the closing ** fail to close.

I tested it against the real parser before writing a word about it:

Input Without plugin With plugin
**重要な変更(破壊的)。**必ず… literal ** bold
~~削除された機能(v2 以降)。~~ literal ~~ strikethrough
**한국어 구문(괄호 포함)**을 literal ** bold
请访问 https://x.com,然后继续。 href includes ,然后继续。 href is the URL

That last row is the nastiest, because it fails silently. The autolink swallows the trailing punctuation and the rest of the sentence into the URL. You get a link that looks fine and 404s when clicked.

Note the Korean row too: no CJK punctuation anywhere, just ) followed immediately by . Still breaks.

The Fix

Vercel ships a plugin for exactly this:

bun add @streamdown/cjk
Enter fullscreen mode Exit fullscreen mode
import { cjk } from '@streamdown/cjk'

<Streamdown plugins={{ code, mermaid, math, cjk }}>
  {content}
</Streamdown>
Enter fullscreen mode Exit fullscreen mode

It's parser-level correctness, not a feature, so I applied it to every renderer — paste pages, encrypted pages, and all the preview components. There's no situation where I want emphasis to break.

Bug 3: Arabic Rendered Left-Aligned

No dir attribute anywhere in the app. So Arabic and Hebrew pastes rendered left-aligned with bullets on the wrong side.

The fix is one attribute:

<div dir="auto">
  <Streamdown className="markdown-content">{content}</Streamdown>
</div>
Enter fullscreen mode Exit fullscreen mode

dir="auto" takes direction from the first strong directional character. A paste that starts with Latin text is completely unaffected.

It has to sit on a wrapper, by the way — Streamdown doesn't forward HTML attributes to its root element. I passed dir directly first and spent a few minutes wondering why nothing changed.

The Part I Didn't Expect

Setting direction wasn't enough, because my CSS used physical properties:

.markdown-content ul { padding-left: 2em; }
.markdown-content blockquote { border-left: 0.25em solid var(--border); }
Enter fullscreen mode Exit fullscreen mode

In RTL those stay pinned to the left. Logical properties follow the text:

.markdown-content ul { padding-inline-start: 2em; }
Enter fullscreen mode Exit fullscreen mode

But the blockquote gave me two borders — one on each side. Streamdown puts its own physical border-l on blockquotes. Mine moved to the right in RTL; theirs stayed left.

.markdown-content blockquote {
  border-width: 0;            /* reset all four first */
  border-inline-start-width: 0.25em;
  border-inline-start-style: solid;
  border-inline-start-color: var(--border);
}
Enter fullscreen mode Exit fullscreen mode

The Editor: Why Not Monaco

Now the roadmap item. I said "CodeMirror or Monaco". Here's why it wasn't close.

I checked the actual unpacked sizes rather than guessing:

Package Unpacked
monaco-editor@0.56.0 97.9 MB
CodeMirror 6 core (state + view + lang-markdown) ~1.8 MB

Unpacked isn't shipped size — Monaco's figure includes every language grammar and three build variants. But it holds up after bundling, because Monaco's language services are designed to load wholesale into web workers and resist tree-shaking.

Monaco's entire value proposition is language intelligence. A paste form has no use for a TypeScript language service. And Monaco's touch support is poor, which is disqualifying for a tool people hit from their phone.

I picked extensions by hand rather than using basicSetup, which drags in autocomplete, search and linting:

const extensions = [
  lineNumbers(),
  highlightActiveLine(),
  bracketMatching(),
  history(),
  // markdownKeymap carries list continuation — Enter inside a
  // list keeps the bullet instead of breaking out of it
  keymap.of([...markdownKeymap, ...defaultKeymap, ...historyKeymap]),
  markdown(),
  syntaxHighlighting(highlightStyle),
  EditorView.lineWrapping,
]
Enter fullscreen mode Exit fullscreen mode

Final cost: 536 KB raw, 180 KB gzipped, 153 KB brotli — in its own chunk. I confirmed the homepage's initial entry doesn't reference it, so the initial load is unchanged.

Theming Through CSS Variables

Nice trick: because my palette is already CSS custom properties, the editor theme follows light/dark mode with no second theme and no re-render.

const theme = EditorView.theme({
  '&': { color: 'var(--foreground)', backgroundColor: 'transparent' },
  '.cm-gutters': { color: 'var(--muted-foreground)' },
  '.cm-cursor': { borderLeftColor: 'var(--foreground)' },
})
Enter fullscreen mode Exit fullscreen mode

var() resolves against the element, so .dark on <html> flips everything underneath it automatically.

For syntax colours I converted the github-light and github-dark Shiki palettes to oklch and added them as tokens:

--md-heading: oklch(0.4949 0.1801 257.6);  /* #005CC5 */
--md-marker:  oklch(0.6482 0.18 45.87);    /* #E36209 */
--md-code:    oklch(0.5879 0.1927 20.47);  /* #D73A49 */
Enter fullscreen mode Exit fullscreen mode

Those are the same colours the preview tab renders code with, so switching tabs feels like one product instead of two.

Autosave, With One Hard Rule

People type in a real editor now instead of pasting and leaving. So a closed tab shouldn't cost them a document.

Straightforward — debounced write to localStorage, restore on mount. But there's a rule I wasn't willing to break:

useEffect(() => {
  if (isEncrypted || !autosave) return   // <- this line
  const id = setTimeout(() => {
    localStorage.setItem(DRAFT_KEY, content)
  }, 500)
  return () => clearTimeout(id)
}, [content, isEncrypted, autosave])
Enter fullscreen mode Exit fullscreen mode

An encrypted paste exists precisely so the plaintext lives nowhere but your screen. Quietly leaving a copy in localStorage — on what might be a shared machine — would defeat the entire feature.

And because you might type the content first and only then decide it's sensitive, switching to Encrypted deletes anything already saved:

useEffect(() => {
  if (!isEncrypted && autosave) return
  localStorage.removeItem(DRAFT_KEY)
  setSavedAt(null)
}, [isEncrypted, autosave])
Enter fullscreen mode Exit fullscreen mode

There's a toggle with a live status ("Draft saved 12s ago") and a Clear button that empties both editor and storage. Clear asks once before doing it — it's discarding work with no other copy.

Bug 4: The Print Stylesheet That Deleted Every Link

Markdown is a document format, so "Save as PDF" should produce a document. I wrote a print stylesheet: strip the interface, force the light palette (a dark page prints grey-on-grey and drinks ink), unwrap horizontal scroll, keep blocks from splitting across pages.

Then I printed a real paste and the list items came out as empty bullets. Inline links vanished mid-sentence, leaving hosted on . where a name had been.

The culprit:

@media print {
  button { display: none !important; }   /* seemed reasonable */
}
Enter fullscreen mode Exit fullscreen mode

Streamdown renders every markdown link as a <button>, not an <a>. So hiding buttons hid every link on the page. On a document that's mostly a link index, that's most of the content.

button:not([data-streamdown='link']) { display: none !important; }

[data-streamdown='link'] {
  display: inline !important;
  color: inherit !important;
  text-decoration: underline;
}
Enter fullscreen mode Exit fullscreen mode

I verified it rather than eyeballing: of 363 buttons on that page, 41 are links, and none of them now match the hiding selector.

One honest limitation: I'd also written a rule to print link targets after each link. It never worked and never could — these are buttons with no href, and the destination lives in JavaScript. So a printed link shows its text but not where it pointed. I removed the dead rule and said so on the page.

Bug 5: The Theme Toggle That Flashed

Small one, but it bugged me. On dark mode you'd briefly see a sun icon before it corrected itself to a moon.

next-themes sets .dark on <html> before paint, so the background never flashed. The problem was my own component:

if (!mounted) {
  return <button><Sun className="w-5 h-5" /></button>   // always a sun!
}
Enter fullscreen mode Exit fullscreen mode

The active choice is in localStorage, so the server can't know it. And .dark on <html> only tells you the resolved theme — not whether the user picked it or is on system, which is my default and therefore the common case.

Cookies would let the server know. But every page here is statically generated, and calling cookies() opts a route into dynamic rendering. Trading full-page CDN caching on 25 static pages for an icon is a bad deal.

So: an inline script in <head>, running before the body is parsed.

<script>
  try {
    document.documentElement.setAttribute(
      'data-theme-choice',
      localStorage.getItem('theme') || 'system'
    )
  } catch (e) {
    document.documentElement.setAttribute('data-theme-choice', 'system')
  }
</script>
Enter fullscreen mode Exit fullscreen mode

Then all three icons ship in the markup and CSS picks one:

.theme-icon { display: none; }

[data-theme-choice='light'] .theme-icon-light,
[data-theme-choice='dark']  .theme-icon-dark,
[data-theme-choice='system'] .theme-icon-system,
html:not([data-theme-choice]) .theme-icon-system {
  display: block;
}
Enter fullscreen mode Exit fullscreen mode

Correct icon in the first frame, no JavaScript in the critical path, everything stays static. The last selector handles JS being disabled, where "whatever the system prefers" is exactly right.

Dead Code I Found Along The Way

While auditing my Streamdown overrides I found ~60 lines of CSS matching zero elements. Streamdown 2.x stopped emitting class="shiki" on <pre> and now colours tokens itself with a --sdm-c custom property per span.

Every pre.shiki rule was dead. Including this one:

.markdown-content pre.shiki span {
  color: var(--shiki-light);   /* not a variable Streamdown defines */
}
Enter fullscreen mode Exit fullscreen mode

Had that selector still matched, it would have out-specified Streamdown's own token class and collapsed every token to one colour — which is exactly the "syntax highlighting looks broken" symptom I'd chased on production a while back. It wasn't the cause (the selector was already dead), but it was a loaded gun sitting in the stylesheet.

Check your overrides after a major dependency bump. Mine had been dead for a version and a half.

Five New Pages

Since I'd learned all this the hard way, I wrote it down:

  • /cjk-markdown — every emphasis and autolink failure in Chinese, Japanese and Korean
  • /multilingual-markdown — RTL, the full-width trap, emoji, size limits
  • /markdown-line-breaks — why pressing Enter does nothing, and the four things that work
  • /markdown-code-blocks — fences, language aliases, nesting, diffs
  • /readme-templates — four templates, copyable or openable directly in the editor

Two things I learned writing them:

The size limit is characters, not bytes. content.length counts UTF-16 code units, so my "100KB max" label was wrong — and wrong in the direction that undersold the product:

Script Bytes/char 100,000 characters is
English 1 ~98 KB
Russian, Greek, Arabic 2 ~195 KB
Chinese, Japanese, Korean 3 ~293 KB

The full-width trap. An IME will happily give you instead of #, or a full-width space after a correct #. Both silently produce a paragraph instead of a heading:

# これは見出しになりません   ← full-width hash, renders as text
# これは見出しです           ← ASCII hash, renders as a heading
Enter fullscreen mode Exit fullscreen mode

The hardest version is a full-width space after a correct #, because the line looks completely right.

What I Gained

Before After
CJK bold/italic/strikethrough ❌ Renders literal ** ✅ Renders correctly
CJK autolinks ❌ Swallows the sentence ✅ Cuts at punctuation
Arabic / Hebrew ❌ Left-aligned dir="auto" + logical CSS
Non-ASCII passwords ❌ Could lock you out ✅ NFC-normalised, backwards compatible
Input Plain textarea CodeMirror 6, lazy-loaded
Preview ❌ None ✅ Same renderer as the paste page
Draft safety ❌ Close the tab, lose it ✅ Autosave (never when encrypted)
Print / PDF ❌ Printed the whole website ✅ Prints as a document
Theme toggle ❌ Flashed the wrong icon ✅ Correct in the first frame

Try It Out

Go to mdbin.sivaramp.com and paste this:

**重要な変更(破壊的)。**必ずお読みください。

请访问 https://mdbin.sivaramp.com,然后继续。
Enter fullscreen mode Exit fullscreen mode

Both should render properly. Paste the same thing into most other markdown tools and compare.

Then hit ⌘+Enter to share it, and Print on the resulting page to get a clean PDF.

What's Next

Same list as last time, minus the editor:

  • Expiry options — and then burn-after-reading, which pairs well with the encryption
  • Per-paste OG images — the whole product is "share a link", and every link currently previews identically
  • Heading anchors and a TOC — Streamdown doesn't generate heading IDs at all, so you can't link to a section
  • Edit links with a secret token

TL;DR: Added CJK support (@streamdown/cjk), RTL support (dir="auto" + logical CSS properties), and fixed a Unicode normalisation bug where a non-ASCII password could permanently lock you out of your own encrypted paste — café in NFC and NFD derive different keys. Replaced the textarea with CodeMirror 6 (180 KB gzipped, lazy-loaded; Monaco is 97.9 MB unpacked and can't tree-shake). Added live preview, draft autosave that refuses to run in encrypted mode, ⌘+Enter, and print styles — where button { display: none } deleted every link, because Streamdown renders links as buttons. Plus five new guide pages.

If you write markdown in a non-Latin script, I'd genuinely like to know what still breaks: mdbin.sivaramp.com

Top comments (0)