← ui

markdown

ui

markdown drawn as a page, on the server or in the browser, the editor's look too

npx shadcn add @ag/markdown
Already installed? Preview an update
npx shadcn add @ag/markdown --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-12
  • Changed: a code block in a rendered body scrolls inside code's frame with code's fade (ag-edge-fade-xy).
Updated 2026-10-12 · release notes

also called prose, typography

shelf text & code

uses scrims, code, quote, youtube

used by editor

the demo above, verbatim

markdown.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import {
  postScript,
  type TelegramPost,
} from "@/registry/base-nova/lib/telegram-chat-post"
import type { Tweet as TweetFacts } from "@/registry/base-nova/lib/tweet-data"
import { Markdown } from "@/registry/base-nova/ui/markdown"
import { TelegramChat } from "@/registry/base-nova/ui/telegram-chat"
import { Tweet } from "@/registry/base-nova/ui/tweet"

// #region embeds
// Links alone on their lines. The post on X and the Telegram post are drawn from facts
// kept beside the page (`mise tweet <url>`, `mise telegram-post <url>`); the video needs
// none.
const LINKS = `A note on decision models, as it went around:

https://x.com/Mappletons/status/2101560333441610133

And the older idea it echoes:

https://t.me/jardindigital/236

https://youtu.be/aqz-KE-bpKQ
`

const POSTS: Record<string, TweetFacts> = {
  "https://x.com/Mappletons/status/2101560333441610133": {
    id: "2101560333441610133",
    url: "https://x.com/Mappletons/status/2101560333441610133",
    author: {
      name: "Maggie Appleton",
      handle: "@Mappletons",
      avatar:
        "https://pbs.twimg.com/profile_images/1635665509512355840/-jFo36KR_200x200.jpg",
    },
    date: "2026-09-20T06:33:23.000Z",
    body: [
      {
        text: 'Can we agree to call Jev a "Decision Model"?\nI feel like they buried this in the explainations and docs.\nLanguage models output language. Decision models output decisions.',
      },
    ],
    media: [
      {
        kind: "photo",
        src: "https://pbs.twimg.com/media/HSo9u6-XUAA8PhA.jpg",
        width: 1638,
        height: 638,
      },
      {
        kind: "photo",
        src: "https://pbs.twimg.com/media/HSo9z9FW8AAj0zC.jpg",
        width: 1632,
        height: 428,
      },
    ],
    likes: 304,
    replies: 48,
  },
}

const CHANNEL_POST: TelegramPost = {
  url: "https://t.me/jardindigital/236",
  channel: {
    name: "Jardin Digital",
    handle: "@jardindigital",
    avatar: "/telegram-jardindigital.jpg",
  },
  date: "2024-07-22T22:27:05+00:00",
  edited: true,
  views: "59",
  photos: [{ src: "/telegram-post-236.jpg", width: 800, height: 121 }],
  body: [
    { text: "💡" },
    { text: "AI as an API", marks: ["bold"] },
    { text: " to accelerate development" },
    { text: ".", marks: ["bold"] },
    {
      text: "\n\nInstead of using AI to help you code an API:\n\n[USER NEED] -> AI as API -> Validation -> Refine for cost/quality.\n",
    },
  ],
}
// #endregion

// #region page
// A page in markdown, every element the renderer draws: the front matter is not shown,
// the quote names who said it on its last line, a YouTube link alone on its line is the
// video, the reference link resolves, the footnote gathers at the foot.
const PAGE = `---
title: the slow road
---

# The slow road

We left before the sun was up, and the road ran along the water the whole way. **One lens, no gimbal**, and a rule: *stop when the light is good*, never when the map says so.

## What we carried

- a 35mm prime, and nothing wider
- two batteries, both charged the night before
  - the third one stayed in the car
- ~~a tripod~~ the wall, the roof of the car, a friend's shoulder

1. salt the water like the sea
2. warm the garlic in the oil, never brown
3. toss the pasta with a ladle of its water

> The best camera is the one that's with you.
>
> — Chase Jarvis, *The Best Camera Is the One That's With You*

### The settings

Shot in \`S-Log3\`, graded later; the one command that pulled stills from the takes:

\`\`\`sh
ffmpeg -i bay.mov -vf fps=3 frames/%03d.jpg
\`\`\`

The short we watched the night before, for the colour:

https://www.youtube.com/watch?v=aqz-KE-bpKQ

| shot | lens | time |
|------|------|------|
| the bay, wide | 24mm | golden hour |
| hands on the map | 35mm | morning |
| the pass at dusk | 35mm | dusk |

- [x] charge the batteries
- [ ] back up the cards

---

More notes are on [the trip's page][trip], and the light was better than the forecast.[^light]

[trip]: https://example.com/trip
[^light]: It nearly always is, on the coast.
`
// #endregion

