feat(markdown): BIG ui improvements
Some checks failed
/ deploy-and-package (push) Has been cancelled

This commit is contained in:
Alois 2026-08-09 22:22:17 +02:00
commit 61d27b0e96
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
10 changed files with 861 additions and 388 deletions

View file

@ -134,85 +134,31 @@ const markdownContent = [
function MarkdownShowcase() { function MarkdownShowcase() {
const [inputValue, setInputValue] = useState( const [inputValue, setInputValue] = useState(
"Hello **Markdown** :fire:\n\nTry the custom :methanium: emoji or type another `:shortcode:`.", 'Hello **Markdown** :fire:\n\nTry the custom :methanium: emoji or type another `:shortcode:`. \n```ts\nconst stuff = "stuff";\n```',
); );
const [submissions, setSubmissions] = useState(0); const [submissions, setSubmissions] = useState(0);
const controllerRef = useRef<InputController | null>(null); const controllerRef = useRef<InputController | null>(null);
return ( return (
<EmojiProvider registry={emojiRegistry}> <EmojiProvider registry={emojiRegistry}>
<Card> <div className="flex min-w-0 flex-col gap-3 min-w-0 rounded-lg border bg-muted/20 p-4">
<CardHeader className="border-b"> <p>Cool input box</p>
<CardTitle>Live Markdown input</CardTitle> <Input
<CardDescription> className="min-h-28 "
CodeMirror editing with hidden syntax, local emoji autocomplete, and value={inputValue}
custom emoji from the shared registry. setValue={setInputValue}
</CardDescription> placeholder="Write Markdown or type :fire:"
</CardHeader> emojiFrequencies={{ ":methanium:": 20, ":fire:": 10 }}
<CardContent className="grid gap-5 lg:grid-cols-2"> onSubmit={() => setSubmissions((count) => count + 1)}
<div className="flex min-w-0 flex-col gap-3"> onControllerChange={(controller) => {
<Input controllerRef.current = controller;
className="min-h-28" }}
value={inputValue} />
setValue={setInputValue} <p>And it's output</p>
placeholder="Write Markdown or type :fire:" <Text value={inputValue} />
emojiFrequencies={{ ":methanium:": 20, ":fire:": 10 }} </div>
onSubmit={() => setSubmissions((count) => count + 1)}
onControllerChange={(controller) => {
controllerRef.current = controller;
}}
/>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => controllerRef.current?.focus()}
>
Focus editor
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
controllerRef.current?.insertText(" :methanium:")
}
>
Insert custom emoji
</Button>
<span className="text-xs text-muted-foreground">
Submissions: {submissions}
</span>
</div>
</div>
<div className="min-w-0 rounded-lg border bg-muted/20 p-4">
<p className="mb-2 text-xs font-medium text-muted-foreground">
Compact Text output
</p>
<Text value={inputValue} />
</div>
<div className="flex items-center gap-3 lg:col-span-2">
<Emoji shortcode=":fire:" />
<Emoji shortcode=":white_check_mark:" />
<Emoji shortcode=":methanium:" />
<span className="text-sm text-muted-foreground">
Twemoji SVGs are packaged locally; the last image is custom.
</span>
</div>
</CardContent>
</Card>
<Card> <Markdown content={markdownContent} className="mx-auto max-w-4xl" />
<CardHeader className="border-b">
<CardTitle>Markdown</CardTitle>
<CardDescription>
GFM, math, syntax highlighting, code metadata, and semantic element
styling.
</CardDescription>
</CardHeader>
<CardContent>
<Markdown content={markdownContent} className="mx-auto max-w-4xl" />
</CardContent>
</Card>
</EmojiProvider> </EmojiProvider>
); );
} }

View file

