
Can we agree to call Jev a "Decision Model"? I feel like they buried this in the explainations and docs. Language models output language. Decision models output decisions.


markdown drawn as a page, on the server or in the browser, the editor's look too
npx shadcn add @ag/markdownnpx shadcn add @ag/markdown --dry-runYour installed source stays unchanged until you update it. How updates work
ag-edge-fade-xy)."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// 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>
)
}"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 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, "")
}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.
every size is in em: set the body small (a file's face in a gallery) and the page shrinks whole, nothing reflowed.
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.
--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.