export default function MarkdownDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="page"
        title="a page"
        description="h1 to h3 in the serif voice, links underlined by a hairline that turns to a highlighter's stroke under the hand, code highlighted in the browser, a task list, a footnote at the foot."
        with="page"
      >
        <Markdown className="mx-auto max-w-[42rem]">{PAGE}</Markdown>
      </Sample>
      <Sample
        name="small"
        title="the same page, small"
        description="every size is in em: set the body small (a file's face in a gallery) and the page shrinks whole, nothing reflowed."
        with="page"
      >
        <div className="h-80 w-64 overflow-hidden rounded-lg bg-foreground/4">
          <Markdown className="px-3.5 py-3 text-[10px]">{PAGE}</Markdown>
        </div>
      </Sample>
      <Sample
        name="embeds"
        title="links that are things"
        description="A link alone on its line is handed to embeds: return what to draw from the facts you keep (a post on X through tweet, a Telegram post through telegram-chat), or nothing to leave it a link. YouTube needs no facts and is drawn by default."
        with="embeds"
      >
        <Markdown
          className="max-w-[36rem]"
          embeds={(url) => {
            const post = POSTS[url]
            if (post) return <Tweet tweet={post} />
            if (url === CHANNEL_POST.url)
              return (
                <TelegramChat
                  script={postScript(CHANNEL_POST, {
                    timeZone: "Europe/Madrid",
                  })}
                  href={CHANNEL_POST.url}
                  frame="none"
                  composer={false}
                  frozen
                  backdrop
                  text="page"
                  className="w-full"
                />
              )
          }}
        >
          {LINKS}
        </Markdown>
      </Sample>
      <Sample
        name="yours"
        glance
        title="in a site's own voice"
        description="--ag-markdown-link gives links the site's accent (the hairline and the hover wash follow it); any rule is overridden by a class of the same kind, here a smaller body and no rule under the h2; components draws a tag your way, here inline code as a pill."
        with="note"
      >
        <Markdown
          className="max-w-[36rem] text-sm [--ag-markdown-link:oklch(0.72_0.12_150)] [&_h2]:border-0"
          components={{
            code: ({ children }) => (
              <code className="rounded-full bg-foreground/8 px-2 py-0.5 font-mono text-[0.85em]">
                {children}
              </code>
            ),
          }}
        >
          {NOTE}
        </Markdown>
      </Sample>
    </div>
  )
}

// #region note
const NOTE = `## A note in a green site

The [route](https://example.com/route) leaves at \`06:40\`; the [ferry](https://example.com/ferry) waits for nobody.
`
// #endregion

the source, every file an install writes

markdown.tsx
// MARKDOWN, READ: a markdown text drawn as a page. The parse is lib/markdown-core's (the
// same one the editor uses), the tree is drawn as plain tags, and the look is one set
// of rules on those tags (`MARKDOWN`), so anything that draws the same tags wears it:
// this renderer, and the editor's document, which then reads as it will be read.
//
// IT RENDERS ANYWHERE: a server component (the text parsed there, no parser shipped
// to the browser, `components` and `transform` free to be server functions) or inside
// a client one. Only a code block is the browser's (markdown-code's `Highlighted`).
//
// THE LOOK (Adrian's site, adriangalilea.com, is the reference): body text a step
// below the headings; h1 to h3 in the serif voice, regular weight, tight tracking, the
// h2 over a hairline; links underlined by a hairline of their own colour that turns to
// a highlighter's stroke under the hand; inline code on a quiet chip; code blocks in
// code's frame, highlighted in the browser with code's themes; quotes in the quote
// voice; tables with small uppercase heads and hairline rows; rules with air around
// them. Every size is in em, so a body set small (a file's face in a gallery) is the
// same page made small, never a page reflowed.
//
// SAFE BY CONSTRUCTION. Nothing is set as HTML except Shiki's own escaped markup of a
// code block: raw HTML in the text is shown as text, front matter and link definitions
// are not shown. A link is live only to the web, mail, a phone, the page or the site,
// an image loads only from the web or the site (`safeHref`, `safeSrc`): a
// `javascript:` or `data:` URL is its words, never a link. So text anyone wrote is safe
// to draw. Reference links resolve through their definitions; footnotes gather at the
// foot.
//
// IT COMPOSES. `components` replaces how a tag is drawn (a link through a router, an
// image through the image item or only from the host's CDN, inline code as keys when
// it spells a shortcut), the way MDX's components do; `transform` changes the tree
// before it is drawn (`mapText` autolinks a ticket number or a mention, never inside
// code or a link). The look is overridden where it is worn, three ways, coarse to
// fine: `--ag-markdown-link` gives links a colour (a site's accent) without touching
// anything else; `className` overrides any rule, a later class of the same kind
// winning (`text-sm`, `[&_p]:my-0`, `[&_h2]:border-0`); and `MARKDOWN` is exported for
// a host that draws the tags itself (the editor).

import { Check } from "lucide-react"
import type * as M from "mdast"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
  parseMarkdown,
  safeHref,
  safeSrc,
  slugOf,
} from "@/registry/base-nova/lib/markdown-core"
import { MARK_BOX, MARK_PATH } from "@/registry/base-nova/lib/quote-card"
import { youtubeOf } from "@/registry/base-nova/lib/youtube-data"
import { Highlighted } from "@/registry/base-nova/ui/markdown-code"
import { Quote, type QuoteProps } from "@/registry/base-nova/ui/quote"
import { YouTube } from "@/registry/base-nova/ui/youtube"

/** The custom properties MARKDOWN's rules read: the quote's mark, as a mask. Worn with
 *  the look by whoever wears it (the renderer here, the editor's document). */
export const MARKDOWN_VARS = {
  "--ag-quote-mark": `url("data:image/svg+xml,${encodeURIComponent(
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${MARK_BOX.w} ${MARK_BOX.h}"><path d="${MARK_PATH}"/></svg>`,
  )}")`,
} as React.CSSProperties

/** The look of a markdown body, on plain tags, in em. Whoever draws the tags wears it
 *  on their container. One colour throughout, the page's own, at the alpha ladder's
 *  tiers: titles at 90, the words at 70, what is quoted at 80, what is struck or
 *  annotated at 40 to 50, every line a hairline (5 to 8) and every surface a wash (3 to
 *  6). Nothing is tinted; a link is told by its hairline, not a hue.
 *
 *  EVERY RULE STOPS AT A THING THAT DRAWS ITSELF: an embed (`[data-embed]`: a post, a
 *  video) and a quote's attribution are drawn as on their own pages, so each tag rule
 *  carries `:not(:is([data-embed],[data-slot=quote-by]) *)`. Without it a post's avatar
 *  took the page's full-width image rule and its name the link's hairline. */