@ -43,6 +43,7 @@
@theme inline { @theme inline {
--font-heading: var(--font-sans), sans-serif; --font-heading: var(--font-sans), sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--font-sans: "Public Sans", system-ui, sans-serif; --font-sans: "Public Sans", system-ui, sans-serif;
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);

View file

@ -1,11 +1,17 @@
import { toJsxRuntime } from "hast-util-to-jsx-runtime"; import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import { Check, Copy } from "lucide-react"; import { Check, Copy } from "lucide-react";
import { startTransition, useEffect, useState, type ReactNode } from "react"; import { useEffect, useLayoutEffect, useState, type ReactNode } from "react";
import { Fragment, jsx, jsxs } from "react/jsx-runtime"; import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { Button } from "../cmp/button"; import { Button } from "../cmp/button";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { highlightCode, isSupportedLanguage, shikiPreClassName } from "./shiki"; import {
highlightCode,
highlightedLineClassNames,
isSupportedLanguage,
shikiLineClassNames,
shikiPreClassName,
} from "./shiki";
type CodeBlockProps = { type CodeBlockProps = {
code: string; code: string;
@ -20,7 +26,7 @@ function CodeBlock({
filename, filename,
highlightedLines, highlightedLines,
language, language,
showLineNumbers = false, showLineNumbers = true,
}: CodeBlockProps) { }: CodeBlockProps) {
const [highlighted, setHighlighted] = useState<ReactNode>(null); const [highlighted, setHighlighted] = useState<ReactNode>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -29,7 +35,7 @@ function CodeBlock({
.sort((left, right) => left - right) .sort((left, right) => left - right)
.join(","); .join(",");
useEffect(() => { useLayoutEffect(() => {
let active = true; let active = true;
setHighlighted(null); setHighlighted(null);
if (!language || !supported) return () => void (active = false); if (!language || !supported) return () => void (active = false);
@ -40,7 +46,7 @@ function CodeBlock({
void highlightCode(code, language, lines).then((tree) => { void highlightCode(code, language, lines).then((tree) => {
if (!active || !tree) return; if (!active || !tree) return;
const rendered = toJsxRuntime(tree, { Fragment, jsx, jsxs }); const rendered = toJsxRuntime(tree, { Fragment, jsx, jsxs });
startTransition(() => setHighlighted(rendered)); setHighlighted(rendered);
}); });
return () => void (active = false); return () => void (active = false);
}, [code, highlightedLineKey, language, supported]); }, [code, highlightedLineKey, language, supported]);
@ -65,11 +71,11 @@ function CodeBlock({
data-slot="code-block" data-slot="code-block"
data-line-numbers={showLineNumbers || undefined} data-line-numbers={showLineNumbers || undefined}
data-language={language || undefined} data-language={language || undefined}
className="group/code-block my-4 overflow-hidden rounded-lg border-[length:var(--ui-border-width)] border-border bg-[var(--markdown-code-background)] group-data-[variant=compact]/markdown:my-[0.45rem]" className="group/code-block overflow-hidden rounded-lg border-[length:var(--ui-border-width)] border-border bg-[var(--markdown-code-background)] group-data-[variant=compact]/markdown:my-[0.45rem]"
> >
<figcaption <figcaption
data-slot="code-block-header" data-slot="code-block-header"
className="flex min-h-9 items-center gap-2 border-b-[length:var(--ui-border-width)] border-border px-2 py-[0.3rem] ps-[0.8rem] font-mono text-xs text-muted-foreground group-data-[variant=compact]/markdown:hidden" className="flex items-center gap-2 ps-[0.6rem] px-0.5 font-mono text-xs text-muted-foreground"
> >
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"> <span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
{filename ?? language ?? "Plain text"} {filename ?? language ?? "Plain text"}
@ -83,15 +89,19 @@ function CodeBlock({
</span> </span>
) : null} ) : null}
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon-xs" size="icon-xs"
data-slot="code-block-copy" className={cn(
className={cn("ms-auto", !supported && language && "ms-0")} "ms-auto h-[22.2px]!",
aria-label={copied ? "Code copied" : "Copy code"} !supported && language && "ms-0",
)}
onClick={copyCode} onClick={copyCode}
> >
{copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />} {copied ? (
<Check className="size-3" aria-hidden="true" />
) : (
<Copy className="size-3" aria-hidden="true" />
)}
</Button> </Button>
<span className="sr-only" aria-live="polite"> <span className="sr-only" aria-live="polite">
{copied ? "Code copied to clipboard" : ""} {copied ? "Code copied to clipboard" : ""}
@ -106,7 +116,23 @@ function CodeBlock({
className={cn(shikiPreClassName, !supported && "is-plain-text")} className={cn(shikiPreClassName, !supported && "is-plain-text")}
tabIndex={0} tabIndex={0}
> >
<code>{code}</code> <code>
{code.split("\n").map((line, index, lines) => (
<Fragment key={index}>
<span
className={cn(
shikiLineClassNames,
highlightedLines.has(index + 1) &&
highlightedLineClassNames,
)}
data-line={index + 1}
>
{line}
</span>
{index < lines.length - 1 ? "\n" : null}
</Fragment>
))}
</code>
</pre> </pre>
)} )}
</div> </div>

View file

@ -117,7 +117,7 @@ function MarkdownParagraph({
return ( return (
<p <p
className={cn( className={cn(
"my-4 group-data-[variant=compact]/markdown:my-0", "my-4 group-data-[variant=compact]/markdown:my-[0.45rem]",
className, className,
)} )}
{...props} {...props}
@ -239,6 +239,7 @@ function MarkdownImage({
/> />
); );
} }
if (!props.src) return alt ? <span>{alt}</span> : null;
return ( return (
<img <img

View file

@ -15,7 +15,6 @@ export default function Emoji({
className = "size-6", className = "size-6",
registry: registryOverride, registry: registryOverride,
shortcode, shortcode,
tooltip = true,
}: EmojiProps) { }: EmojiProps) {
const registry = useEmojiRegistry(registryOverride); const registry = useEmojiRegistry(registryOverride);
const emoji = registry.resolve(shortcode); const emoji = registry.resolve(shortcode);
@ -23,26 +22,27 @@ export default function Emoji({
useEffect(() => setFailedSource(undefined), [emoji?.src]); useEffect(() => setFailedSource(undefined), [emoji?.src]);
if (!emoji || failedSource === emoji.src) return <span>{shortcode}</span>; const content =
emoji?.src && failedSource !== emoji.src ? (
const image = ( <img
<img alt={emoji.shortcode}
alt={emoji.shortcode} className={className}
className={className} decoding="async"
decoding="async" draggable={false}
draggable={false} loading="lazy"
loading="lazy" src={emoji.src}
src={emoji.src} onError={() => setFailedSource(emoji.src)}
onError={() => setFailedSource(emoji.src)} />
/> ) : (
); <span>{shortcode}</span>
);
if (!tooltip) return image;
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger render={image} /> <TooltipTrigger render={content} />
<TooltipContent sideOffset={8}>{emoji.shortcode}</TooltipContent> <TooltipContent sideOffset={8}>
{emoji?.shortcode ?? shortcode}
</TooltipContent>
</Tooltip> </Tooltip>
); );
} }

View file

@ -1,253 +1,294 @@
@import "katex/dist/katex.min.css"; @import "katex/dist/katex.min.css";
@layer components { @layer components {
[data-slot="markdown"] { [data-slot="markdown"],
--markdown-code-background: color-mix( .tm-md-root {
in oklch, --markdown-code-background: color-mix(
var(--muted) 72%, in oklch,
var(--background) var(--muted) 72%,
); var(--background)
--markdown-code-foreground: var(--foreground); );
--markdown-code-token-constant: var(--primary-foreground-alt); --markdown-code-foreground: var(--foreground);
--markdown-code-token-string: color-mix( --markdown-code-token-constant: var(--primary-foreground-alt);
in oklch, --markdown-code-token-string: color-mix(
var(--chart-2) 78%, in oklch,
var(--foreground) var(--chart-2) 78%,
); var(--foreground)
--markdown-code-token-comment: var(--muted-foreground); );
--markdown-code-token-keyword: color-mix( --markdown-code-token-comment: var(--muted-foreground);
in oklch, --markdown-code-token-keyword: color-mix(
var(--primary) 72%, in oklch,
var(--foreground) var(--primary) 72%,
); var(--foreground)
--markdown-code-token-parameter: var(--chart-3); );
--markdown-code-token-function: var(--chart-4); --markdown-code-token-parameter: var(--chart-3);
--markdown-code-token-string-expression: var(--chart-2); --markdown-code-token-function: var(--chart-4);
--markdown-code-token-punctuation: color-mix( --markdown-code-token-string-expression: var(--chart-2);
in oklch, --markdown-code-token-punctuation: color-mix(
var(--foreground) 72%, in oklch,
var(--muted-foreground) var(--foreground) 72%,
); var(--muted-foreground)
--markdown-code-token-link: var(--primary-foreground-alt); );
} --markdown-code-token-link: var(--primary-foreground-alt);
}
.dark [data-slot="markdown"]:not([data-code-theme="light"]), .dark [data-slot="markdown"]:not([data-code-theme="light"]),
[data-slot="markdown"][data-code-theme="dark"] { [data-slot="markdown"][data-code-theme="dark"],
--markdown-code-background: color-mix( .dark .tm-md-root {
in oklch, --markdown-code-background: color-mix(
var(--muted) 76%, in oklch,
black 24% var(--muted) 76%,
); black 24%
--markdown-code-token-constant: var(--chart-1); );
--markdown-code-token-keyword: color-mix( --markdown-code-token-constant: var(--chart-1);
in oklch, --markdown-code-token-keyword: color-mix(
var(--primary) 62%, in oklch,
var(--foreground) var(--primary) 62%,
); var(--foreground)
--markdown-code-token-function: color-mix( );
in oklch, --markdown-code-token-function: color-mix(
var(--chart-4) 72%, in oklch,
var(--foreground) var(--chart-4) 72%,
); var(--foreground)
} );
}
.cm-editor.tm-md-editor { .cm-editor.tm-md-editor {
border-radius: inherit; border-radius: inherit;
background: transparent; background: transparent;
caret-color: var(--foreground); caret-color: var(--foreground);
} }
.cm-editor.tm-md-editor.cm-focused { .cm-editor.tm-md-editor.cm-focused {
outline: none; outline: none;
box-shadow: none; box-shadow: none;
} }
.cm-editor.tm-md-editor .cm-cursor, .cm-editor.tm-md-editor .cm-cursor,
.cm-editor.tm-md-editor .cm-dropCursor { .cm-editor.tm-md-editor .cm-dropCursor {
border-left-color: var(--foreground) !important; border-left-color: var(--foreground) !important;
} }
.cm-editor.tm-md-editor .cm-scroller { .cm-editor.tm-md-editor .cm-scroller {
max-height: 30vh; max-height: 30vh;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
font-family: "Public Sans", system-ui, sans-serif !important; font-family: "Public Sans", system-ui, sans-serif !important;
line-height: 1.55; line-height: 1.55;
} }
.cm-editor.tm-md-editor .cm-content { .cm-editor.tm-md-editor .cm-content {
min-height: 2rem; min-height: 2rem;
padding: var(--tm-md-content-padding, 0.25rem 0.625rem); padding: var(--tm-md-content-padding, 0.25rem 0.625rem);
caret-color: var(--foreground) !important; caret-color: var(--foreground) !important;
} }
.cm-editor.tm-md-editor .cm-line { .cm-editor.tm-md-editor .cm-line {
padding: 0; padding: 0 0 0 0 !important;
color: var(--foreground); color: var(--foreground);
} }
.cm-editor.tm-md-editor .tm-md-editor-emoji { .cm-editor.tm-md-editor .tm-md-editor-emoji {
display: inline-block; display: inline-block;
width: 1.15em; width: 1.15em;
height: 1.15em; height: 1.15em;
object-fit: contain; object-fit: contain;
vertical-align: -0.18em; vertical-align: -0.18em;
pointer-events: none; pointer-events: none;
} }
.cm-editor.tm-md-editor .tm-md-code-line { .cm-editor.tm-md-editor .tm-md-code-line {
border-radius: var(--radius-sm); box-sizing: border-box;
background: var(--muted); border-right: var(--ui-border-width) solid var(--border);
font-family: "JetBrains Mono", ui-monospace, monospace; border-left: var(--ui-border-width) solid var(--border);
font-size: 0.82rem; border-radius: 0;
line-height: 1.7; background: var(--markdown-code-background);
} color: var(--markdown-code-foreground);
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.82rem;
line-height: 1.7;
padding-right: 1rem !important;
padding-left: 0 !important;
}
.cm-editor.tm-md-editor .tm-md-heading { .cm-editor.tm-md-editor .tm-md-code-line::before {
font-family: var(--font-heading); display: inline-block;
font-weight: 700; width: 2.5rem;
line-height: 1.25; padding-right: 1rem;
} color: var(--muted-foreground);
content: attr(data-code-line);
font-variant-numeric: tabular-nums;
text-align: right;
user-select: none;
}
.cm-editor.tm-md-editor .tm-md-h1 { .cm-editor.tm-md-editor .tm-md-code-line-start {
font-size: 1.65rem; margin-top: 0.45rem;
} border-top: var(--ui-border-width) solid var(--border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
padding-top: 0.65rem;
}
.cm-editor.tm-md-editor .tm-md-h2 { .cm-editor.tm-md-editor .tm-md-code-line-end {
font-size: 1.45rem; margin-bottom: 0.45rem;
} border-bottom: var(--ui-border-width) solid var(--border);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
padding-bottom: 0.65rem;
}
.cm-editor.tm-md-editor .tm-md-h3 { .cm-editor.tm-md-editor .tm-md-code-line-start.tm-md-code-line-end {
font-size: 1.25rem; border-radius: var(--radius-lg);
} }
.cm-editor.tm-md-editor .tm-md-h4 { .cm-editor.tm-md-editor .tm-md-rendered-code-block {
font-size: 1.1rem; width: 100%;
} }
.cm-editor.tm-md-editor .tm-md-h5 { .cm-editor.tm-md-editor .tm-md-heading {
font-size: 1rem; font-family: var(--font-heading);
} font-weight: 700;
line-height: 1.25;
}
.cm-editor.tm-md-editor .tm-md-h6 { .cm-editor.tm-md-editor .tm-md-h1 {
font-size: 0.95rem; font-size: 1.65rem;
opacity: 0.9; }
}
.cm-editor.tm-md-editor .tm-md-strong { .cm-editor.tm-md-editor .tm-md-h2 {
font-weight: 700; font-size: 1.45rem;
} }
.cm-editor.tm-md-editor .tm-md-em { .cm-editor.tm-md-editor .tm-md-h3 {
font-style: italic; font-size: 1.25rem;
} }
.cm-editor.tm-md-editor .tm-md-del { .cm-editor.tm-md-editor .tm-md-h4 {
text-decoration: line-through; font-size: 1.1rem;
} }
.cm-editor.tm-md-editor .tm-md-code { .cm-editor.tm-md-editor .tm-md-h5 {
border: var(--ui-border-width) solid var(--border); font-size: 1rem;
border-radius: var(--radius-sm); }
background: var(--muted);
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.88em;
}
.cm-editor.tm-md-editor .tm-md-code-active { .cm-editor.tm-md-editor .tm-md-h6 {
border-radius: var(--radius-sm); font-size: 0.95rem;
background: var(--muted); opacity: 0.9;
box-shadow: inset 0 0 0 var(--ui-border-width) var(--border); }
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.88em;
}
.cm-editor.tm-md-editor .tm-md-code-active .tm-md-code { .cm-editor.tm-md-editor .tm-md-strong {
border: 0; font-weight: 700;
border-radius: 0; }
background: transparent;
font-size: inherit;
}
.cm-editor.tm-md-editor .tm-md-link { .cm-editor.tm-md-editor .tm-md-em {
color: var(--primary-foreground-alt); font-style: italic;
text-decoration: underline; }
text-underline-offset: 0.14rem;
}
.cm-tooltip.cm-tooltip-autocomplete { .cm-editor.tm-md-editor .tm-md-del {
min-width: 18rem; text-decoration: line-through;
max-width: min(26rem, calc(100vw - 1rem)); }
overflow: hidden;
border: var(--ui-border-width) solid var(--border);
border-radius: var(--radius);
background: var(--popover);
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 10%),
0 2px 4px -2px rgb(0 0 0 / 10%);
color: var(--popover-foreground);
font-family: var(--font-sans);
font-size: 0.875rem;
}
.cm-tooltip.cm-tooltip-autocomplete > ul { .cm-editor.tm-md-editor .tm-md-code {
max-height: min(20rem, 45vh); border: var(--ui-border-width) solid var(--border);
padding: 0.25rem; border-radius: var(--radius-sm);
font-family: var(--font-sans); background: var(--muted);
scrollbar-color: var(--border) transparent; font-family: "JetBrains Mono", ui-monospace, monospace;
scrollbar-width: thin; font-size: 0.88em;
} }
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar { .cm-editor.tm-md-editor .tm-md-code-active {
width: 6px; border-radius: var(--radius-sm);
height: 6px; background: var(--muted);
} box-shadow: inset 0 0 0 var(--ui-border-width) var(--border);
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.88em;
}
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-track { .cm-editor.tm-md-editor .tm-md-code-active .tm-md-code {
background: transparent; border: 0;
} border-radius: 0;
background: transparent;
font-size: inherit;
}
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-thumb { .cm-editor.tm-md-editor .tm-md-link {
border-radius: 9999px; color: var(--primary-foreground-alt);
background: var(--border); text-decoration: underline;
} text-underline-offset: 0.14rem;
}
.cm-tooltip.cm-tooltip-autocomplete > ul > li { .cm-tooltip.cm-tooltip-autocomplete {
display: flex; min-width: 18rem;
min-height: 2.25rem; max-width: min(26rem, calc(100vw - 1rem));
align-items: center; overflow: hidden;
padding: 0.3rem 0.5rem; border: var(--ui-border-width) solid var(--border);
border-radius: var(--radius-md); border-radius: var(--radius);
color: var(--popover-foreground); background: var(--popover);
} box-shadow:
0 4px 6px -1px rgb(0 0 0 / 10%),
0 2px 4px -2px rgb(0 0 0 / 10%);
color: var(--popover-foreground);
font-family: var(--font-sans);
font-size: 0.875rem;
}
.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover, .cm-tooltip.cm-tooltip-autocomplete > ul {
.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] { max-height: min(20rem, 45vh);
background: var(--accent); padding: 0.25rem;
color: var(--accent-foreground); font-family: var(--font-sans);
} scrollbar-color: var(--border) transparent;
scrollbar-width: thin;
}
.cm-tooltip.cm-tooltip-autocomplete .cm-completionIcon { .cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar {
display: none; width: 6px;
} height: 6px;
}
.cm-tooltip.cm-tooltip-autocomplete .cm-completionLabel { .cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-track {
overflow: hidden; background: transparent;
text-overflow: ellipsis; }
}
.cm-tooltip.cm-tooltip-autocomplete .cm-completionMatchedText { .cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-thumb {
color: inherit; border-radius: 9999px;
font-weight: 600; background: var(--border);
text-decoration: none; }
}
.cm-tooltip-autocomplete .tm-md-completion-emoji { .cm-tooltip.cm-tooltip-autocomplete > ul > li {
display: inline-block; display: flex;
flex: 0 0 auto; min-height: 2.25rem;
width: 1.35rem; align-items: center;
height: 1.35rem; padding: 0.3rem 0.5rem;
margin-right: 0.5rem; border-radius: var(--radius-md);
vertical-align: middle; color: var(--popover-foreground);
} }
.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover,
.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] {
background: var(--accent);
color: var(--accent-foreground);
}
.cm-tooltip.cm-tooltip-autocomplete .cm-completionIcon {
display: none;
}
.cm-tooltip.cm-tooltip-autocomplete .cm-completionLabel {
overflow: hidden;
text-overflow: ellipsis;
}
.cm-tooltip.cm-tooltip-autocomplete .cm-completionMatchedText {
color: inherit;
font-weight: 600;
text-decoration: none;
}
.cm-tooltip-autocomplete .tm-md-completion-emoji {
display: inline-block;
flex: 0 0 auto;
width: 1.35rem;
height: 1.35rem;
margin-right: 0.5rem;
vertical-align: middle;
}
} }

View file

@ -22,6 +22,8 @@ import {
EditorSelection, EditorSelection,
EditorState, EditorState,
Prec, Prec,
StateEffect,
StateField,
Transaction, Transaction,
type Extension, type Extension,
type Range, type Range,
@ -42,6 +44,7 @@ import { useEffect, useRef, type CSSProperties } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { CodeBlock } from "./code-block";
import Emoji from "./emoji"; import Emoji from "./emoji";
import { useEmojiRegistry } from "./emoji-context"; import { useEmojiRegistry } from "./emoji-context";
import { import {
@ -52,6 +55,8 @@ import {
searchEmojis, searchEmojis,
type EmojiRegistry, type EmojiRegistry,
} from "./emoji-data"; } from "./emoji-data";
import { parseCodeMeta } from "./markdown-meta";
import { highlightCodeTokens } from "./shiki";
export const MAX_RENDERED_EMOJI_OPTIONS = 100; export const MAX_RENDERED_EMOJI_OPTIONS = 100;
@ -204,10 +209,253 @@ const emDecoration = Decoration.mark({ class: "tm-md-em" });
const delDecoration = Decoration.mark({ class: "tm-md-del" }); const delDecoration = Decoration.mark({ class: "tm-md-del" });
const codeDecoration = Decoration.mark({ class: "tm-md-code" }); const codeDecoration = Decoration.mark({ class: "tm-md-code" });
const linkDecoration = Decoration.mark({ class: "tm-md-link" }); const linkDecoration = Decoration.mark({ class: "tm-md-link" });
const codeLineDecoration = Decoration.line({ class: "tm-md-code-line" });
const externalValueSync = Annotation.define<boolean>(); const externalValueSync = Annotation.define<boolean>();
const widgetRoots = new WeakMap<HTMLElement, Root>(); const widgetRoots = new WeakMap<HTMLElement, Root>();
function destroyWidgetRoot(dom: HTMLElement) {
const root = widgetRoots.get(dom);
if (!root) return;
widgetRoots.delete(dom);
setTimeout(() => root.unmount(), 0);
}
type FencedCodeRange = {
closingMark?: { from: number; to: number };
code?: { from: number; to: number };
info?: { from: number; to: number };
openingMark: { from: number; to: number };
revealFrom: number;
revealTo: number;
};
function fencedCodeRanges(state: EditorState): FencedCodeRange[] {
const ranges: FencedCodeRange[] = [];
syntaxTree(state).iterate({
enter(node) {
if (node.name !== "FencedCode") return;
const marks = node.node.getChildren("CodeMark");
const openingMark = marks[0];
if (!openingMark) return false;
const info = node.node.getChild("CodeInfo");
const code = node.node.getChild("CodeText");
ranges.push({
closingMark: marks[1]
? { from: marks[1].from, to: marks[1].to }
: undefined,
code: code ? { from: code.from, to: code.to } : undefined,
info: info ? { from: info.from, to: info.to } : undefined,
openingMark: { from: openingMark.from, to: openingMark.to },
revealFrom: state.doc.lineAt(node.from).from,
revealTo: state.doc.lineAt(node.to).to,
});
return false;
},
});
return ranges;
}
function selectionTouchesFencedCode(state: EditorState) {
const blocks = fencedCodeRanges(state);
return state.selection.ranges.some((selection) =>
blocks.some(
(block) =>
selection.from <= block.revealTo && selection.to >= block.revealFrom,
),
);
}
function enterAdjacentFencedCode(view: EditorView, direction: "down" | "up") {
const selection = view.state.selection.main;
if (!selection.empty || view.state.selection.ranges.length !== 1)
return false;
const currentLine = view.state.doc.lineAt(selection.head).number;
const block = fencedCodeRanges(view.state).find((candidate) => {
const firstLine = view.state.doc.lineAt(candidate.revealFrom).number;
const lastLine = view.state.doc.lineAt(candidate.revealTo).number;
return direction === "down"
? firstLine === currentLine + 1
: lastLine === currentLine - 1;
});
if (!block) return false;
const cursor =
direction === "down"
? block.openingMark.from
: (block.closingMark?.from ?? block.code?.to ?? block.openingMark.to);
view.dispatch({
selection: EditorSelection.cursor(cursor),
scrollIntoView: true,
});
return true;
}
class CodeBlockWidget extends WidgetType {
readonly code: string;
readonly codeFrom: number;
readonly cursor: number;
readonly filename?: string;
readonly highlightedLines: ReadonlySet<number>;
readonly highlightedLineKey: string;
readonly language?: string;
readonly showLineNumbers: boolean;
constructor(state: EditorState, block: FencedCodeRange) {
super();
const info = block.info
? state.sliceDoc(block.info.from, block.info.to).trim()
: "";
const meta = parseCodeMeta(info);
this.code = block.code
? state.sliceDoc(block.code.from, block.code.to)
: "";
this.codeFrom = block.code?.from ?? block.openingMark.to;
this.cursor = block.code?.from ?? block.openingMark.to;
this.filename = meta.filename;
this.highlightedLines = meta.highlightedLines;
this.highlightedLineKey = [...meta.highlightedLines]
.sort((left, right) => left - right)
.join(",");
this.language = info.split(/\s+/, 1)[0] || undefined;
this.showLineNumbers = meta.showLineNumbers;
}
eq(other: CodeBlockWidget) {
return (
this.code === other.code &&
this.codeFrom === other.codeFrom &&
this.cursor === other.cursor &&
this.filename === other.filename &&
this.highlightedLineKey === other.highlightedLineKey &&
this.language === other.language &&
this.showLineNumbers === other.showLineNumbers
);
}
toDOM(view: EditorView) {
const container = document.createElement("div");
container.className = "group/markdown tm-md-rendered-code-block";
container.dataset.variant = "compact";
const root = createRoot(container);
root.render(
<CodeBlock
code={this.code}
filename={this.filename}
highlightedLines={this.highlightedLines}
language={this.language}
showLineNumbers={this.showLineNumbers}
/>,
);
container.addEventListener("mousedown", (event) => {
if (
event.target instanceof Element &&
event.target.closest('[data-slot="code-block-copy"]')
) {
return;
}
event.preventDefault();
const codeOffset = codeOffsetAtPoint(container, event);
view.dispatch({
selection: EditorSelection.cursor(
codeOffset === undefined
? this.cursor
: this.codeFrom + Math.min(codeOffset, this.code.length),
),
scrollIntoView: true,
});
view.focus();
});
widgetRoots.set(container, root);
return container;
}
destroy(dom: HTMLElement) {
destroyWidgetRoot(dom);
}
ignoreEvent() {
return true;
}
}
function codeOffsetAtPoint(container: HTMLElement, event: MouseEvent) {
const code = container.querySelector("code");
if (!code) return undefined;
const position = document.caretPositionFromPoint?.(
event.clientX,
event.clientY,
);
let node = position?.offsetNode;
let offset = position?.offset;
if (!node || offset === undefined) {
const range = (
document as Document & {
caretRangeFromPoint?: (
x: number,
y: number,
) => ReturnType<Document["createRange"]> | null;
}
).caretRangeFromPoint?.(event.clientX, event.clientY);
node = range?.startContainer;
offset = range?.startOffset;
}
if (!node || offset === undefined || !code.contains(node)) return undefined;
const prefix = document.createRange();
prefix.selectNodeContents(code);
prefix.setEnd(node, offset);
return prefix.toString().length;
}
function buildCodeBlockDecorations(state: EditorState, focused: boolean) {
const builder: Range<Decoration>[] = [];
const selections = state.selection.ranges.map((range) => ({
from: range.from,
to: range.to,
}));
for (const block of fencedCodeRanges(state)) {
if (
focused &&
selections.some((selection) =>
overlapsRange(selection, block.revealFrom, block.revealTo),
)
) {
continue;
}
builder.push(
Decoration.replace({
block: true,
widget: new CodeBlockWidget(state, block),
}).range(block.revealFrom, block.revealTo),
);
}
return Decoration.set(builder, true);
}
const codeBlockFocusEffect = StateEffect.define<boolean>();
type CodeBlockDecorationState = {
decorations: DecorationSet;
focused: boolean;
};
const codeBlockDecorations = StateField.define<CodeBlockDecorationState>({
create: (state) => ({
decorations: buildCodeBlockDecorations(state, false),
focused: false,
}),
update: (value, transaction) => {
const focusEffect = transaction.effects.find((effect) =>
effect.is(codeBlockFocusEffect),
);
const focused = focusEffect?.value ?? value.focused;
return {
decorations: buildCodeBlockDecorations(transaction.state, focused),
focused,
};
},
provide: (field) =>
EditorView.decorations.from(field, (value) => value.decorations),
});
type EmojiRange = { type EmojiRange = {
from: number; from: number;
shortcode: string; shortcode: string;
@ -250,8 +498,7 @@ class EmojiWidget extends WidgetType {
} }
destroy(dom: HTMLElement) { destroy(dom: HTMLElement) {
widgetRoots.get(dom)?.unmount(); destroyWidgetRoot(dom);
widgetRoots.delete(dom);
} }
ignoreEvent() { ignoreEvent() {
@ -396,17 +643,123 @@ const markdownDecorations = ViewPlugin.fromClass(
class { class {
decorations: DecorationSet; decorations: DecorationSet;
constructor(view: EditorView) { constructor(view: EditorView) {
this.decorations = buildMarkdownDecorations(view.state); this.decorations = buildMarkdownDecorations(view.state, view.hasFocus);
} }
update(update: ViewUpdate) { update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet || update.viewportChanged) { if (
this.decorations = buildMarkdownDecorations(update.state); update.docChanged ||
update.selectionSet ||
update.viewportChanged ||
update.focusChanged
) {
this.decorations = buildMarkdownDecorations(
update.state,
update.view.hasFocus,
);
} }
} }
}, },
{ decorations: (instance) => instance.decorations }, { decorations: (instance) => instance.decorations },
); );
const codeHighlightEffect = StateEffect.define<{
decorations: DecorationSet;
document: string;
}>();
async function buildCodeHighlightDecorations(state: EditorState) {
const highlighted = await Promise.all(
fencedCodeRanges(state).map(async (block) => {
if (!block.code || !block.info) return [];
const language = state
.sliceDoc(block.info.from, block.info.to)
.trim()
.split(/\s+/, 1)[0];
if (!language) return [];
const tokens = await highlightCodeTokens(
state.sliceDoc(block.code.from, block.code.to),
language,
);
if (!tokens) return [];
return tokens.flatMap((token) => {
if (!token.content || token.content.includes("\n")) return [];
const from = block.code!.from + token.offset;
const to = from + token.content.length;
if (from >= to || to > block.code!.to) return [];
const styles = token.color ? [`color:${token.color}`] : [];
if (token.fontStyle && token.fontStyle > 0) {
if (token.fontStyle & 1) styles.push("font-style:italic");
if (token.fontStyle & 2) styles.push("font-weight:bold");
if (token.fontStyle & 4) styles.push("text-decoration:underline");
}
if (styles.length === 0) return [];
return Decoration.mark({
attributes: { style: styles.join(";") },
}).range(from, to);
});
}),
);
return Decoration.set(highlighted.flat(), true);
}
const codeHighlightDecorations = ViewPlugin.fromClass(
class {
decorations = Decoration.none;
private request = 0;
private timeout: ReturnType<typeof setTimeout> | undefined;
constructor(view: EditorView) {
this.schedule(view, 0);
}
update(update: ViewUpdate) {
for (const transaction of update.transactions) {
for (const effect of transaction.effects) {
if (
effect.is(codeHighlightEffect) &&
effect.value.document === update.state.doc.toString()
) {
this.decorations = effect.value.decorations;
}
}
}
if (!update.docChanged) return;
this.decorations = this.decorations.map(update.changes);
this.schedule(update.view, 30);
}
destroy() {
this.request += 1;
if (this.timeout !== undefined) clearTimeout(this.timeout);
}
private schedule(view: EditorView, delay: number) {
this.request += 1;
const request = this.request;
if (this.timeout !== undefined) clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
this.timeout = undefined;
const state = view.state;
const document = state.doc.toString();
void buildCodeHighlightDecorations(state).then((decorations) => {
setTimeout(() => {
if (
request !== this.request ||
view.state.doc.toString() !== document
)
return;
view.dispatch({
effects: codeHighlightEffect.of({ decorations, document }),
});
}, 0);
});
}, delay);
}
},
{ decorations: (instance) => instance.decorations },
);
export default function Input(props: InputProps) { export default function Input(props: InputProps) {
const registry = useEmojiRegistry(props.registry); const registry = useEmojiRegistry(props.registry);
const elementRef = useRef<HTMLDivElement | null>(null); const elementRef = useRef<HTMLDivElement | null>(null);
@ -510,7 +863,7 @@ export default function Input(props: InputProps) {
<div <div
ref={elementRef} ref={elementRef}
className={cn( className={cn(
"tm-md-root min-h-8 w-full min-w-0 rounded-lg border border-input bg-transparent text-base transition-colors outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", "tm-md-root min-h-8 w-full min-w-0 text-base outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
props.className, props.className,
)} )}
style={ style={
@ -548,6 +901,7 @@ function createEditorExtensions(
run: (view) => { run: (view) => {
if (completionStatus(view.state) === "active") if (completionStatus(view.state) === "active")
return acceptCompletion(view); return acceptCompletion(view);
if (selectionTouchesFencedCode(view.state)) return false;
if (!getInvertEnterBehavior()) return false; if (!getInvertEnterBehavior()) return false;
onSubmit(); onSubmit();
return true; return true;
@ -558,6 +912,7 @@ function createEditorExtensions(
run: (view) => { run: (view) => {
if (completionStatus(view.state) === "active") if (completionStatus(view.state) === "active")
return acceptCompletion(view); return acceptCompletion(view);
if (selectionTouchesFencedCode(view.state)) return false;
if (getInvertEnterBehavior()) return false; if (getInvertEnterBehavior()) return false;
onSubmit(); onSubmit();
return true; return true;
@ -573,6 +928,16 @@ function createEditorExtensions(
: false, : false,
}, },
]); ]);
const codeBlockNavigationKeymap = keymap.of([
{
key: "ArrowDown",
run: (view) => enterAdjacentFencedCode(view, "down"),
},
{
key: "ArrowUp",
run: (view) => enterAdjacentFencedCode(view, "up"),
},
]);
const emojiDeletionKeymap = keymap.of([ const emojiDeletionKeymap = keymap.of([
{ {
key: "Backspace", key: "Backspace",
@ -587,10 +952,12 @@ function createEditorExtensions(
return [ return [
history(), history(),
markdown(), markdown(),
codeBlockDecorations,
emojiCompartment.of( emojiCompartment.of(
createEmojiExtensions(emojiFrequencies, onEmojiSelect, registry), createEmojiExtensions(emojiFrequencies, onEmojiSelect, registry),
), ),
keymap.of(editorKeymap), keymap.of(editorKeymap),
Prec.highest(codeBlockNavigationKeymap),
Prec.highest(completionTabKeymap), Prec.highest(completionTabKeymap),
Prec.highest(emojiDeletionKeymap), Prec.highest(emojiDeletionKeymap),
Prec.highest(customEnterKeymap), Prec.highest(customEnterKeymap),
@ -616,7 +983,16 @@ function createEditorExtensions(
spellcheck: "true", spellcheck: "true",
"aria-label": "Markdown input", "aria-label": "Markdown input",
}), }),
EditorView.domEventHandlers({
focus(_event, view) {
view.dispatch({ effects: codeBlockFocusEffect.of(true) });
},
blur(_event, view) {
view.dispatch({ effects: codeBlockFocusEffect.of(false) });
},
}),
markdownDecorations, markdownDecorations,
codeHighlightDecorations,
]; ];
} }
@ -756,13 +1132,56 @@ export function createEmojiCompletionSource(
export const emojiCompletionSource = createEmojiCompletionSource(); export const emojiCompletionSource = createEmojiCompletionSource();
export function buildMarkdownDecorations(state: EditorState): DecorationSet { export function buildMarkdownDecorations(
state: EditorState,
revealSelection = true,
): DecorationSet {
const builder: Range<Decoration>[] = []; const builder: Range<Decoration>[] = [];
const selections = state.selection.ranges.map((range: SelectionRange) => ({ const selections = revealSelection
? state.selection.ranges.map((range: SelectionRange) => ({
from: range.from,
to: range.to,
}))
: [];
const codeSelections = state.selection.ranges.map((range) => ({
from: range.from, from: range.from,
to: range.to, to: range.to,
})); }));
let codeFenceOpen = false; const codeLines = new Map<
number,
{ end: boolean; number?: number; start: boolean }
>();
const fencedLines = new Set<number>();
for (const block of fencedCodeRanges(state)) {
const active = codeSelections.some((selection) =>
overlapsRange(selection, block.revealFrom, block.revealTo),
);
const firstLine = state.doc.lineAt(block.revealFrom).number;
const lastLine = state.doc.lineAt(block.revealTo).number;
const firstCodeLine = block.code
? state.doc.lineAt(block.code.from).number
: undefined;
const lastCodeLine = block.code
? state.doc.lineAt(Math.max(block.code.from, block.code.to - 1)).number
: undefined;
for (let line = firstLine; line <= lastLine; line += 1) {
fencedLines.add(line);
if (active) {
codeLines.set(line, {
end: line === lastLine,
number:
firstCodeLine !== undefined &&
lastCodeLine !== undefined &&
line >= firstCodeLine &&
line <= lastCodeLine
? line - firstCodeLine + 1
: undefined,
start: line === firstLine,
});
}
}
}
for (let lineNumber = 1; lineNumber <= state.doc.lines; lineNumber += 1) { for (let lineNumber = 1; lineNumber <= state.doc.lines; lineNumber += 1) {
const line = state.doc.line(lineNumber); const line = state.doc.line(lineNumber);
@ -775,21 +1194,24 @@ export function buildMarkdownDecorations(state: EditorState): DecorationSet {
revealTo: line.to, revealTo: line.to,
to, to,
}); });
const fence = text.match(/^```\s*([^`]*)$/); const codeLine = codeLines.get(lineNumber);
if (fence) { if (codeLine) {
const ticksStart = lineFrom + text.indexOf("```"); builder.push(
const ticksEnd = ticksStart + 3; Decoration.line({
addHiddenToken(builder, selections, lineToken(ticksStart, ticksEnd)); attributes: {
if (trimmed.length > 3) { "data-code-line":
addHiddenToken(builder, selections, lineToken(ticksEnd, line.to)); codeLine.number === undefined ? "" : String(codeLine.number),
} },
codeFenceOpen = !codeFenceOpen; class: cn(
continue; "tm-md-code-line",
} codeLine.start && "tm-md-code-line-start",
if (codeFenceOpen) { codeLine.end && "tm-md-code-line-end",
builder.push(codeLineDecoration.range(lineFrom)); ),
}).range(lineFrom),
);
continue; continue;
} }
if (fencedLines.has(lineNumber)) continue;
const heading = text.match(/^(#{1,6})\s+/); const heading = text.match(/^(#{1,6})\s+/);
if (heading) { if (heading) {
@ -900,15 +1322,23 @@ function addHiddenToken(
token: InlineTokenRange, token: InlineTokenRange,
) { ) {
if (token.from >= token.to) return false; if (token.from >= token.to) return false;
const overlapsSelection = selections.some((selection) => { const overlapsSelection = selections.some((selection) =>
const selectionFrom = Math.min(selection.from, selection.to); overlapsRange(selection, token.revealFrom, token.revealTo),
const selectionTo = Math.max(selection.from, selection.to); );
return selectionFrom === selectionTo
? selectionFrom >= token.revealFrom && selectionFrom <= token.revealTo
: selectionFrom < token.revealTo && selectionTo > token.revealFrom;
});
if (!overlapsSelection) { if (!overlapsSelection) {
builder.push(hiddenTokenDecoration.range(token.from, token.to)); builder.push(hiddenTokenDecoration.range(token.from, token.to));
} }
return !overlapsSelection; return !overlapsSelection;
} }
function overlapsRange(
selection: { from: number; to: number },
from: number,
to: number,
) {
const selectionFrom = Math.min(selection.from, selection.to);
const selectionTo = Math.max(selection.from, selection.to);
return selectionFrom === selectionTo
? selectionFrom >= from && selectionFrom <= to
: selectionFrom < to && selectionTo > from;
}

View file

@ -30,7 +30,7 @@ export function parseCodeMeta(meta?: string): CodeMeta {
return { return {
filename: filenameMatch?.[1] ?? filenameMatch?.[2], filename: filenameMatch?.[1] ?? filenameMatch?.[2],
highlightedLines, highlightedLines,
showLineNumbers: /(?:^|\s)showLineNumbers(?=\s|$)/.test(meta ?? ""), showLineNumbers: true,
}; };
} }

View file

@ -145,9 +145,9 @@ function Markdown({
> = [ > = [
remarkGfm, remarkGfm,
remarkMath, remarkMath,
remarkBreaks,
remarkCodeMeta, remarkCodeMeta,
[remarkEmoji, { registry }], [remarkEmoji, { registry }],
...(variant === "compact" ? [remarkBreaks] : []),
]; ];
return ( return (

View file

@ -10,11 +10,18 @@ import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
const THEME_NAME = "methanium-css"; const THEME_NAME = "methanium-css";
const CACHE_LIMIT = 200; const CACHE_LIMIT = 200;
export type HighlightedCodeToken = {
color?: string;
content: string;
fontStyle?: number;
offset: number;
};
export const shikiPreClassName = export const shikiPreClassName =
"shiki m-0 w-max min-w-full bg-[var(--markdown-code-background)]! p-4 font-mono text-[0.82rem]/[1.7] text-[var(--markdown-code-foreground)]! [tab-size:2]"; "shiki m-0 w-max min-w-full bg-[var(--markdown-code-background)]! font-mono text-[0.82rem]/[1.7] text-[var(--markdown-code-foreground)]! [tab-size:2]";
const shikiLineClassNames = export const shikiLineClassNames =
"line -mx-4 inline-block min-w-full px-4 group-data-[line-numbers]/code-block:before:inline-block group-data-[line-numbers]/code-block:before:w-10 group-data-[line-numbers]/code-block:before:pe-4 group-data-[line-numbers]/code-block:before:text-end group-data-[line-numbers]/code-block:before:text-muted-foreground group-data-[line-numbers]/code-block:before:select-none group-data-[line-numbers]/code-block:before:content-[attr(data-line)]"; "line -mx-4 inline-block min-w-full px-4 group-data-[line-numbers]/code-block:before:inline-block group-data-[line-numbers]/code-block:before:w-10 group-data-[line-numbers]/code-block:before:pe-4 group-data-[line-numbers]/code-block:before:text-end group-data-[line-numbers]/code-block:before:text-muted-foreground group-data-[line-numbers]/code-block:before:select-none group-data-[line-numbers]/code-block:before:content-[attr(data-line)]";
const highlightedLineClassNames = export const highlightedLineClassNames =
"bg-primary/12 shadow-[inset_0.18rem_0_var(--primary-foreground-alt)]"; "bg-primary/12 shadow-[inset_0.18rem_0_var(--primary-foreground-alt)]";
const aliases: Record<string, string> = { const aliases: Record<string, string> = {
@ -162,6 +169,27 @@ export function isSupportedLanguage(language?: string) {
return normalized !== undefined && supportedLanguages.has(normalized); return normalized !== undefined && supportedLanguages.has(normalized);
} }
export async function highlightCodeTokens(
code: string,
language: string,
): Promise<HighlightedCodeToken[] | null> {
const normalized = normalizeLanguage(language);
if (!normalized || !supportedLanguages.has(normalized)) return null;
const highlighter = await getHighlighter();
await ensureLanguage(highlighter, normalized);
return highlighter
.codeToTokens(code, { lang: normalized, theme: THEME_NAME })
.tokens.flatMap((line) =>
line.map(({ color, content, fontStyle, offset }) => ({
color,
content,
fontStyle,
offset,
})),
);
}
export async function highlightCode( export async function highlightCode(
code: string, code: string,
language: string, language: string,