learning_ai_common_plat/packages/ai-ui/src/Markdown.tsx
saravanakumardb1 87e3bc490a feat: Wave 9.E (ai-ui@0.6.0) + Wave 13.D.1/.5 (motion@0.2.1)
──────────────────────────────────────────────────────────────────
motion@0.2.1 — Parallax + TiltGallery
──────────────────────────────────────────────────────────────────
  + Parallax.tsx
      - scroll-driven translate3d via rAF + window.scroll
      - speed multiplier + axis (y / x) + reduced-motion bypass
      - listener cleanup + cancelAnimationFrame on unmount
      - WAVE 13.D.1
  + TiltGallery.tsx
      - horizontally-scrolling rail of <TiltCard>-style tiles
      - per-tile cursor-tracking rotateX/Y + glare gradient
      - role=region + arrow-key scrolling (←/→) + scroll-snap
      - reduced-motion strips tilt + glare, keeps the rail
      - WAVE 13.D.5
  + 5 new tests (Parallax x 2, TiltGallery x 3) — 28/28 passing
  + index.ts: exports both + types
  + package.json: 0.2.0 → 0.2.1

──────────────────────────────────────────────────────────────────
ai-ui@0.6.0 — Wave 9.E composition surfaces
──────────────────────────────────────────────────────────────────
  + Markdown.tsx
      - dep-free subset renderer: h1-h3 / **bold** / *italic* /
        `code` / fenced code / ul + ol / [text](url)
      - inline `[cite:<id>]` chips resolved from a citations
        registry (missing ids render as [?] — failure mode is loud)
      - WAVE 9.E.1
  + CodeDiff.tsx
      - line-LCS diff in <100 LOC, zero deps
      - split (2-col) and unified views; tinted add/del rows
      - WAVE 9.E.2
  + ExplainThis.tsx
      - listens for selectionchange, pops 'Explain' CTA over the
        selection rect when inside the wrapper + ≥ minLength chars
      - fires onExplain({ text, rect }) so hosts can open a richer
        side panel if preferred
      - WAVE 9.E.3
  + usePromptHistory.ts
      - bash-style ↑/↓ recall with localStorage persistence
        (storage key configurable; null = in-memory for tests/SSR)
      - dedupes consecutive duplicates + trims to capacity
      - WAVE 9.E.4
  + useTokenCount.ts
      - cheap estimator (default ~4 chars/token; configurable for
        code/CJK) + optional USD cost
      - memoised — stable across re-renders
      - WAVE 9.E.5
  + 19 new tests in src/__tests__/composition.test.tsx — 98/98 passing
  + index.ts: '0.6 surfaces' section exports all 5 + types
  + package.json: 0.5.0 → 0.6.0

Showcase routes + roadmap flips land in the paired showcase commit.
2026-05-27 16:59:28 -07:00

334 lines
8.9 KiB
TypeScript