export const MARKDOWN = [
  "text-foreground/70 leading-[1.7] [overflow-wrap:anywhere]",
  // The first and last blocks sit flush with the body's edges, whatever they are:
  // important, since each tag's own margin below is the more specific rule.
  "[&>:first-child]:mt-0! [&>:last-child]:mb-0!",
  "[&_p:not([data-slot=quote-body]_p):not(:is([data-embed],[data-slot=quote-by])_*)]:my-[1em]",
  // headings: the serif voice for the three that title, the sans for the rest
  "[&_:is(h1,h2,h3,h4,h5,h6):not(:is([data-embed],[data-slot=quote-by])_*)]:text-foreground/90 [&_:is(h1,h2,h3,h4,h5,h6):not(:is([data-embed],[data-slot=quote-by])_*)]:text-balance [&_:is(h1,h2,h3,h4,h5,h6):not(:is([data-embed],[data-slot=quote-by])_*)]:scroll-mt-20",
  "[&_:is(h1,h2,h3):not(:is([data-embed],[data-slot=quote-by])_*)]:font-serif [&_:is(h1,h2,h3):not(:is([data-embed],[data-slot=quote-by])_*)]:font-normal",
  "[&_h1:not(:is([data-embed],[data-slot=quote-by])_*)]:mt-[1.5em] [&_h1:not(:is([data-embed],[data-slot=quote-by])_*)]:mb-[0.6em] [&_h1:not(:is([data-embed],[data-slot=quote-by])_*)]:text-[2.25em] [&_h1:not(:is([data-embed],[data-slot=quote-by])_*)]:leading-[1.15] [&_h1:not(:is([data-embed],[data-slot=quote-by])_*)]:tracking-[-0.03em]",
  "[&_h2:not(:is([data-embed],[data-slot=quote-by])_*)]:mt-[1.7em] [&_h2:not(:is([data-embed],[data-slot=quote-by])_*)]:mb-[0.7em] [&_h2:not(:is([data-embed],[data-slot=quote-by])_*)]:border-b [&_h2:not(:is([data-embed],[data-slot=quote-by])_*)]:border-foreground/8 [&_h2:not(:is([data-embed],[data-slot=quote-by])_*)]:pb-[0.4em] [&_h2:not(:is([data-embed],[data-slot=quote-by])_*)]:text-[1.75em] [&_h2:not(:is([data-embed],[data-slot=quote-by])_*)]:leading-[1.2] [&_h2:not(:is([data-embed],[data-slot=quote-by])_*)]:tracking-[-0.03em]",
  "[&_h3:not(:is([data-embed],[data-slot=quote-by])_*)]:mt-[1.6em] [&_h3:not(:is([data-embed],[data-slot=quote-by])_*)]:mb-[0.55em] [&_h3:not(:is([data-embed],[data-slot=quote-by])_*)]:text-[1.375em] [&_h3:not(:is([data-embed],[data-slot=quote-by])_*)]:leading-[1.25] [&_h3:not(:is([data-embed],[data-slot=quote-by])_*)]:tracking-[-0.02em]",
  "[&_:is(h4,h5,h6):not(:is([data-embed],[data-slot=quote-by])_*)]:mt-[1.75em] [&_:is(h4,h5,h6):not(:is([data-embed],[data-slot=quote-by])_*)]:mb-[0.5em] [&_:is(h4,h5,h6):not(:is([data-embed],[data-slot=quote-by])_*)]:font-semibold [&_:is(h4,h5,h6):not(:is([data-embed],[data-slot=quote-by])_*)]:tracking-[-0.01em]",
  // words
  "[&_strong:not(:is([data-embed],[data-slot=quote-by])_*)]:font-semibold [&_strong:not(:is([data-embed],[data-slot=quote-by])_*)]:text-foreground/90 [&_em:not(:is([data-embed],[data-slot=quote-by])_*)]:italic [&_:is(s,del):not(:is([data-embed],[data-slot=quote-by])_*)]:text-foreground/40",
  // links: a hairline at a third of their colour, a highlighter's wash under the hand.
  // Their colour is `--ag-markdown-link` when set (a site's accent: the hairline and
  // the wash follow it), else the page's own at 90.
  "[&_a:not(:is([data-embed],[data-slot=quote-by])_*)]:text-[var(--ag-markdown-link,color-mix(in_oklab,var(--color-foreground)_90%,transparent))] [&_a:not(:is([data-embed],[data-slot=quote-by])_*)]:no-underline [&_a:not(:is([data-embed],[data-slot=quote-by])_*)]:shadow-[inset_0_-1px_0_0_color-mix(in_oklab,currentColor_35%,transparent)] [&_a:not(:is([data-embed],[data-slot=quote-by])_*)]:transition-shadow [&_a:not(:is([data-embed],[data-slot=quote-by])_*)]:duration-150 [&_a:not(:is([data-embed],[data-slot=quote-by])_*):hover]:shadow-[inset_0_-0.5em_0_0_color-mix(in_oklab,currentColor_12%,transparent)]",
  // lists, tight: an item's own paragraph (the editor wraps items in one) adds nothing
  "[&_ul:not(:is([data-embed],[data-slot=quote-by])_*)]:list-disc [&_ol:not(:is([data-embed],[data-slot=quote-by])_*)]:list-decimal [&_:is(ul,ol):not(:is([data-embed],[data-slot=quote-by])_*)]:my-[1em] [&_:is(ul,ol):not(:is([data-embed],[data-slot=quote-by])_*)]:pl-[1.5em] [&_:is(ul,ol)_:is(ul,ol):not(:is([data-embed],[data-slot=quote-by])_*)]:my-[0.25em]",
  "[&_li:not(:is([data-embed],[data-slot=quote-by])_*)]:my-[0.3em] [&_li:not(:is([data-embed],[data-slot=quote-by])_*)]:pl-[0.25em] [&_li>p:not(:is([data-embed],[data-slot=quote-by])_*)]:my-0 [&_li:not(:is([data-embed],[data-slot=quote-by])_*)::marker]:text-foreground/30",
  "[&_li[data-task]:not(:is([data-embed],[data-slot=quote-by])_*)]:-ml-[1.25em] [&_li[data-task]:not(:is([data-embed],[data-slot=quote-by])_*)]:flex [&_li[data-task]:not(:is([data-embed],[data-slot=quote-by])_*)]:list-none [&_li[data-task]:not(:is([data-embed],[data-slot=quote-by])_*)]:gap-[0.6em]",
  // code: a chip inline; a block in code's frame (renderer) or bare (editor), both a
  // hairline round a wash. edge: the bare block is the editor's own <pre>, framed
  // on itself, which a fade would fade with its frame; the renderer's blocks scroll
  // inside code's frame, fading (code.css).
  "[&_:not(pre)>code:not(:is([data-embed],[data-slot=quote-by])_*)]:rounded-[0.375em] [&_:not(pre)>code:not(:is([data-embed],[data-slot=quote-by])_*)]:bg-foreground/6 [&_:not(pre)>code:not(:is([data-embed],[data-slot=quote-by])_*)]:px-[0.4em] [&_:not(pre)>code:not(:is([data-embed],[data-slot=quote-by])_*)]:py-[0.15em] [&_:not(pre)>code:not(:is([data-embed],[data-slot=quote-by])_*)]:font-mono [&_:not(pre)>code:not(:is([data-embed],[data-slot=quote-by])_*)]:text-[0.85em] [&_:not(pre)>code:not(:is([data-embed],[data-slot=quote-by])_*)]:text-foreground/85",
  "[&_.ag-code:not(:is([data-embed],[data-slot=quote-by])_*)]:my-[1.5em] [&_.ag-code:not(:is([data-embed],[data-slot=quote-by])_*)]:rounded-[0.75em] [&_.ag-code:not(:is([data-embed],[data-slot=quote-by])_*)]:border-foreground/8 [&_.ag-code:not(:is([data-embed],[data-slot=quote-by])_*)]:bg-foreground/3 [&_.ag-code_pre:not(:is([data-embed],[data-slot=quote-by])_*)]:px-[1.1em] [&_.ag-code_pre:not(:is([data-embed],[data-slot=quote-by])_*)]:py-[1em] [&_.ag-code_pre:not(:is([data-embed],[data-slot=quote-by])_*)]:text-[0.8125em]",
  "[&_:not(.ag-code-scroll)>pre:not(:is([data-embed],[data-slot=quote-by])_*)]:my-[1.5em] [&_:not(.ag-code-scroll)>pre:not(:is([data-embed],[data-slot=quote-by])_*)]:overflow-x-auto [&_:not(.ag-code-scroll)>pre:not(:is([data-embed],[data-slot=quote-by])_*)]:rounded-[0.75em] [&_:not(.ag-code-scroll)>pre:not(:is([data-embed],[data-slot=quote-by])_*)]:border [&_:not(.ag-code-scroll)>pre:not(:is([data-embed],[data-slot=quote-by])_*)]:border-foreground/8 [&_:not(.ag-code-scroll)>pre:not(:is([data-embed],[data-slot=quote-by])_*)]:bg-foreground/3 [&_:not(.ag-code-scroll)>pre:not(:is([data-embed],[data-slot=quote-by])_*)]:px-[1.1em] [&_:not(.ag-code-scroll)>pre:not(:is([data-embed],[data-slot=quote-by])_*)]:py-[1em] [&_:not(.ag-code-scroll)>pre:not(:is([data-embed],[data-slot=quote-by])_*)]:font-mono [&_:not(.ag-code-scroll)>pre:not(:is([data-embed],[data-slot=quote-by])_*)]:text-[0.8125em] [&_:not(.ag-code-scroll)>pre:not(:is([data-embed],[data-slot=quote-by])_*)]:leading-[1.65] [&_:not(.ag-code-scroll)>pre:not(:is([data-embed],[data-slot=quote-by])_*)]:text-foreground/80",
  // source kept as it came (the editor's verbatim block): set apart and quiet, read as
  // the file's own text, not the page's
  "[&_:not(.ag-code-scroll)>pre[data-language=verbatim]]:border-dashed [&_:not(.ag-code-scroll)>pre[data-language=verbatim]]:bg-transparent [&_:not(.ag-code-scroll)>pre[data-language=verbatim]]:py-[0.6em] [&_:not(.ag-code-scroll)>pre[data-language=verbatim]]:text-[0.75em] [&_:not(.ag-code-scroll)>pre[data-language=verbatim]]:text-foreground/40",
  // quotes: the quote item's prose weight. The renderer draws `Quote` itself (its words,
  // its attribution); a bare blockquote (the editor's) is drawn the same way here: one
  // step up on a surface, the quote voice, the quote's own mark in the gutter
  // (`--ag-quote-mark`, MARKDOWN_VARS, from quote-card's outline)
  // an embed: set apart, and given back the page's own ink and leading
  "[&_[data-slot=quote]]:my-[1.5em] [&_[data-embed]]:my-[1.5em] [&_[data-embed]]:text-foreground [&_[data-embed]]:leading-normal [&_[data-embed]]:[overflow-wrap:normal]",
  "[&_blockquote:not([data-slot=quote-body])]:relative [&_blockquote:not([data-slot=quote-body])]:my-[1.5em] [&_blockquote:not([data-slot=quote-body])]:rounded-xl [&_blockquote:not([data-slot=quote-body])]:bg-foreground/4 [&_blockquote:not([data-slot=quote-body])]:py-[0.889em] [&_blockquote:not([data-slot=quote-body])]:pr-[1.111em] [&_blockquote:not([data-slot=quote-body])]:pl-[2em] [&_blockquote:not([data-slot=quote-body])]:font-[family-name:var(--font-quote)] [&_blockquote:not([data-slot=quote-body])]:text-[1.125em] [&_blockquote:not([data-slot=quote-body])]:text-foreground/92",
  "[&_blockquote:not([data-slot=quote-body])]:before:absolute [&_blockquote:not([data-slot=quote-body])]:before:top-[1.089em] [&_blockquote:not([data-slot=quote-body])]:before:left-[0.8em] [&_blockquote:not([data-slot=quote-body])]:before:h-[0.95em] [&_blockquote:not([data-slot=quote-body])]:before:w-[1.083em] [&_blockquote:not([data-slot=quote-body])]:before:bg-current [&_blockquote:not([data-slot=quote-body])]:before:opacity-30 [&_blockquote:not([data-slot=quote-body])]:before:content-[''] [&_blockquote:not([data-slot=quote-body])]:before:[mask:var(--ag-quote-mark)_center/contain_no-repeat]",
  "[&_blockquote_p]:my-0 [&_blockquote_p+p]:mt-[0.5em]",
  // rules
  "[&_hr:not(:is([data-embed],[data-slot=quote-by])_*)]:my-[3em] [&_hr:not(:is([data-embed],[data-slot=quote-by])_*)]:border-0 [&_hr:not(:is([data-embed],[data-slot=quote-by])_*)]:border-t [&_hr:not(:is([data-embed],[data-slot=quote-by])_*)]:border-foreground/8",
  // tables: no grid, a hairline under the head and between rows
  "[&_table:not(:is([data-embed],[data-slot=quote-by])_*)]:my-[1.5em] [&_table:not(:is([data-embed],[data-slot=quote-by])_*)]:w-full [&_table:not(:is([data-embed],[data-slot=quote-by])_*)]:border-collapse [&_table:not(:is([data-embed],[data-slot=quote-by])_*)]:text-[0.875em] [&_table:not(:is([data-embed],[data-slot=quote-by])_*)]:leading-[1.5]",
  "[&_th:not(:is([data-embed],[data-slot=quote-by])_*)]:border-b [&_th:not(:is([data-embed],[data-slot=quote-by])_*)]:border-foreground/8 [&_th:not(:is([data-embed],[data-slot=quote-by])_*)]:px-[1em] [&_th:not(:is([data-embed],[data-slot=quote-by])_*)]:py-[0.75em] [&_th:not(:is([data-embed],[data-slot=quote-by])_*)]:text-left [&_th:not(:is([data-embed],[data-slot=quote-by])_*)]:text-[0.75em] [&_th:not(:is([data-embed],[data-slot=quote-by])_*)]:font-medium [&_th:not(:is([data-embed],[data-slot=quote-by])_*)]:uppercase [&_th:not(:is([data-embed],[data-slot=quote-by])_*)]:tracking-[0.05em] [&_th:not(:is([data-embed],[data-slot=quote-by])_*)]:text-foreground/50",
  "[&_td:not(:is([data-embed],[data-slot=quote-by])_*)]:border-b [&_td:not(:is([data-embed],[data-slot=quote-by])_*)]:border-foreground/5 [&_td:not(:is([data-embed],[data-slot=quote-by])_*)]:px-[1em] [&_td:not(:is([data-embed],[data-slot=quote-by])_*)]:py-[0.75em] [&_td:not(:is([data-embed],[data-slot=quote-by])_*)]:align-top [&_tr:last-child>td:not(:is([data-embed],[data-slot=quote-by])_*)]:border-b-0",
  // images and the foot
  "[&_img:not(:is([data-embed],[data-slot=quote-by])_*)]:my-[2em] [&_img:not(:is([data-embed],[data-slot=quote-by])_*)]:h-auto [&_img:not(:is([data-embed],[data-slot=quote-by])_*)]:w-full [&_img:not(:is([data-embed],[data-slot=quote-by])_*)]:rounded-[0.75em]",
  "[&_[data-footnotes]]:mt-[3em] [&_[data-footnotes]]:border-t [&_[data-footnotes]]:border-foreground/8 [&_[data-footnotes]]:pt-[1em] [&_[data-footnotes]]:text-[0.875em] [&_[data-footnotes]]:text-foreground/50",
].join(" ")

