/**
* mdx-lite — a deliberately tiny, dependency-free compiler for a *constrained*
* dialect of MDX, targeting the one thing Display Case's Primer actually needs:
* Markdown prose interleaved with **block-level** JSX specimens, plus real ES
* `import` statements that resolve like any other TypeScript module.
*
* It is NOT a general MDX implementation. It does not parse the combined
* Markdown+JSX grammar that `@mdx-js/mdx` does. Instead it *segments* a document
* into three block kinds and emits a `.tsx` module, then hands the hard parts
* back to the toolchain that already exists:
*
* - **imports** → passed through verbatim; the bundler (Bun) resolves them.
* - **JSX blocks** → passed through verbatim; the TSX compiler handles JSX and
* expression props (e.g. `style={{…}}`) for free — the exact features a
* runtime Markdown renderer cannot do.
* - **markdown runs** → emitted as `{"…"}` using a single
* runtime Markdown component (markdown-to-jsx), the same renderer the doc
* placards use.
*
* The compiled default export is `MDXContent({ components })`, matching the MDX
* calling convention Display Case's primer mount already uses: capitalized tags
* the document does not import (notably ``) resolve from `components`,
* and Markdown headings route to `components.h1` / `components.h2`.
*
* Self-contained on purpose (no imports from the rest of the repo) so it can be
* lifted into its own package later if it proves useful in isolation.
*
* ## The supported dialect (everything else is out of scope and should be
* rejected by callers such as the structure check)
*
* - `import`/`export` statements at column 0 (single- or multi-line).
* - CommonMark + GFM prose, as supported by markdown-to-jsx. Raw HTML is NOT
* rendered (`disableParsingRawHTML`).
* - **Block-level** JSX only: an element that begins a line at column 0 with
* `` fragment, consumed to its matching close.
* - Fenced code blocks are prose, never JSX — even when they contain ``.
*
* Unsupported (by construction): inline JSX inside a prose paragraph, Markdown
* syntax inside JSX children (passed through as literal JSX), and `{expression}`
* interpolation in prose.
*/
export type MdxBlock =
| { kind: 'imports'; code: string }
| { kind: 'markdown'; text: string }
| { kind: 'jsx'; code: string; tags: string[] }
export interface MdxToTsxOptions {
/** Import specifier for the runtime Markdown component. Default markdown-to-jsx. */
markdownSpecifier?: string
}
// ----------------------------------------------------------------------------
// Low-level character scanners. Each takes the full source and a start index,
// and returns the index immediately AFTER the construct it consumed.
// ----------------------------------------------------------------------------
function isWs(ch: string): boolean {
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'
}
function skipWs(s: string, from: number): number {
let p = from
while (p < s.length && isWs(s[p] as string)) p++
return p
}
/** Scan a string literal. `s[from]` is the opening quote (', ", or `). Handles
* escapes and — for templates — nested `${ … }` interpolation. */
export function scanString(s: string, from: number): number {
const quote = s[from]
let p = from + 1
while (p < s.length) {
const c = s[p]
if (c === '\\') {
p += 2
continue
}
if (quote === '`' && c === '$' && s[p + 1] === '{') {
p = scanBraces(s, p + 1)
continue
}
if (c === quote) return p + 1
p++
}
throw new Error('mdx-lite: unterminated string literal')
}
/** Scan a brace group. `s[from]` is `{`. Returns the index after the matching
* `}`. Respects nested braces, string/template literals, and `//` + block
* comments — so `style={{ a: '}' }}`, `{/* } */}` and friends are handled. */
export function scanBraces(s: string, from: number): number {
let p = from + 1 // past the opening {
while (p < s.length) {
const c = s[p] as string
if (c === '{') {
p = scanBraces(s, p)
continue
}
if (c === '}') return p + 1
if (c === '"' || c === "'" || c === '`') {
p = scanString(s, p)
continue
}
if (c === '/' && s[p + 1] === '/') {
const nl = s.indexOf('\n', p)
p = nl === -1 ? s.length : nl
continue
}
if (c === '/' && s[p + 1] === '*') {
const end = s.indexOf('*/', p + 2)
p = end === -1 ? s.length : end + 2
continue
}
p++
}
throw new Error('mdx-lite: unterminated braces')
}
function readName(s: string, from: number): { name: string; next: number } {
let p = from
while (p < s.length && /[\w$.-]/.test(s[p] as string)) p++
return { name: s.slice(from, p), next: p }
}
/** Scan one JSX element. `s[from]` is `<`. Returns the index after the element's
* close. Collects every element/component tag name into `tags`. */
export function scanElement(s: string, from: number, tags: string[]): number {
let p = skipWs(s, from + 1)
// Fragment <> … >
if (s[p] === '>') return scanChildren(s, p + 1, tags)
const { name, next } = readName(s, p)
tags.push(name)
p = next
// Attributes
while (p < s.length) {
p = skipWs(s, p)
if (s[p] === '/' && s[p + 1] === '>') return p + 2
if (s[p] === '>') {
p++
break
}
if (s[p] === '{') {
// spread attribute {...x}
p = scanBraces(s, p)
continue
}
// attribute name
while (p < s.length && !/[\s=/>]/.test(s[p] as string)) p++
p = skipWs(s, p)
if (s[p] === '=') {
p = skipWs(s, p + 1)
if (s[p] === '{') p = scanBraces(s, p)
else if (s[p] === '"' || s[p] === "'") p = scanString(s, p)
else while (p < s.length && !/[\s>]/.test(s[p] as string)) p++
}
}
return scanChildren(s, p, tags)
}
/** Scan element children starting after the open tag's `>`. Returns the index
* after the matching close tag (`…>` or `>`). */
function scanChildren(s: string, from: number, tags: string[]): number {
let p = from
while (p < s.length) {
const c = s[p]
if (c === '<') {
if (s[p + 1] === '/') {
// closing tag — consume through '>'
p += 2
while (p < s.length && s[p] !== '>') p++
return p + 1
}
p = scanElement(s, p, tags)
continue
}
if (c === '{') {
p = scanBraces(s, p)
continue
}
p++ // text node
}
throw new Error('mdx-lite: unterminated JSX element')
}
// ----------------------------------------------------------------------------
// Segmentation
// ----------------------------------------------------------------------------
const FENCE = /^\s*(```|~~~)/
const IMPORT_EXPORT = /^(import|export)\b/
const JSX_BLOCK_START = /^<([A-Z]|>)/
/** Count `{` minus `}` outside strings/comments — used to tell whether a
* (possibly multi-line) import/export statement is complete. */
function braceBalance(text: string): number {
let depth = 0
let i = 0
while (i < text.length) {
const c = text[i] as string
if (c === '"' || c === "'" || c === '`') {
i = scanString(text, i)
continue
}
if (c === '/' && text[i + 1] === '/') {
const nl = text.indexOf('\n', i)
i = nl === -1 ? text.length : nl
continue
}
if (c === '/' && text[i + 1] === '*') {
const end = text.indexOf('*/', i + 2)
i = end === -1 ? text.length : end + 2
continue
}
if (c === '{') depth++
else if (c === '}') depth--
i++
}
return depth
}
/** Segment an mdx-lite document into imports / markdown / jsx blocks. */
export function segmentMdx(source: string): MdxBlock[] {
const lines = source.replace(/\r\n/g, '\n').split('\n')
const blocks: MdxBlock[] = []
let md: string[] = []
let inFence = false
let fence = ''
const flushMd = (): void => {
const text = md.join('\n').replace(/^\n+/, '').replace(/\n+$/, '')
if (text.trim() !== '') blocks.push({ kind: 'markdown', text })
md = []
}
let li = 0
while (li < lines.length) {
const line = lines[li] as string
if (inFence) {
md.push(line)
if (FENCE.test(line) && line.trim().startsWith(fence)) inFence = false
li++
continue
}
const fenceMatch = FENCE.exec(line)
if (fenceMatch) {
inFence = true
fence = fenceMatch[1] as string
md.push(line)
li++
continue
}
if (IMPORT_EXPORT.test(line)) {
flushMd()
const start = li
let stmt = line
li++
while (braceBalance(stmt) > 0 && li < lines.length) {
stmt += `\n${lines[li]}`
li++
}
// greedily absorb a run of further import/export statements
const codeLines = lines.slice(start, li)
while (li < lines.length && IMPORT_EXPORT.test(lines[li] as string)) {
let next = lines[li] as string
codeLines.push(next)
li++
while (braceBalance(next) > 0 && li < lines.length) {
next += `\n${lines[li]}`
codeLines.push(lines[li] as string)
li++
}
}
blocks.push({ kind: 'imports', code: codeLines.join('\n') })
continue
}
if (JSX_BLOCK_START.test(line)) {
flushMd()
const rest = lines.slice(li).join('\n')
const tags: string[] = []
const end = scanElement(rest, 0, tags)
const consumed = rest.slice(0, end)
const nLines = consumed.split('\n').length
const code = lines.slice(li, li + nLines).join('\n')
blocks.push({ kind: 'jsx', code, tags })
li += nLines
continue
}
md.push(line)
li++
}
flushMd()
return blocks
}
// ----------------------------------------------------------------------------
// Compilation to TSX
// ----------------------------------------------------------------------------
/** Extract the local binding names introduced by import/export statements. */
export function extractBoundNames(code: string): Set {
const names = new Set()
// namespace: import * as N from …
for (const m of code.matchAll(/import\s+\*\s+as\s+([A-Za-z_$][\w$]*)/g))
names.add(m[1] as string)
// default: import Name from … / import Name, { … } from …
for (const m of code.matchAll(/import\s+([A-Za-z_$][\w$]*)\s*(?:,|from)/g))
names.add(m[1] as string)
// named: import … { a, b as c } from …
for (const m of code.matchAll(/import[^{]*\{([^}]*)\}/g)) {
for (const part of (m[1] as string).split(',')) {
const seg = part.trim()
if (!seg) continue
const as = seg.split(/\s+as\s+/)
names.add((as[1] ?? as[0] ?? '').trim())
}
}
// export const/let/var/function/class Name
for (const m of code.matchAll(
/export\s+(?:const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/g,
))
names.add(m[1] as string)
names.delete('')
return names
}
const indent = (text: string, pad: string): string =>
text
.split('\n')
.map((l) => (l === '' ? l : pad + l))
.join('\n')
/** Compile an mdx-lite document to a `.tsx` module source string. */
export function mdxToTsx(source: string, opts: MdxToTsxOptions = {}): string {
const spec = opts.markdownSpecifier ?? 'markdown-to-jsx'
const blocks = segmentMdx(source)
const importsCode = blocks
.filter(
(b): b is Extract => b.kind === 'imports',
)
.map((b) => b.code)
.join('\n')
const bound = extractBoundNames(importsCode)
// Component tags used by JSX blocks but not imported → resolved from props.
const used = new Set()
for (const b of blocks) {
if (b.kind !== 'jsx') continue
for (const t of b.tags) used.add(t)
}
const external = [...used].filter(
(n) => /^[A-Z][\w]*$/.test(n) && !bound.has(n),
)
const body = blocks
.map((b) => {
if (b.kind === 'imports') return ''
if (b.kind === 'markdown')
return `<__Md options={__mdOpts}>{${JSON.stringify(b.text)}}`
return b.code
})
.filter((x) => x !== '')
const destructure =
external.length > 0
? ` const { ${external.join(', ')} } = __components\n`
: ''
return `// AUTO-GENERATED by display-case mdx-lite — do not edit.
import __Md from ${JSON.stringify(spec)}
${importsCode}
export default function MDXContent(props) {
const __components = (props && props.components) || {}
${destructure} const { h1: __h1, h2: __h2 } = __components
const __ov = {}
if (__h1) __ov.h1 = __h1
if (__h2) __ov.h2 = __h2
const __mdOpts = { disableParsingRawHTML: true, overrides: __ov }
return (
<>
${body.map((x) => indent(x, ' ')).join('\n')}
>
)
}
`
}