import { type CSSProperties, type ReactNode } from 'react';
export interface MarkdownCitation {
/** Citation id token matched in the source text — e.g. `doc:42#§3`. */
id: string;
/** Display label shown inside the chip. */
label: string;
/** Optional click handler — typically opens the source document. */
onClick?: () => void;
}
export interface MarkdownProps {
/** Markdown source string. */
source: string;
/**
* Optional citation registry. Inline `[cite:<id>]` tokens are
* replaced with `<CitationChip>`-styled inline chips that look up
* the id here. Missing ids render as `[?]` to keep the failure mode
* obvious.
*/
citations?: MarkdownCitation[];
className?: string;
style?: CSSProperties;
}
/**
* `<Markdown>` — pure-JS, dependency-free subset Markdown renderer with
* inline citation support.
*
* Supports: # / ## / ### headings, **bold**, *italic*, `inline code`,
* ```fenced code```, - / 1. lists, [link](url), `[cite:<id>]`.
*
* Tradeoffs: no GFM tables, no HTML pass-through (intentional for
* safety), no nested lists. Hosts that need richer rendering should
* compose a markdown pipeline (remark/rehype) and feed the result here
* via a custom renderer.
*
* Wave 9.E.1.
*/
export function Markdown({
source,
citations,
className,
style,
}: MarkdownProps) {
const blocks = parseBlocks(source);
const map = new Map<string, MarkdownCitation>(
(citations ?? []).map((c) => [c.id, c]),
);
return (
<div
data-testid="bl-markdown"
className={className}
style={{ fontSize: 14, lineHeight: 1.6, ...style }}
>
{blocks.map((b, i) => renderBlock(b, i, map))}
</div>
);
}
type Block =
| { kind: 'heading'; level: 1 | 2 | 3; text: string }
| { kind: 'paragraph'; text: string }
| { kind: 'code'; lang: string; text: string }
| { kind: 'list'; ordered: boolean; items: string[] };
function parseBlocks(source: string): Block[] {
const lines = source.replace(/\r\n/g, '\n').split('\n');
const out: Block[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line === undefined) {
i += 1;
continue;
}
// Fenced code block.
if (line.startsWith('```')) {
const lang = line.slice(3).trim();
const buf: string[] = [];
i += 1;
while (i < lines.length && !(lines[i] ?? '').startsWith('```')) {
buf.push(lines[i] ?? '');
i += 1;
}
out.push({ kind: 'code', lang, text: buf.join('\n') });
i += 1; // skip closing fence
continue;
}
// Heading.
const h = /^(#{1,3})\s+(.+)$/.exec(line);
if (h) {
const level = h[1]?.length as 1 | 2 | 3;
out.push({ kind: 'heading', level, text: h[2] ?? '' });
i += 1;
continue;
}
// List.
if (/^[-*]\s+/.test(line) || /^\d+\.\s+/.test(line)) {
const ordered = /^\d+\.\s+/.test(line);
const items: string[] = [];
while (
i < lines.length &&
((ordered && /^\d+\.\s+/.test(lines[i] ?? '')) ||
(!ordered && /^[-*]\s+/.test(lines[i] ?? '')))
) {
const m = ordered
? /^\d+\.\s+(.*)$/.exec(lines[i] ?? '')
: /^[-*]\s+(.*)$/.exec(lines[i] ?? '');
items.push(m?.[1] ?? '');
i += 1;
}
out.push({ kind: 'list', ordered, items });
continue;
}
// Blank line.
if (line.trim() === '') {
i += 1;
continue;
}
// Paragraph — consume until blank line or block start.
const buf: string[] = [];
while (
i < lines.length &&
(lines[i] ?? '').trim() !== '' &&
!(lines[i] ?? '').startsWith('```') &&
!/^(#{1,3})\s+/.test(lines[i] ?? '') &&
!/^[-*]\s+/.test(lines[i] ?? '') &&
!/^\d+\.\s+/.test(lines[i] ?? '')
) {
buf.push(lines[i] ?? '');
i += 1;
}
out.push({ kind: 'paragraph', text: buf.join('\n') });
}
return out;
}
function renderBlock(b: Block, key: number, cites: Map<string, MarkdownCitation>): ReactNode {
switch (b.kind) {
case 'heading': {
const Tag = (`h${b.level}` as 'h1' | 'h2' | 'h3') as 'h1';
const sizes = { 1: 22, 2: 18, 3: 16 } as const;
return (
<Tag
key={key}
style={{
fontSize: sizes[b.level],
fontWeight: 600,
margin: '14px 0 6px',
}}
>
{renderInline(b.text, cites)}
</Tag>
);
}
case 'paragraph':
return (
<p key={key} style={{ margin: '6px 0' }}>
{renderInline(b.text, cites)}
</p>
);
case 'code':
return (
<pre
key={key}
data-testid="bl-markdown-code"
data-lang={b.lang || undefined}
style={{
margin: '8px 0',
padding: 12,
borderRadius: 10,
background: 'var(--bl-surface-muted, #f6f6f6)',
color: 'var(--bl-text-primary, #111)',
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: 12,
lineHeight: 1.55,
overflow: 'auto',
}}
>
{b.text}
</pre>
);
case 'list': {
const Tag = b.ordered ? 'ol' : 'ul';
return (
<Tag
key={key}
style={{ margin: '6px 0 6px 20px', paddingLeft: 8 }}
>
{b.items.map((it, j) => (
<li key={j} style={{ margin: '2px 0' }}>
{renderInline(it, cites)}
</li>
))}
</Tag>
);
}
default:
return null;
}
}
/**
* Inline-token renderer. Tokens recognised:
* `code` inline code
* **bold** bold
* *italic* italic
* [text](url) link (opens in same tab, target=_self)
* [cite:<id>] citation chip (resolved from registry)
*
* Implementation is a single pass with regex alternation — order
* matters (inline code first to protect from formatting consumption).
*/
function renderInline(text: string, cites: Map<string, MarkdownCitation>): ReactNode[] {
const out: ReactNode[] = [];
// Tokenizer — single regex picks the FIRST matching pattern.
const tokenRe =
/(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(\[cite:[^\]]+\])|(\[[^\]]+\]\([^)]+\))/g;
let last = 0;
let m: RegExpExecArray | null;
let key = 0;
while ((m = tokenRe.exec(text))) {
if (m.index > last) {
out.push(text.slice(last, m.index));
}
const full = m[0];
if (full.startsWith('`')) {
out.push(
<code
key={key++}
style={{
background: 'var(--bl-surface-muted, #f0f0f0)',
padding: '0 4px',
borderRadius: 4,
fontFamily: 'ui-monospace, monospace',
fontSize: 12,
}}
>
{full.slice(1, -1)}
</code>,
);
} else if (full.startsWith('**')) {
out.push(
<strong key={key++}>{full.slice(2, -2)}</strong>,
);
} else if (full.startsWith('[cite:')) {
const id = full.slice('[cite:'.length, -1);
const c = cites.get(id);
out.push(
<CitationChip
key={key++}
id={id}
label={c?.label}
onClick={c?.onClick}
missing={!c}
/>,
);
} else if (full.startsWith('[')) {
const lm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(full);
if (lm) {
out.push(
<a
key={key++}
href={lm[2]}
style={{
color: 'var(--bl-accent, #6366f1)',
textDecoration: 'underline',
textUnderlineOffset: 2,
}}
>
{lm[1]}
</a>,
);
} else {
out.push(full);
}
} else if (full.startsWith('*')) {
out.push(<em key={key++}>{full.slice(1, -1)}</em>);
}
last = m.index + full.length;
}
if (last < text.length) {
out.push(text.slice(last));
}
return out;
}
function CitationChip({
id,
label,
onClick,
missing,
}: {
id: string;
label?: string;
onClick?: () => void;
missing: boolean;
}) {
const text = missing ? '[?]' : label ?? id;
return (
<button
type="button"
onClick={onClick}
data-testid="bl-markdown-citation"
data-id={id}
data-missing={missing ? 'true' : 'false'}
aria-label={`Citation ${id}`}
disabled={!onClick}
style={{
display: 'inline-block',
margin: '0 2px',
padding: '0 6px',
borderRadius: 999,
border: '1px solid color-mix(in srgb, var(--bl-accent, #6366f1) 35%, transparent)',
background: missing
? 'var(--bl-surface-muted, #f0f0f0)'
: 'color-mix(in srgb, var(--bl-accent, #6366f1) 14%, transparent)',
color: missing
? 'var(--bl-text-tertiary, #888)'
: 'var(--bl-accent, #4338ca)',
font: 'inherit',
fontSize: 11,
fontWeight: 500,
cursor: onClick ? 'pointer' : 'default',
verticalAlign: 'baseline',
}}
>
{text}
</button>
);
}