type Tags = {
  a: React.ComponentType<React.ComponentProps<"a">>
  img: React.ComponentType<React.ComponentProps<"img">>
  /** Inline code, given its text (a code block is `Highlighted`'s). */
  code: React.ComponentType<{ children: string }>
  /** A quotation: its words drawn, who said it when the last line names them. */
  blockquote: React.ComponentType<QuoteProps>
}

export interface MarkdownProps {
  /** The markdown text. */
  children: string
  /** How a link, an image, inline code or a quotation is drawn, when not as the
   *  registry draws them. Links and images receive URLs already checked safe; a host
   *  narrows further (images only from its CDN). */
  components?: Partial<Tags>
  /** A link alone on its line, as the thing it points to: return what to draw for a
   *  URL (a post on X from its facts, a Telegram post), `undefined` to leave it to the
   *  defaults, or `null` to keep it a link (`() => null`: nothing embedded, for text
   *  that must load nothing from another host). A YouTube link is a `YouTube` by
   *  default; any other stays a link. */
  embeds?: (url: string) => React.ReactNode
  /** The tree, changed before it is drawn: autolinking a ticket number or a mention
   *  (`mapText`), dropping a section. A pure function, the given tree untouched. */
  transform?: (tree: M.Root) => M.Root
  className?: string
}

/** A node's words, as text: inline runs joined, blocks set apart by a space. */
const plainOf = (node: M.Nodes): string =>
  "value" in node
    ? node.value
    : "children" in node
      ? (node.children as M.Nodes[])
          .map(plainOf)
          .join(node.type === "paragraph" || node.type === "heading" ? "" : " ")
      : ""

/** The embed the registry draws by itself: a YouTube link as its video. */
const builtInEmbed = (url: string): React.ReactNode =>
  youtubeOf(url) ? <YouTube video={url} /> : undefined

/** A blockquote's last line naming who said it: `— Ada Lovelace` (an em dash, two
 *  hyphens, or a hyphen, then a space), a link around the name taken as theirs, and
 *  anything after a comma as where it was said (`— Ada, in her notes`). */
function attributionOf(node: M.Blockquote) {
  const last = node.children.at(-1)
  if (last?.type !== "paragraph" || node.children.length < 2) return null
  const [first, ...rest] = last.children
  if (first?.type !== "text") return null
  const lead = /^(?:—|–|--|-)\s+/.exec(first.value)
  if (!lead) return null
  const nodes: M.PhrasingContent[] = [
    { ...first, value: first.value.slice(lead[0].length) },
    ...rest,
  ]
  const link = nodes.find((n): n is M.Link => n.type === "link")
  const text = nodes.map(plainOf).join("").trim()
  const comma = text.indexOf(", ")
  const name = comma < 0 ? text : text.slice(0, comma)
  const source = comma < 0 ? null : text.slice(comma + 2)
  if (!name) return null
  const href = link ? safeHref(link.url) : null
  return { name, href, source }
}

const PlainLink = (props: React.ComponentProps<"a">) => {
  const away = /^https?:\/\//.test(props.href ?? "")
  return (
    <a
      {...props}
      {...(away ? { target: "_blank", rel: "noopener noreferrer" } : {})}
    />
  )
}
const PlainImage = ({ alt, ...props }: React.ComponentProps<"img">) => (
  // biome-ignore lint/performance/noImgElement: the plain tag; `components.img` swaps in the image item
  <img alt={alt ?? ""} {...props} />
)
const PlainCode = ({ children }: { children: string }) => (
  <code>{children}</code>
)

/** A markdown text, drawn. */
export function Markdown({
  children,
  components,
  embeds,
  transform,
  className,
}: MarkdownProps) {
  const tree = React.useMemo(() => {
    const parsed = parseMarkdown(children)
    return transform ? transform(parsed) : parsed
  }, [children, transform])
  const tags: Tags = {
    a: PlainLink,
    img: PlainImage,
    code: PlainCode,
    blockquote: Quote,
    ...components,
  }
  // `undefined` leaves the link to the defaults; `null` keeps it a link, defaults and
  // all (user content that must load nothing from another host).
  const embed = (url: string) => {
    const own = embeds?.(url)
    return own === undefined ? builtInEmbed(url) : own
  }
  const draw = drawer(tree, tags, embed)
  return (
    <div
      data-slot="markdown"
      className={cn(MARKDOWN, className)}
      style={MARKDOWN_VARS}
    >
      {tree.children.map(draw.block)}
      {draw.notes.length > 0 && (
        <section data-footnotes="">
          <ol>
            {draw.notes.map((n) => (
              <li key={n.identifier} id={`fn-${n.identifier}`}>
                {n.children.map(draw.block)}
              </li>
            ))}
          </ol>
        </section>
      )}
    </div>
  )
}

/** What draws a tree: its definitions resolved, its headings' anchors unique. */
function drawer(
  tree: M.Root,
  tags: Tags,
  embed: (url: string) => React.ReactNode,
) {
  const definitions = new Map<string, M.Definition>()
  const notes: M.FootnoteDefinition[] = []
  for (const node of tree.children) {
    if (node.type === "definition") definitions.set(node.identifier, node)
    if (node.type === "footnoteDefinition") notes.push(node)
  }
  const slugs = new Map<string, number>()
  const anchor = (text: string) => {
    const base = slugOf(text) || "section"
    const n = slugs.get(base) ?? 0
    slugs.set(base, n + 1)
    return n === 0 ? base : `${base}-${n}`
  }
  const footnote = (id: string) =>
    notes.findIndex((n) => n.identifier === id) + 1
  // A link or an image only where its URL is safe: an unsafe link is its words, an
  // unsafe image its alt text. The drawing components see only checked URLs.
  const link = (
    url: string,
    title: string | null | undefined,
    children: readonly M.PhrasingContent[],
    key: number,
  ) => {
    const href = safeHref(url)
    const words = children.map(inline)
    return href ? (
      <tags.a key={key} href={href} title={title ?? undefined}>
        {words}
      </tags.a>
    ) : (
      <React.Fragment key={key}>{words}</React.Fragment>
    )
  }
  const image = (
    url: string,
    alt: string | null | undefined,
    title: string | null | undefined,
    key: number,
  ) => {
    const src = safeSrc(url)
    return src ? (
      // biome-ignore lint/performance/noImgElement: the consumer's image (components.img), not a bare tag
      <tags.img
        key={key}
        src={src}
        alt={alt ?? ""}
        title={title ?? undefined}
      />
    ) : (
      (alt ?? "")
    )
  }

  const inline = (node: M.PhrasingContent, key: number): React.ReactNode => {
    switch (node.type) {
      case "text":
        return node.value
      case "emphasis":
        return <em key={key}>{node.children.map(inline)}</em>
      case "strong":
        return <strong key={key}>{node.children.map(inline)}</strong>
      case "delete":
        return <s key={key}>{node.children.map(inline)}</s>
      case "inlineCode":
        return <tags.code key={key}>{node.value}</tags.code>
      case "break":
        return <br key={key} />
      case "link":
        return link(node.url, node.title, node.children, key)
      case "image":
        return image(node.url, node.alt, node.title, key)
      case "linkReference": {
        const d = definitions.get(node.identifier)
        return link(d?.url ?? "", d?.title, node.children, key)
      }
      case "imageReference": {
        const d = definitions.get(node.identifier)
        return image(d?.url ?? "", node.alt, d?.title, key)
      }
      case "footnoteReference":
        return (
          <sup key={key}>
            <a href={`#fn-${node.identifier}`}>{footnote(node.identifier)}</a>
          </sup>
        )
      case "html":
        return <code key={key}>{node.value}</code>
    }
  }

  const textOf = (nodes: readonly M.PhrasingContent[]): string =>
    nodes
      .map((n) =>
        "value" in n ? n.value : "children" in n ? textOf(n.children) : "",
      )
      .join("")

  const block = (node: M.RootContent, key: number): React.ReactNode => {
    switch (node.type) {
      case "paragraph": {
        // A link alone on its line (a bare URL, or one whose words are its URL) is the
        // thing it points to, when there is one to draw; the URL is checked safe first.
        const only = node.children.length === 1 ? node.children[0] : undefined
        const words =
          only?.type === "link" && only.children.length === 1
            ? only.children[0]
            : undefined
        const href =
          only?.type === "link" &&
          words?.type === "text" &&
          words.value === only.url
            ? safeHref(only.url)
            : null
        const embedded = href ? embed(href) : undefined
        if (embedded)
          return (
            <div key={key} data-slot="markdown-embed" data-embed="">
              {embedded}
            </div>
          )
        return <p key={key}>{node.children.map(inline)}</p>
      }
      case "heading": {
        const Tag = `h${node.depth}` as const
        return (
          <Tag key={key} id={anchor(textOf(node.children))}>
            {node.children.map(inline)}
          </Tag>
        )
      }
      case "blockquote": {
        // The quote item's prose weight: its words, and who said it when the last line
        // names them (`— Name`), drawn as the quote's attribution.
        const by = attributionOf(node)
        const words = by ? node.children.slice(0, -1) : node.children
        return (
          <tags.blockquote
            key={key}
            text={words.map(plainOf).join("\n\n")}
            author={by && { name: by.name, href: by.href }}
            source={by?.source}
          >
            {words.map(block)}
          </tags.blockquote>
        )
      }
      case "list": {
        const items = node.children.map((item, i) => {
          // A tight list's items are their words; a loose one's, paragraphs.
          const kids =
            !node.spread &&
            item.children.length === 1 &&
            item.children[0]?.type === "paragraph"
              ? item.children[0].children.map(inline)
              : item.children.map(block)
          return item.checked == null ? (
            // biome-ignore lint/suspicious/noArrayIndexKey: a list's items are its order
            <li key={i}>{kids}</li>
          ) : (
            // biome-ignore lint/suspicious/noArrayIndexKey: a list's items are its order
            <li key={i} data-task={item.checked ? "done" : "open"}>
              <Box done={item.checked} />
              <span>{kids}</span>
            </li>
          )
        })
        return node.ordered ? (
          <ol key={key} start={node.start ?? undefined}>
            {items}
          </ol>
        ) : (
          <ul key={key}>{items}</ul>
        )
      }
      case "code":
        return (
          <Highlighted key={key} text={node.value} lang={node.lang ?? "text"} />
        )
      case "thematicBreak":
        return <hr key={key} />
      case "table": {
        const [head, ...body] = node.children
        const align = (i: number) => node.align?.[i] ?? undefined
        return (
          <table key={key}>
            {head && (
              <thead>
                <tr>
                  {head.children.map((c, i) => (
                    // biome-ignore lint/suspicious/noArrayIndexKey: a row's cells are its columns
                    <th key={i} style={{ textAlign: align(i) }}>
                      {c.children.map(inline)}
                    </th>
                  ))}
                </tr>
              </thead>
            )}
            <tbody>
              {body.map((r, ri) => (
                // biome-ignore lint/suspicious/noArrayIndexKey: a table's rows are its order
                <tr key={ri}>
                  {r.children.map((c, i) => (
                    // biome-ignore lint/suspicious/noArrayIndexKey: a row's cells are its columns
                    <td key={i} style={{ textAlign: align(i) }}>
                      {c.children.map(inline)}
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        )
      }
      case "html":
        return <Highlighted key={key} text={node.value} lang="html" />
      // Shown elsewhere or not at all: front matter, link definitions, footnotes (at
      // the foot).
      case "yaml":
      case "definition":
      case "footnoteDefinition":
        return null
      default:
        return null
    }
  }
  return { block, notes }
}

/** A task's box: done or open. */
function Box({ done }: { done: boolean }) {
  return (
    <span
      aria-label={done ? "done" : "to do"}
      role="img"
      className={cn(
        "mt-[0.3em] grid size-[1em] shrink-0 place-items-center rounded-[0.25em] border border-foreground/30 text-background",
        done && "border-foreground/70 bg-foreground/70",
      )}
    >
      {done && <Check className="size-[0.7em]" strokeWidth={3.5} />}
    </span>
  )
}
markdown-code.tsx
"use client"

// A CODE BLOCK, HIGHLIGHTED IN THE BROWSER: the one part of markdown that needs the
// browser (Shiki loads there, only once a block shows), so the page around it renders
// anywhere, a server included. Plain at once, coloured once Shiki has loaded, in code's
// frame with code's two themes.

import * as React from "react"
import { cn } from "@/lib/utils"
import { CODE_THEMES } from "@/registry/base-nova/lib/code-tokens"
import "./code.css"
import "./edge-fade"

/** Code highlighted in the browser, with code's two themes and its frame. */
export function Highlighted({
  text,
  lang,
  className,
}: {
  text: string
  lang: string
  className?: string
}) {
  const [html, setHtml] = React.useState<string | null>(null)
  React.useEffect(() => {
    if (lang === "text") return
    let live = true
    void import("shiki").then(async ({ bundledLanguages, codeToHtml }) => {
      if (!(lang in bundledLanguages)) return
      const out = await codeToHtml(text, {
        lang,
        themes: CODE_THEMES,
        defaultColor: false,
      })
      if (live) setHtml(out)
    })
    return () => {
      live = false
    }
  }, [text, lang])
  return (
    <div
      data-slot="markdown-code"
      data-language={lang}
      className={cn("ag-code", className)}
    >
      {/* The code's own scroller, both ways, its cut edges fading (code.css,
          scrims' `ag-edge-fade-xy`), the frame staying whole. */}
      {html ? (
        <div
          className="ag-code-scroll ag-edge-fade-xy"
          // biome-ignore lint/security/noDangerouslySetInnerHtml: Shiki's own escaped markup of the text
          dangerouslySetInnerHTML={{ __html: html }}
        />
      ) : (
        <div className="ag-code-scroll ag-edge-fade-xy">
          <pre>
            <code>{text}</code>
          </pre>
        </div>
      )}
    </div>
  )
}
markdown-core.ts
// MARKDOWN AS A TREE, framework-free: the one parse every reader and writer of
// markdown here shares (the renderer, the editor's converter), so a file means the same
// thing wherever it is shown or edited. CommonMark with GitHub's extensions (tables,
// strikethrough, task lists, autolinks, footnotes) and front matter (YAML or TOML,
// kept as its own node, never read as a rule and a heading). The tree is mdast; every
// node keeps its source position, so a reader can always fall back to the exact text.

import type { Nodes, PhrasingContent, Root } from "mdast"
import { fromMarkdown } from "mdast-util-from-markdown"
import { frontmatterFromMarkdown } from "mdast-util-frontmatter"
import { gfmFromMarkdown } from "mdast-util-gfm"
import { frontmatter } from "micromark-extension-frontmatter"
import { gfm } from "micromark-extension-gfm"

/** The front matter fences understood. */
export const FRONTMATTER = ["yaml", "toml"] as const

/** A markdown text as its tree. */
export function parseMarkdown(text: string): Root {
  return fromMarkdown(text, {
    extensions: [gfm(), frontmatter([...FRONTMATTER])],
    mdastExtensions: [
      gfmFromMarkdown(),
      frontmatterFromMarkdown([...FRONTMATTER]),
    ],
  })
}

/** A tree with every run of text outside code and links replaced by what `f` makes of
 *  its words (the words again, links, emphasis): the seam for autolinking a ticket
 *  number or a mention without ever reaching into code or an existing link. A new
 *  tree; the given one is untouched. */
export function mapText(
  tree: Root,
  f: (value: string) => PhrasingContent[],
): Root {
  const walk = <N extends Nodes>(node: N): N => {
    if (!("children" in node)) return node
    if (node.type === "link" || node.type === "linkReference") return node
    const children = (node.children as Nodes[]).flatMap((child): Nodes[] =>
      child.type === "text" ? f(child.value) : [walk(child)],
    )
    return { ...node, children } as N
  }
  return walk(tree)
}

/** The protocols a link may use: the web, mail, a phone, the page and the site. */
const SAFE_HREF =
  /^(?:https?:|mailto:|tel:|#|\/|\.{1,2}\/|[^:/?#]+(?:[/?#]|$))/i
/** The sources an image may come from: the web and the site, never a data or script
 *  URL. */
const SAFE_SRC = /^(?:https?:|\/|\.{1,2}\/|[^:/?#]+(?:[/?#]|$))/i

/** A link's target when it is safe to follow, else null: `javascript:`, `data:` and
 *  the rest never become a live link. */
export function safeHref(url: string): string | null {
  const u = url.trim()
  return SAFE_HREF.test(u) ? u : null
}

/** An image's source when it is safe to load, else null. */
export function safeSrc(url: string): string | null {
  const u = url.trim()
  return SAFE_SRC.test(u) ? u : null
}

/** A heading's anchor: its words, lowercased, joined by dashes. */
export function slugOf(text: string): string {
  return text
    .toLowerCase()
    .normalize("NFKD")
    .replace(/\p{M}/gu, "")
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "")
}