{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pretext-md",
  "title": "pretext-md",
  "description": "Measured markdown renderer powered by pretext. DOM-free text measurement, plugin architecture, virtualizable.",
  "type": "registry:block",
  "dependencies": [
    "@chenglou/pretext",
    "unified",
    "remark-parse",
    "remark-gfm"
  ],
  "registryDependencies": [
    "https://inference.sh/ui/r/code-block.json",
    "https://inference.sh/ui/r/youtube-embed.json",
    "https://inference.sh/ui/r/zoomable-image.json"
  ],
  "files": [
    {
      "path": "lib/pretext-md/core/types.ts",
      "content": "// Layout IR types for pretext-md\n// These types bridge the gap between mdast (remark's AST) and pretext measurement.\n\n// --- Font configuration ---\n\nexport type FontConfig = {\n  body: string       // e.g. '14px \"Inter\", sans-serif'\n  bold: string       // e.g. 'bold 14px \"Inter\", sans-serif'\n  italic: string     // e.g. 'italic 14px \"Inter\", sans-serif'\n  boldItalic: string // e.g. 'bold italic 14px \"Inter\", sans-serif'\n  code: string       // e.g. '13px \"Fira Code\", monospace'\n  h1: string\n  h2: string\n  h3: string\n  h4: string\n  h5: string\n  h6: string\n}\n\nexport type LineHeightConfig = {\n  body: number    // e.g. 20\n  code: number    // e.g. 18\n  h1: number      // e.g. 32\n  h2: number      // e.g. 28\n  h3: number      // e.g. 24\n  h4: number\n  h5: number\n  h6: number\n}\n\nexport type MeasureConfig = {\n  maxWidth: number\n  fonts: FontConfig\n  lineHeights: LineHeightConfig\n  plugins?: EmbedPlugin[]\n  // Internal — used by container plugins to thread nesting state\n  _quoteRails?: number[]\n  _contentLeft?: number\n}\n\n// --- Block-level nodes (parser output) ---\n\nexport type BlockNode =\n  | ParagraphNode\n  | HeadingNode\n  | CodeBlockNode\n  | BlockquoteNode\n  | ListNode\n  | ThematicBreakNode\n  | ImageNode\n  | TableNode\n\nexport type ParagraphNode = {\n  kind: 'paragraph'\n  items: InlineItem[]\n}\n\nexport type HeadingNode = {\n  kind: 'heading'\n  level: 1 | 2 | 3 | 4 | 5 | 6\n  items: InlineItem[]\n}\n\nexport type CodeBlockNode = {\n  kind: 'code-block'\n  lang: string\n  code: string\n  meta?: string\n}\n\nexport type BlockquoteNode = {\n  kind: 'blockquote'\n  children: BlockNode[]\n}\n\nexport type ListNode = {\n  kind: 'list'\n  ordered: boolean\n  start?: number\n  items: BlockNode[][]\n}\n\nexport type ThematicBreakNode = {\n  kind: 'hr'\n}\n\nexport type ImageNode = {\n  kind: 'image'\n  src: string\n  alt?: string\n}\n\nexport type TableAlignType = 'left' | 'center' | 'right' | null\n\nexport type TableNode = {\n  kind: 'table'\n  align: TableAlignType[]\n  rows: InlineItem[][][] // rows → cells → inline items\n}\n\n// --- Inline items (within paragraphs/headings) ---\n\nexport type InlineItem =\n  | TextItem\n  | CodeItem\n  | LinkItem\n  | BreakItem\n\nexport type TextItem = {\n  kind: 'text'\n  text: string\n  font: FontStyle\n}\n\nexport type CodeItem = {\n  kind: 'code'\n  text: string\n}\n\nexport type LinkItem = {\n  kind: 'link'\n  items: InlineItem[]\n  href: string\n}\n\nexport type BreakItem = {\n  kind: 'break'\n}\n\n// font style resolved during inline layout based on FontConfig\nexport type FontStyle = 'body' | 'bold' | 'italic' | 'boldItalic' | 'strikethrough'\n\n// --- Measured output ---\n\nexport type MeasuredBlock = {\n  node: BlockNode\n  height: number\n  y: number\n  lines?: MeasuredLine[]          // for paragraph/heading/code blocks\n  children?: MeasuredBlock[]      // for blockquote inner blocks\n  items?: MeasuredBlock[][]       // for list items (each item = array of measured blocks)\n  // Marker/rail positioning — measured coordinates for precise rendering\n  marker?: { text: string; x: number }       // list bullet/number position\n  quoteRails?: number[]                       // blockquote rail x-positions (one per nesting level)\n  contentLeft?: number                        // x-offset where content starts (after markers/rails)\n}\n\nexport type MeasuredLine = {\n  fragments: LineFragment[]\n  width: number\n  y: number // offset within block\n}\n\nexport type LineFragment = {\n  text: string\n  width: number\n  font: string      // resolved CSS font string\n  fontStyle: FontStyle\n  leadingGap: number // marginLeft gap before this fragment\n  href?: string\n  isCode?: boolean\n  isStrikethrough?: boolean\n}\n\nexport type MeasureResult = {\n  height: number\n  lineCount: number\n  blocks: MeasuredBlock[]\n}\n\n// --- Plugin system ---\n\nexport type EmbedMeasurement =\n  | { kind: 'fixed'; height: number }\n  | { kind: 'computed'; height: number }\n  | { kind: 'aspect-ratio'; ratio: number; maxHeight?: number }\n\n/** Context passed to plugins for recursive measurement. */\nexport type PluginContext = {\n  measureBlocks: (blocks: BlockNode[], config: MeasureConfig) => MeasureResult\n  config: MeasureConfig\n}\n\nexport type EmbedPlugin = {\n  name: string\n  match: (node: BlockNode) => boolean\n  /** Simple measurement — returns a height. For leaf blocks (code, image, hr). */\n  measure: (node: any, maxWidth: number, ctx?: PluginContext) => EmbedMeasurement\n  /** Full measurement — returns MeasuredBlock with children/lines. For container blocks (blockquote, list). */\n  measureBlock?: (node: any, ctx: PluginContext) => MeasuredBlock\n}\n",
      "type": "registry:lib",
      "target": "lib/pretext-md/core/types.ts"
    },
    {
      "path": "lib/pretext-md/core/parser.ts",
      "content": "// mdast walker — converts remark AST to our layout IR (BlockNode/InlineItem)\n\nimport { unified } from 'unified'\nimport remarkParse from 'remark-parse'\nimport remarkGfm from 'remark-gfm'\nimport type {\n  BlockNode,\n  InlineItem,\n  FontStyle,\n} from './types'\nimport type {\n  Root,\n  Content,\n  PhrasingContent,\n  Paragraph,\n  Heading,\n  Code,\n  Blockquote,\n  List,\n  ListItem,\n  ThematicBreak,\n  Text,\n  Emphasis,\n  Strong,\n  InlineCode,\n  Link,\n  Delete,\n  Break,\n  Image,\n  Table,\n  TableRow,\n  TableCell,\n  AlignType,\n} from 'mdast'\n\nconst parser = unified().use(remarkParse).use(remarkGfm)\n\nexport function parse(markdown: string): BlockNode[] {\n  // Strip HTML comments before parsing (same as MarkdownRenderer)\n  const cleaned = markdown.replace(/<!--[\\s\\S]*?-->/g, '')\n  const tree = parser.parse(cleaned) as Root\n  return walkBlocks(tree.children)\n}\n\nfunction walkBlocks(nodes: Content[]): BlockNode[] {\n  const blocks: BlockNode[] = []\n  for (const node of nodes) {\n    const block = walkBlock(node)\n    if (block) blocks.push(block)\n  }\n  return blocks\n}\n\nfunction walkBlock(node: Content): BlockNode | null {\n  switch (node.type) {\n    case 'paragraph':\n      return walkParagraph(node as Paragraph)\n    case 'heading':\n      return walkHeading(node as Heading)\n    case 'code':\n      return walkCode(node as Code)\n    case 'blockquote':\n      return walkBlockquote(node as Blockquote)\n    case 'list':\n      return walkList(node as List)\n    case 'thematicBreak':\n      return { kind: 'hr' } as const\n    case 'table':\n      return walkTable(node as Table)\n    default:\n      return null\n  }\n}\n\nfunction walkParagraph(node: Paragraph): BlockNode {\n  // Image-only paragraph → promote to block-level image\n  if (node.children.length === 1 && node.children[0]!.type === 'image') {\n    const img = node.children[0] as Image\n    return { kind: 'image', src: img.url, alt: img.alt ?? undefined }\n  }\n  return {\n    kind: 'paragraph',\n    items: walkInlines(node.children, 'body'),\n  }\n}\n\nfunction walkHeading(node: Heading): BlockNode {\n  return {\n    kind: 'heading',\n    level: node.depth as 1 | 2 | 3 | 4 | 5 | 6,\n    items: walkInlines(node.children, 'body'),\n  }\n}\n\nfunction walkCode(node: Code): BlockNode {\n  return {\n    kind: 'code-block',\n    lang: node.lang ?? '',\n    code: node.value,\n    meta: node.meta ?? undefined,\n  }\n}\n\nfunction walkBlockquote(node: Blockquote): BlockNode {\n  return {\n    kind: 'blockquote',\n    children: walkBlocks(node.children),\n  }\n}\n\nfunction walkList(node: List): BlockNode {\n  return {\n    kind: 'list',\n    ordered: node.ordered ?? false,\n    start: node.start ?? undefined,\n    items: node.children.map((item: ListItem) => walkBlocks(item.children)),\n  }\n}\n\nfunction walkTable(node: Table): BlockNode {\n  const align = (node.align ?? []).map((a: AlignType | null | undefined) =>\n    a === 'left' || a === 'center' || a === 'right' ? a : null,\n  )\n  const rows = node.children.map((row: TableRow) =>\n    row.children.map((cell: TableCell) =>\n      walkInlines(cell.children as PhrasingContent[], 'body'),\n    ),\n  )\n  return { kind: 'table', align, rows }\n}\n\n// --- Inline walking ---\n\nfunction walkInlines(nodes: PhrasingContent[], fontStyle: FontStyle): InlineItem[] {\n  const items: InlineItem[] = []\n  for (const node of nodes) {\n    walkInline(node, fontStyle, items)\n  }\n  return items\n}\n\nfunction walkInline(node: PhrasingContent, fontStyle: FontStyle, out: InlineItem[]): void {\n  switch (node.type) {\n    case 'text':\n      out.push({ kind: 'text', text: (node as Text).value, font: fontStyle })\n      break\n    case 'strong':\n      walkInlineChildren((node as Strong).children, applyBold(fontStyle), out)\n      break\n    case 'emphasis':\n      walkInlineChildren((node as Emphasis).children, applyItalic(fontStyle), out)\n      break\n    case 'delete':\n      walkInlineChildren((node as Delete).children, 'strikethrough', out)\n      break\n    case 'inlineCode':\n      out.push({ kind: 'code', text: (node as InlineCode).value })\n      break\n    case 'link':\n      out.push({\n        kind: 'link',\n        href: (node as Link).url,\n        items: walkInlines((node as Link).children, fontStyle),\n      })\n      break\n    case 'break':\n      out.push({ kind: 'break' })\n      break\n    default:\n      // skip unknown inline nodes\n      break\n  }\n}\n\nfunction walkInlineChildren(nodes: PhrasingContent[], fontStyle: FontStyle, out: InlineItem[]): void {\n  for (const node of nodes) {\n    walkInline(node, fontStyle, out)\n  }\n}\n\nfunction applyBold(current: FontStyle): FontStyle {\n  if (current === 'italic' || current === 'boldItalic') return 'boldItalic'\n  return 'bold'\n}\n\nfunction applyItalic(current: FontStyle): FontStyle {\n  if (current === 'bold' || current === 'boldItalic') return 'boldItalic'\n  return 'italic'\n}\n",
      "type": "registry:lib",
      "target": "lib/pretext-md/core/parser.ts"
    },
    {
      "path": "lib/pretext-md/core/inline-layout.ts",
      "content": "// Mixed-font inline layout engine\n//\n// Based on chenglou's rich-note demo pattern:\n// - Flatten inline items to prepared runs\n// - Layout lines greedily with layoutNextLine per run\n// - Use leadingGap (marginLeft) for inter-fragment spacing instead of absolute x\n// - Trim text, convert boundary whitespace to measured gaps\n\nimport {\n  prepareWithSegments,\n  layoutNextLine,\n  walkLineRanges,\n  type PreparedTextWithSegments,\n  type LayoutCursor,\n} from '@chenglou/pretext'\nimport type {\n  InlineItem,\n  FontConfig,\n  FontStyle,\n  MeasuredLine,\n  LineFragment,\n} from './types'\n\n// --- Internal types ---\n\ntype PreparedTextItem = {\n  kind: 'text'\n  font: string\n  fontStyle: FontStyle\n  chromeWidth: number\n  endCursor: LayoutCursor\n  fullText: string\n  fullWidth: number\n  leadingGap: number\n  prepared: PreparedTextWithSegments\n  href?: string\n  isCode?: boolean\n  isStrikethrough?: boolean\n}\n\ntype PreparedBreakItem = {\n  kind: 'break'\n}\n\ntype PreparedItem = PreparedTextItem | PreparedBreakItem\n\n// --- Constants ---\n\nconst LINE_START: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 }\nconst UNBOUNDED = 100_000\nconst CODE_CHROME_WIDTH = 8 // 4px padding each side\n\n// --- Measurement helpers ---\n\nconst collapsedSpaceWidthCache = new Map<string, number>()\n\nfunction measureSingleLineWidth(prepared: PreparedTextWithSegments): number {\n  let maxWidth = 0\n  walkLineRanges(prepared, UNBOUNDED, line => {\n    if (line.width > maxWidth) maxWidth = line.width\n  })\n  return maxWidth\n}\n\nfunction getCollapsedSpaceWidth(font: string): number {\n  const cached = collapsedSpaceWidthCache.get(font)\n  if (cached !== undefined) return cached\n  const joined = measureSingleLineWidth(prepareWithSegments('A A', font))\n  const compact = measureSingleLineWidth(prepareWithSegments('AA', font))\n  const w = Math.max(0, joined - compact)\n  collapsedSpaceWidthCache.set(font, w)\n  return w\n}\n\n// Resolve FontStyle to CSS font string\nfunction resolveFont(style: FontStyle, fonts: FontConfig): string {\n  switch (style) {\n    case 'body': return fonts.body\n    case 'bold': return fonts.bold\n    case 'italic': return fonts.italic\n    case 'boldItalic': return fonts.boldItalic\n    case 'strikethrough': return fonts.body\n  }\n}\n\n// --- Flatten + prepare ---\n// Two-pass: first flatten the inline tree to raw runs (preserving gap state\n// across link boundaries), then prepare each run with pretext.\n\ntype RawRun =\n  | { kind: 'text'; text: string; font: string; fontStyle: FontStyle; href?: string; isCode?: boolean; isStrikethrough?: boolean; chromeWidth: number }\n  | { kind: 'break' }\n\nfunction flattenRuns(items: InlineItem[], fonts: FontConfig, out: RawRun[], parentHref?: string): void {\n  for (const item of items) {\n    switch (item.kind) {\n      case 'text':\n        out.push({\n          kind: 'text',\n          text: item.text,\n          font: resolveFont(item.font, fonts),\n          fontStyle: item.font,\n          href: parentHref,\n          isStrikethrough: item.font === 'strikethrough',\n          chromeWidth: 0,\n        })\n        break\n      case 'code':\n        out.push({\n          kind: 'text',\n          text: item.text,\n          font: fonts.code,\n          fontStyle: 'body',\n          href: parentHref,\n          isCode: true,\n          chromeWidth: CODE_CHROME_WIDTH,\n        })\n        break\n      case 'link':\n        flattenRuns(item.items, fonts, out, item.href)\n        break\n      case 'break':\n        out.push({ kind: 'break' })\n        break\n    }\n  }\n}\n\nfunction prepareRuns(runs: RawRun[]): PreparedItem[] {\n  const out: PreparedItem[] = []\n  let pendingGap = 0\n\n  for (const run of runs) {\n    if (run.kind === 'break') {\n      out.push({ kind: 'break' })\n      pendingGap = 0\n      continue\n    }\n\n    const hasLeading = /^\\s/.test(run.text)\n    const hasTrailing = /\\s$/.test(run.text)\n    const trimmed = run.text.trim()\n    const carryGap = pendingGap\n    pendingGap = hasTrailing ? getCollapsedSpaceWidth(run.font) : 0\n    if (trimmed.length === 0) continue\n\n    const prepared = prepareWithSegments(trimmed, run.font)\n    const wholeLine = layoutNextLine(prepared, LINE_START, UNBOUNDED)\n    if (wholeLine === null) continue\n\n    out.push({\n      kind: 'text',\n      font: run.font,\n      fontStyle: run.fontStyle,\n      chromeWidth: run.chromeWidth,\n      endCursor: wholeLine.end,\n      fullText: wholeLine.text,\n      fullWidth: wholeLine.width,\n      leadingGap: carryGap > 0 || hasLeading ? getCollapsedSpaceWidth(run.font) : 0,\n      prepared,\n      href: run.href,\n      isCode: run.isCode,\n      isStrikethrough: run.isStrikethrough,\n    })\n  }\n\n  return out\n}\n\nfunction flattenAndPrepare(items: InlineItem[], fonts: FontConfig): PreparedItem[] {\n  const runs: RawRun[] = []\n  flattenRuns(items, fonts, runs)\n  return prepareRuns(runs)\n}\n\n// --- Cursor helpers ---\n\nfunction cursorsMatch(a: LayoutCursor, b: LayoutCursor): boolean {\n  return a.segmentIndex === b.segmentIndex && a.graphemeIndex === b.graphemeIndex\n}\n\n// --- Line layout ---\n\nexport function layoutInline(\n  items: InlineItem[],\n  maxWidth: number,\n  fonts: FontConfig,\n  lineHeight: number,\n): MeasuredLine[] {\n  const prepared = flattenAndPrepare(items, fonts)\n  if (prepared.length === 0) return []\n\n  const lineRanges = layoutLineRanges(prepared, maxWidth)\n  return lineRanges.map((range, i) => {\n    const line: MeasuredLine = {\n      fragments: null!,\n      width: range.width,\n      y: i * lineHeight,\n    }\n    // Lazy: fragments are materialized on first access.\n    // Offscreen lines never pay the string allocation cost.\n    let cached: LineFragment[] | null = null\n    Object.defineProperty(line, 'fragments', {\n      get() {\n        if (cached === null) cached = materializeRange(prepared, range)\n        return cached\n      },\n      enumerable: true,\n    })\n    return line\n  })\n}\n\n// --- Line range types (lazy materialization) ---\n// The layout pass produces ranges (cursor positions + widths) without\n// allocating fragment text. Materialization runs only for visible lines.\n\ntype FragmentRange = {\n  itemIndex: number       // index into prepared items array\n  startCursor: LayoutCursor | null  // null = whole item (fast path)\n  endCursor: LayoutCursor\n  width: number\n  leadingGap: number\n  availableWidth: number  // text width offered during layout (for re-layout during materialization)\n}\n\ntype LineRange = {\n  fragmentRanges: FragmentRange[]\n  width: number\n}\n\nfunction layoutLineRanges(items: PreparedItem[], maxWidth: number): LineRange[] {\n  const lines: LineRange[] = []\n  const safeWidth = Math.max(1, maxWidth)\n\n  let itemIdx = 0\n  let textCursor: LayoutCursor | null = null\n\n  while (itemIdx < items.length) {\n    const item = items[itemIdx]!\n\n    if (item.kind === 'break') {\n      lines.push({ fragmentRanges: [], width: 0 })\n      itemIdx++\n      textCursor = null\n      continue\n    }\n\n    const ranges: FragmentRange[] = []\n    let lineWidth = 0\n    let remainingWidth = safeWidth\n\n    lineLoop:\n    while (itemIdx < items.length) {\n      const item = items[itemIdx]!\n      if (item.kind === 'break') break lineLoop\n\n      if (textCursor !== null && cursorsMatch(textCursor, item.endCursor)) {\n        itemIdx++\n        textCursor = null\n        continue\n      }\n\n      const leadingGap = ranges.length === 0 ? 0 : item.leadingGap\n      const reservedWidth = leadingGap + item.chromeWidth\n\n      if (ranges.length > 0 && reservedWidth >= remainingWidth) break lineLoop\n\n      // Fast path: entire item fits\n      if (textCursor === null) {\n        const fullWidth = leadingGap + item.fullWidth + item.chromeWidth\n        if (fullWidth <= remainingWidth) {\n          ranges.push({\n            itemIndex: itemIdx,\n            startCursor: null, // null = whole item\n            endCursor: item.endCursor,\n            width: item.fullWidth + item.chromeWidth,\n            leadingGap,\n            availableWidth: UNBOUNDED,\n          })\n          lineWidth += fullWidth\n          remainingWidth = Math.max(0, safeWidth - lineWidth)\n          itemIdx++\n          continue\n        }\n      }\n\n      // Slow path: break item\n      const startCursor = textCursor ?? LINE_START\n      const availableWidth = Math.max(1, remainingWidth - reservedWidth)\n      const line = layoutNextLine(item.prepared, startCursor, availableWidth)\n\n      if (line === null) { itemIdx++; textCursor = null; continue }\n      if (cursorsMatch(startCursor, line.end)) {\n        if (ranges.length > 0) break lineLoop\n        itemIdx++; textCursor = null; continue\n      }\n\n      // Guard against mid-word breaking when not first on line\n      if (ranges.length > 0 && line.end.graphemeIndex > 0) {\n        const fullLine = layoutNextLine(item.prepared, startCursor, safeWidth - item.chromeWidth)\n        if (fullLine && !cursorsMatch(line.end, fullLine.end)) {\n          break lineLoop\n        }\n      }\n\n      ranges.push({\n        itemIndex: itemIdx,\n        startCursor,\n        endCursor: line.end,\n        width: line.width + item.chromeWidth,\n        leadingGap,\n        availableWidth,\n      })\n      lineWidth += leadingGap + line.width + item.chromeWidth\n      remainingWidth = Math.max(0, safeWidth - lineWidth)\n\n      if (cursorsMatch(line.end, item.endCursor)) {\n        itemIdx++; textCursor = null; continue\n      }\n      textCursor = line.end\n      break lineLoop\n    }\n\n    if (ranges.length === 0) break\n    lines.push({ fragmentRanges: ranges, width: lineWidth })\n  }\n\n  return lines\n}\n\n// Materialize a single line range into actual fragment objects.\n// Called lazily — only when the renderer accesses .fragments on a visible line.\nfunction materializeRange(items: PreparedItem[], range: LineRange): LineFragment[] {\n  return range.fragmentRanges.map(fr => {\n    const item = items[fr.itemIndex]! as PreparedTextItem\n    let text: string\n    if (fr.startCursor === null) {\n      // Fast path: whole item was placed intact\n      text = item.fullText\n    } else {\n      // Partial item: re-layout at the same available width to reproduce the break\n      const line = layoutNextLine(item.prepared, fr.startCursor, fr.availableWidth)\n      text = line?.text ?? ''\n    }\n    return {\n      text,\n      width: fr.width,\n      font: item.font,\n      fontStyle: item.fontStyle,\n      href: item.href,\n      isCode: item.isCode,\n      isStrikethrough: item.isStrikethrough,\n      leadingGap: fr.leadingGap,\n    }\n  })\n}\n\n/**\n * Quick line count — same range algorithm, no materialization.\n */\nexport function countInlineLines(\n  items: InlineItem[],\n  maxWidth: number,\n  fonts: FontConfig,\n): number {\n  const prepared = flattenAndPrepare(items, fonts)\n  if (prepared.length === 0) return 0\n  return layoutLineRanges(prepared, maxWidth).length\n}\n",
      "type": "registry:lib",
      "target": "lib/pretext-md/core/inline-layout.ts"
    },
    {
      "path": "lib/pretext-md/core/block-layout.ts",
      "content": "// Block layout coordinator\n//\n// Stacks blocks vertically, delegates inline measurement to the inline layout engine.\n// Block types like code-block, image, hr are measured by plugins — the coordinator\n// only handles paragraph, heading, list, blockquote natively.\n\nimport { layoutInline } from './inline-layout'\nimport type {\n  BlockNode,\n  MeasuredBlock,\n  MeasuredLine,\n  MeasureConfig,\n  MeasureResult,\n  FontConfig,\n  LineHeightConfig,\n  HeadingNode,\n  EmbedPlugin,\n  EmbedMeasurement,\n  PluginContext,\n} from './types'\n\n// Core layout constant — the coordinator owns block spacing\nconst BLOCK_GAP = 12\n\n\nfunction getHeadingFont(level: HeadingNode['level']): keyof FontConfig {\n  return `h${level}` as keyof FontConfig\n}\n\nfunction getHeadingLineHeight(level: HeadingNode['level'], lineHeights: LineHeightConfig): number {\n  return lineHeights[`h${level}` as keyof LineHeightConfig]\n}\n\nfunction resolveEmbedHeight(m: EmbedMeasurement, maxWidth: number): number {\n  switch (m.kind) {\n    case 'fixed':\n    case 'computed':\n      return m.height\n    case 'aspect-ratio': {\n      const h = maxWidth / m.ratio\n      return m.maxHeight ? Math.min(h, m.maxHeight) : h\n    }\n  }\n}\n\nfunction findPlugin(block: BlockNode, plugins?: EmbedPlugin[]): EmbedPlugin | null {\n  if (!plugins) return null\n  for (const p of plugins) {\n    if (p.match(block)) return p\n  }\n  return null\n}\n\n/**\n * Measure all blocks and return exact heights, y-offsets, and line data.\n */\nexport function measureBlocks(\n  blocks: BlockNode[],\n  config: MeasureConfig,\n): MeasureResult {\n  const measured: MeasuredBlock[] = []\n  let y = 0\n\n  for (let i = 0; i < blocks.length; i++) {\n    const block = blocks[i]!\n    const mb = measureBlock(block, config)\n    mb.y = y\n    measured.push(mb)\n    y += mb.height\n    if (i < blocks.length - 1) y += BLOCK_GAP\n  }\n\n  const totalLines = measured.reduce((sum, b) => sum + (b.lines?.length ?? 0), 0)\n\n  return {\n    height: y,\n    lineCount: totalLines,\n    blocks: measured,\n  }\n}\n\nfunction measureBlock(block: BlockNode, config: MeasureConfig): MeasuredBlock {\n  const ctx: PluginContext = { measureBlocks, config }\n\n  // Plugins handle all non-inline block types\n  const plugin = findPlugin(block, config.plugins)\n  if (plugin) {\n    // Full measurement: plugin returns MeasuredBlock with children/lines\n    if (plugin.measureBlock) {\n      return plugin.measureBlock(block, ctx)\n    }\n    // Simple measurement: plugin returns height\n    const m = plugin.measure(block as any, config.maxWidth, ctx)\n    return { node: block, height: resolveEmbedHeight(m, config.maxWidth), y: 0 }\n  }\n\n  // Core handles inline block types natively (paragraphs, headings)\n  switch (block.kind) {\n    case 'paragraph':\n      return measureParagraph(block, config)\n    case 'heading':\n      return measureHeading(block, config)\n    default:\n      // Unknown block without plugin — rough fallback\n      return { node: block, height: 40, y: 0 }\n  }\n}\n\nfunction measureParagraph(block: BlockNode & { kind: 'paragraph' }, config: MeasureConfig): MeasuredBlock {\n  const lines = layoutInline(\n    block.items,\n    config.maxWidth,\n    config.fonts,\n    config.lineHeights.body,\n  )\n  const height = lines.length * config.lineHeights.body\n  return { node: block, height, y: 0, lines }\n}\n\nfunction measureHeading(block: HeadingNode, config: MeasureConfig): MeasuredBlock {\n  const lineHeight = getHeadingLineHeight(block.level, config.lineHeights)\n  const headingFonts: FontConfig = {\n    ...config.fonts,\n    body: config.fonts[getHeadingFont(block.level)],\n    bold: config.fonts[getHeadingFont(block.level)],\n  }\n  const lines = layoutInline(\n    block.items,\n    config.maxWidth,\n    headingFonts,\n    lineHeight,\n  )\n  const height = lines.length * lineHeight\n  return { node: block, height, y: 0, lines }\n}\n\n",
      "type": "registry:lib",
      "target": "lib/pretext-md/core/block-layout.ts"
    },
    {
      "path": "lib/pretext-md/core/shrinkwrap.ts",
      "content": "// Shrinkwrap — binary search for the tightest width that preserves total height.\n\nimport { parse } from './parser'\nimport { measureBlocks } from './block-layout'\nimport type { MeasureConfig, MeasureResult } from './types'\n\nexport type ShrinkwrapResult = {\n  width: number\n  height: number\n}\n\n/**\n * Find the tightest width that preserves the same total height.\n * Binary search on width, measuring at each candidate.\n */\nexport function shrinkwrap(\n  markdown: string,\n  config: MeasureConfig,\n): ShrinkwrapResult {\n  const blocks = parse(markdown)\n  const initial = measureBlocks(blocks, config)\n\n  // Single-line text: no shrinkwrap needed. Any narrower width risks\n  // wrapping to 2 lines due to canvas/DOM measurement differences.\n  if (initial.lineCount <= 1) {\n    return { width: Math.ceil(config.maxWidth), height: initial.height }\n  }\n\n  // Reuse a single config object, mutating maxWidth per iteration\n  const searchConfig = { ...config }\n  let lo = 1\n  let hi = Math.ceil(config.maxWidth)\n  let lastHeight = initial.height\n\n  while (lo < hi) {\n    const mid = (lo + hi) >>> 1\n    searchConfig.maxWidth = mid\n    const candidate = measureBlocks(blocks, searchConfig)\n    if (candidate.height <= initial.height) {\n      hi = mid\n      lastHeight = candidate.height\n    } else {\n      lo = mid + 1\n    }\n  }\n\n  return { width: lo, height: lastHeight }\n}\n\nfunction getMaxContentWidth(result: MeasureResult): number {\n  let max = 0\n  for (const block of result.blocks) {\n    if (block.lines) {\n      for (const line of block.lines) {\n        max = Math.max(max, line.width)\n      }\n    }\n  }\n  return Math.ceil(max)\n}\n",
      "type": "registry:lib",
      "target": "lib/pretext-md/core/shrinkwrap.ts"
    },
    {
      "path": "lib/pretext-md/core/index.ts",
      "content": "export { parse } from './parser'\nexport { layoutInline } from './inline-layout'\nexport { measureBlocks } from './block-layout'\nexport { shrinkwrap } from './shrinkwrap'\nexport type {\n  BlockNode,\n  InlineItem,\n  FontConfig,\n  LineHeightConfig,\n  MeasureConfig,\n  MeasuredBlock,\n  MeasuredLine,\n  LineFragment,\n  MeasureResult,\n  FontStyle,\n  ParagraphNode,\n  HeadingNode,\n  CodeBlockNode,\n  BlockquoteNode,\n  ListNode,\n  ThematicBreakNode,\n  ImageNode,\n  TextItem,\n  CodeItem,\n  LinkItem,\n  BreakItem,\n  EmbedPlugin,\n  EmbedMeasurement,\n} from './types'\nexport type { ShrinkwrapResult } from './shrinkwrap'\n",
      "type": "registry:lib",
      "target": "lib/pretext-md/core/index.ts"
    },
    {
      "path": "lib/pretext-md/react/context.tsx",
      "content": "'use client'\n\nimport { createContext, useContext } from 'react'\nimport type { FontConfig, LineHeightConfig } from '../core/types'\n\nexport type PretextMdConfig = {\n  fonts: FontConfig\n  lineHeights: LineHeightConfig\n}\n\n// Font strings use rem matching the app theme scale (--text-sm: 0.9rem, etc.).\n// The inline layout engine resolves rem→px before passing to canvas measureText.\nconst defaultFonts: FontConfig = {\n  body: '0.9rem \"Mona Sans\", sans-serif',\n  bold: 'bold 0.9rem \"Mona Sans\", sans-serif',\n  italic: 'italic 0.9rem \"Mona Sans\", sans-serif',\n  boldItalic: 'bold italic 0.9rem \"Mona Sans\", sans-serif',\n  code: '0.8rem \"DM Mono\", \"Fira Code\", monospace',\n  h1: '1.8rem \"Sentient\", serif',\n  h2: '1.5rem \"Sentient\", serif',\n  h3: '1.15rem \"Sentient\", serif',\n  h4: '0.9rem \"Sentient\", serif',\n  h5: '0.8rem \"Sentient\", serif',\n  h6: '0.8rem \"Sentient\", serif',\n}\n\nconst defaultLineHeights: LineHeightConfig = {\n  body: 20,\n  code: 18,\n  h1: 32,\n  h2: 28,\n  h3: 24,\n  h4: 20,\n  h5: 20,\n  h6: 18,\n}\n\nexport const defaultConfig: PretextMdConfig = {\n  fonts: defaultFonts,\n  lineHeights: defaultLineHeights,\n}\n\nexport const PretextMdContext = createContext<PretextMdConfig>(defaultConfig)\n\nexport function usePretextMdConfig(): PretextMdConfig {\n  return useContext(PretextMdContext)\n}\n",
      "type": "registry:lib",
      "target": "lib/pretext-md/react/context.tsx"
    },
    {
      "path": "lib/pretext-md/react/renderer.tsx",
      "content": "'use client'\n\nimport React, { useMemo, memo, useState, useRef, useLayoutEffect, createContext, useContext } from 'react'\nimport { parse } from '../core/parser'\nimport { measureBlocks } from '../core/block-layout'\nimport type {\n  BlockNode,\n  ListNode,\n  TableNode,\n  InlineItem,\n  MeasuredBlock,\n  MeasuredLine,\n  LineFragment,\n  MeasureConfig,\n  HeadingNode,\n  ImageNode,\n  CodeBlockNode,\n  EmbedPlugin,\n} from '../core/types'\nimport { usePretextMdConfig } from './context'\nimport {\n  defaultPlugins,\n  renderCodeBlock,\n  renderYouTube,\n  renderImage,\n  renderTable,\n} from './plugins'\nimport { CodeBlock } from '@/components/infsh/code-block/code-block'\n\nconst DEFAULT_PLUGINS = defaultPlugins()\n\n// --- Plugin render registry ---\n// Maps plugin name → render function. Plugins own both measure and render.\n// Receives MeasuredBlock so container plugins can render measured children.\n\ntype PluginRenderer = (block: MeasuredBlock, renderChild: (b: MeasuredBlock) => React.ReactNode) => React.ReactNode\n\nconst defaultRenderers: Record<string, PluginRenderer> = {\n  'code-block': (b) => renderCodeBlock(b.node as CodeBlockNode),\n  'blockquote': (b, renderChild) => (\n    <blockquote className=\"relative\" style={{ paddingLeft: b.contentLeft ?? 16 }}>\n      {b.quoteRails?.map((x, i) => (\n        <span\n          key={i}\n          className=\"absolute top-0 bottom-0 w-0.5 bg-muted-foreground/30\"\n          style={{ left: x }}\n        />\n      ))}\n      {b.children?.map((child, i) => <React.Fragment key={i}>{renderChild(child)}</React.Fragment>)}\n    </blockquote>\n  ),\n  'list': (b) => {\n    return (\n      <div className=\"relative\">\n        {b.items?.map((measuredBlocks, i) => (\n          <div key={i} className=\"relative\" style={i > 0 ? { marginTop: 4 } : undefined}>\n            {measuredBlocks.map((child, j) => {\n              if (child.marker) {\n                return (\n                  <div key={j} className=\"relative\" style={{ paddingLeft: child.contentLeft ?? 0 }}>\n                    <span\n                      className=\"absolute text-muted-foreground text-sm select-none\"\n                      style={{ left: child.marker.x, top: 0, lineHeight: 'inherit' }}\n                    >\n                      {child.marker.text}\n                    </span>\n                    <MeasuredBlockRenderer block={child} />\n                  </div>\n                )\n              }\n              return <MeasuredBlockRenderer key={j} block={child} />\n            })}\n          </div>\n        ))}\n      </div>\n    )\n  },\n  'youtube': (b) => renderYouTube(b.node as ImageNode),\n  'image': (b) => renderImage(b.node as ImageNode),\n  'table': (b) => renderTable(b.node as TableNode),\n  'hr': () => <hr className=\"border-border\" />,\n}\n\n// --- Plugin context ---\n\nconst PluginsContext = createContext<{\n  plugins: EmbedPlugin[]\n  renderers: Record<string, PluginRenderer>\n}>({ plugins: [], renderers: defaultRenderers })\n\nfunction usePlugins() {\n  return useContext(PluginsContext)\n}\n\nfunction findPluginForNode(\n  node: BlockNode,\n  plugins: EmbedPlugin[],\n): EmbedPlugin | null {\n  for (const p of plugins) {\n    if (p.match(node)) return p\n  }\n  return null\n}\n\n// --- Main component ---\n\ntype MarkdownProps = {\n  content: string\n  /** Fixed width for measurement. If omitted, auto-measures the container. */\n  maxWidth?: number\n  className?: string\n  measured?: boolean\n  plugins?: EmbedPlugin[]\n  renderers?: Record<string, PluginRenderer>\n}\n\n/**\n * Content-box width of `el` — the width available to lay text into.\n *\n * clientWidth includes padding. The container sets none itself and no current\n * caller passes a padded `className`, so this subtraction is insurance rather\n * than a live fix — but it is the exact mistake that made text overflow when\n * this measured its `p-4` parent instead, so it stays. Mount-time only: the\n * observer below reads contentRect, which already excludes padding.\n */\nexport function contentBoxWidth(el: HTMLElement): number {\n  const cs = getComputedStyle(el)\n  const padX = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0)\n  return Math.floor(el.clientWidth - padX)\n}\n\nexport const Markdown = memo(function Markdown({\n  content,\n  maxWidth: maxWidthProp,\n  className,\n  measured = true,\n  plugins: userPlugins,\n  renderers: userRenderers,\n}: MarkdownProps) {\n  const config = usePretextMdConfig()\n  const plugins = userPlugins ?? DEFAULT_PLUGINS\n  const renderers = useMemo(\n    () => ({ ...defaultRenderers, ...userRenderers }),\n    [userRenderers],\n  )\n\n  const containerRef = useRef<HTMLDivElement>(null)\n  const [containerWidth, setContainerWidth] = useState(0)\n  const lastWidth = useRef(0)\n\n  // Only whether a width was supplied matters, not its value — depending on\n  // the number would tear down and rebuild the observer on every change.\n  const auto = maxWidthProp === undefined\n\n  // Measure our own container. Not circular: width:100% is set inline below,\n  // and an inline style beats any class rule, so the box is always sized by\n  // its parent and never by its own nowrap content. Verified against a\n  // parent-measuring variant at ui.inference.sh/lab/measure — identical at\n  // every width, in both fixed-width and w-fit parents.\n  //\n  // If text ever stops reflowing as the box shrinks, that invariant broke:\n  // the inline width:100% was removed. Restore it, or fall back to measuring\n  // `el.parentElement` — a box we do not size, so it cannot be circular\n  // (contentBoxWidth already subtracts the padding that requires).\n  useLayoutEffect(() => {\n    const el = containerRef.current\n    if (!auto || !el) return\n\n    // Sync read so the first paint has a width; the observer's initial\n    // callback fires asynchronously.\n    const initial = contentBoxWidth(el)\n    if (initial > 0) {\n      lastWidth.current = initial\n      setContainerWidth(initial)\n    }\n\n    if (typeof ResizeObserver === 'undefined') return\n    // Exactly one observed element, so exactly one entry per callback.\n    const ro = new ResizeObserver(([entry]) => {\n      // contentRect is the content box the browser already computed for this\n      // callback; getComputedStyle() here would force a style resolution per\n      // instance on every frame of a resize drag.\n      //\n      // We observe ourselves, so this also fires on every height change — i.e.\n      // on every streamed token. Compare against a ref so height-only ticks\n      // never reach React.\n      const w = Math.floor(entry.contentRect.width)\n      if (Math.abs(lastWidth.current - w) <= 1) return\n      lastWidth.current = w\n      setContainerWidth(w)\n    })\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [auto])\n\n  const blocks = useMemo(() => content ? parse(content) : [], [content])\n\n  // An explicit maxWidth skips measurement entirely — useful for fixed layouts\n  // and for deterministic tests that must not depend on live layout.\n  const effectiveWidth = maxWidthProp ?? containerWidth\n\n  const measuredResult = useMemo(() => {\n    if (!measured || effectiveWidth <= 0 || blocks.length === 0) return null\n    const measureConfig: MeasureConfig = {\n      maxWidth: effectiveWidth,\n      fonts: config.fonts,\n      lineHeights: config.lineHeights,\n      plugins,\n    }\n    return measureBlocks(blocks, measureConfig)\n  }, [blocks, effectiveWidth, config.fonts, config.lineHeights, measured, plugins])\n\n  const ctx = { plugins, renderers }\n\n  // Always render the container — never unmount the ref.\n  // Content inside is gated on width + content availability.\n  return (\n    <PluginsContext.Provider value={ctx}>\n      <div\n        ref={containerRef}\n        className={className}\n        style={{ display: 'flex', flexDirection: 'column', gap: 12, width: '100%' }}\n      >\n        {measuredResult ? (\n          measuredResult.blocks.map((block, i) => (\n            <MeasuredBlockRenderer key={i} block={block} />\n          ))\n        ) : blocks.length > 0 ? (\n          blocks.map((block, i) => (\n            <FlowBlockRenderer key={i} node={block} />\n          ))\n        ) : null}\n      </div>\n    </PluginsContext.Provider>\n  )\n})\n\n// ============================================================\n// MEASURED MODE\n// ============================================================\n\nfunction MeasuredBlockRenderer({ block }: { block: MeasuredBlock }) {\n  const { plugins, renderers } = usePlugins()\n  const node = block.node\n\n  // Plugin-rendered blocks\n  const plugin = findPluginForNode(node, plugins)\n  if (plugin) {\n    const render = renderers[plugin.name]\n    if (render) return <>{render(block, (child) => <MeasuredBlockRenderer block={child} />)}</>\n  }\n\n  // Core inline block types\n  switch (node.kind) {\n    case 'paragraph':\n      return <MeasuredInlineBlock block={block} tag=\"p\" />\n    case 'heading':\n      return <MeasuredInlineBlock block={block} tag={`h${node.level}`} />\n    default:\n      return null\n  }\n}\n\nfunction MeasuredInlineBlock({ block, tag }: { block: MeasuredBlock; tag: string }) {\n  if (!block.lines) return null\n  const Tag = tag as any\n  const lh = block.lines.length > 0 ? block.height / block.lines.length : 20\n  return (\n    <Tag style={{ margin: 0, width: '100%' }}>\n      {block.lines.map((line, i) => (\n        <MeasuredLineRenderer key={i} line={line} lineHeight={lh} />\n      ))}\n    </Tag>\n  )\n}\n\n\nfunction MeasuredLineRenderer({ line, lineHeight }: { line: MeasuredLine; lineHeight: number }) {\n  return (\n    <span style={{ display: 'block', height: lineHeight, whiteSpace: 'nowrap' }}>\n      {line.fragments.map((frag, i) => (\n        <FragmentRenderer key={i} fragment={frag} />\n      ))}\n    </span>\n  )\n}\n\nfunction FragmentRenderer({ fragment }: { fragment: LineFragment }) {\n  const style: React.CSSProperties = { font: fragment.font }\n  const space = fragment.leadingGap > 0 ? ' ' : ''\n\n  if (fragment.isCode) {\n    return (\n      <>{space}<code\n        className=\"bg-foreground/[0.06] rounded px-1 py-0.5\"\n        style={style}\n      >{fragment.text}</code></>\n    )\n  }\n\n  let content: React.ReactNode = fragment.text\n  if (fragment.isStrikethrough) content = <del>{content}</del>\n  if (fragment.href) {\n    return <>{space}<a href={fragment.href} className=\"underline text-primary\" style={style}>{content}</a></>\n  }\n  return <>{space}<span style={style}>{content}</span></>\n}\n\n\n// ============================================================\n// FLOW MODE — normal browser layout, same parsed AST\n// ============================================================\n\nfunction FlowBlockRenderer({ node }: { node: BlockNode }) {\n  const { plugins, renderers } = usePlugins()\n\n  // Leaf plugins (code-block, youtube, image, hr) work in flow mode too\n  const plugin = findPluginForNode(node, plugins)\n  if (plugin && !plugin.measureBlock) {\n    const render = renderers[plugin.name]\n    if (render) return <>{render({ node, height: 0, y: 0 }, () => null)}</>\n  }\n\n  switch (node.kind) {\n    case 'paragraph':\n      return <p className=\"text-sm leading-5\"><FlowInlineItems items={node.items} /></p>\n    case 'heading':\n      return <FlowHeading node={node} />\n    case 'code-block':\n      return <FlowCodeBlock node={node as CodeBlockNode} />\n    case 'blockquote':\n      return (\n        <blockquote className=\"border-l-2 border-muted-foreground/30 pl-4\">\n          {node.children.map((child, i) => (\n            <FlowBlockRenderer key={i} node={child} />\n          ))}\n        </blockquote>\n      )\n    case 'list': {\n      const Tag = node.ordered ? 'ol' : 'ul'\n      return (\n        <Tag className={`${node.ordered ? 'list-decimal' : 'list-disc'} pl-6 text-sm leading-5`} start={node.start}>\n          {node.items.map((itemBlocks, i) => (\n            <li key={i}>\n              {itemBlocks.map((child, j) => (\n                <FlowBlockRenderer key={j} node={child} />\n              ))}\n            </li>\n          ))}\n        </Tag>\n      )\n    }\n    default:\n      return null\n  }\n}\n\nfunction FlowHeading({ node }: { node: HeadingNode }) {\n  const Tag = `h${node.level}` as const\n  const sizes: Record<number, string> = {\n    1: 'text-2xl font-bold',\n    2: 'text-xl font-bold',\n    3: 'text-base font-bold',\n    4: 'text-sm font-bold',\n    5: 'text-sm font-bold',\n    6: 'text-xs font-bold',\n  }\n  return (\n    <Tag className={sizes[node.level]}>\n      <FlowInlineItems items={node.items} />\n    </Tag>\n  )\n}\n\nfunction FlowCodeBlock({ node }: { node: CodeBlockNode }) {\n  return (\n    <CodeBlock language={node.lang} showHeader={!!node.lang} showLineNumbers={false} className=\"!my-0 !h-auto\">\n      {node.code}\n    </CodeBlock>\n  )\n}\n\nfunction FlowInlineItems({ items }: { items: InlineItem[] }) {\n  return (\n    <>\n      {items.map((item, i) => (\n        <FlowInlineItem key={i} item={item} />\n      ))}\n    </>\n  )\n}\n\nfunction FlowInlineItem({ item }: { item: InlineItem }) {\n  switch (item.kind) {\n    case 'text': {\n      const Tag = item.font === 'bold' || item.font === 'boldItalic' ? 'strong' : item.font === 'italic' ? 'em' : item.font === 'strikethrough' ? 'del' : 'span'\n      if (item.font === 'boldItalic') return <strong><em>{item.text}</em></strong>\n      return <Tag>{item.text}</Tag>\n    }\n    case 'code':\n      return <code className=\"bg-foreground/[0.06] rounded px-1 py-0.5 text-[0.9em]\">{item.text}</code>\n    case 'link':\n      return (\n        <a href={item.href} className=\"underline text-primary\">\n          <FlowInlineItems items={item.items} />\n        </a>\n      )\n    case 'break':\n      return <br />\n    default:\n      return null\n  }\n}\n",
      "type": "registry:lib",
      "target": "lib/pretext-md/react/renderer.tsx"
    },
    {
      "path": "lib/pretext-md/react/plugins.tsx",
      "content": "'use client'\n\nimport React from 'react'\nimport type {\n  BlockNode,\n  BlockquoteNode,\n  ListNode,\n  CodeBlockNode,\n  ImageNode,\n  TableNode,\n  EmbedPlugin,\n  PluginContext,\n} from '../core/types'\nimport { splitLines } from '@/components/infsh/code-block/utils'\nimport { CodeBlock } from '@/components/infsh/code-block/code-block'\nimport { YouTubeEmbed } from '@/components/infsh/youtube-embed'\nimport ZoomableImage from '@/components/infsh/zoomable-image'\n\n// --- YouTube detection ---\n\nfunction getYouTubeVideoId(url: string): string | null {\n  const m = url.match(/(?:youtu\\.be\\/|youtube\\.com\\/(?:embed\\/|v\\/|watch\\?v=|shorts\\/))([a-zA-Z0-9_-]{11})/)\n  return m?.[1] ?? null\n}\n\n// --- Code block plugin ---\n// Owns its own chrome dimensions — core doesn't know about these.\n\nconst CODE_BLOCK_CHROME = {\n  headerHeight: 33,  // py-2 (16px) + text-xs line (16px) + border-b (1px)\n  paddingY: 32,      // p-4 top + bottom\n  border: 2,         // border top + bottom\n} as const\n\nexport function codeBlockPlugin(lineHeight: number = 18): EmbedPlugin {\n  return {\n    name: 'code-block',\n    match: (node) => node.kind === 'code-block',\n    measure: (node) => {\n      const codeNode = node as CodeBlockNode\n      const hasHeader = !!codeNode.lang\n      const numLines = splitLines(codeNode.code).length\n      const height =\n        (hasHeader ? CODE_BLOCK_CHROME.headerHeight : 0) +\n        CODE_BLOCK_CHROME.paddingY +\n        numLines * lineHeight +\n        CODE_BLOCK_CHROME.border\n      return { kind: 'computed', height }\n    },\n  }\n}\n\nexport function renderCodeBlock(node: CodeBlockNode): React.ReactNode {\n  return (\n    <CodeBlock language={node.lang} showHeader={!!node.lang} showLineNumbers={false} className=\"!my-0 !h-auto\">\n      {node.code}\n    </CodeBlock>\n  )\n}\n\n// --- YouTube plugin ---\n\nexport function youtubePlugin(): EmbedPlugin {\n  return {\n    name: 'youtube',\n    match: (node) =>\n      node.kind === 'image' && getYouTubeVideoId((node as ImageNode).src) !== null,\n    measure: () => ({ kind: 'aspect-ratio', ratio: 16 / 9 }),\n  }\n}\n\nexport function renderYouTube(node: ImageNode): React.ReactNode {\n  const videoId = getYouTubeVideoId(node.src)!\n  return <YouTubeEmbed videoId={videoId} title={node.alt} />\n}\n\n// --- Image plugin ---\n\nexport function imagePlugin(): EmbedPlugin {\n  return {\n    name: 'image',\n    match: (node) => node.kind === 'image',\n    measure: (_, maxWidth) => ({ kind: 'aspect-ratio', ratio: 16 / 9, maxHeight: 400 }),\n  }\n}\n\nexport function renderImage(node: ImageNode): React.ReactNode {\n  return <ZoomableImage src={node.src} alt={node.alt} className=\"rounded-md max-w-full\" />\n}\n\n// --- HR plugin ---\n\nexport function hrPlugin(): EmbedPlugin {\n  return {\n    name: 'hr',\n    match: (node) => node.kind === 'hr',\n    measure: () => ({ kind: 'fixed', height: 1 }),\n  }\n}\n\n// --- Blockquote plugin ---\n\nconst QUOTE_RAIL_WIDTH = 2     // border-l-2\nconst QUOTE_RAIL_GAP = 10      // gap between rail and content\nconst QUOTE_INDENT = QUOTE_RAIL_WIDTH + QUOTE_RAIL_GAP\n\nexport function blockquotePlugin(): EmbedPlugin {\n  return {\n    name: 'blockquote',\n    match: (node) => node.kind === 'blockquote',\n    measure: () => ({ kind: 'fixed', height: 0 }),\n    measureBlock: (node: BlockquoteNode, ctx) => {\n      // Collect parent rail positions and add our own\n      const parentRails = ctx.config._quoteRails ?? []\n      const railX = (ctx.config._contentLeft ?? 0)\n      const ourRails = [...parentRails, railX]\n      const contentLeft = railX + QUOTE_INDENT\n\n      const innerConfig = {\n        ...ctx.config,\n        maxWidth: ctx.config.maxWidth - QUOTE_INDENT,\n        _quoteRails: ourRails,\n        _contentLeft: contentLeft,\n      }\n      const inner = ctx.measureBlocks(node.children, innerConfig)\n      return {\n        node, height: inner.height, y: 0,\n        children: inner.blocks,\n        quoteRails: ourRails,\n        contentLeft,\n      }\n    },\n  }\n}\n\n// --- List plugin ---\n\nconst LIST_MARKER_GAP = 8      // gap between marker and content\nconst LIST_INDENT = 18          // total indent per nesting level\nconst LIST_ITEM_GAP = 4         // vertical gap between items\n\nexport function listPlugin(): EmbedPlugin {\n  return {\n    name: 'list',\n    match: (node) => node.kind === 'list',\n    measure: () => ({ kind: 'fixed', height: 0 }),\n    measureBlock: (node: ListNode, ctx) => {\n      const baseLeft = ctx.config._contentLeft ?? 0\n      const markerX = baseLeft\n      const contentLeft = baseLeft + LIST_INDENT\n      const innerConfig = {\n        ...ctx.config,\n        maxWidth: ctx.config.maxWidth - LIST_INDENT,\n        _contentLeft: contentLeft,\n      }\n\n      let totalHeight = 0\n      const measuredItems = node.items.map((itemBlocks, i) => {\n        const inner = ctx.measureBlocks(itemBlocks, innerConfig)\n        if (i > 0) totalHeight += LIST_ITEM_GAP\n        // Tag each item's first block with marker info\n        if (inner.blocks.length > 0) {\n          const markerText = node.ordered\n            ? `${(node.start ?? 1) + i}.`\n            : '•'\n          inner.blocks[0] = {\n            ...inner.blocks[0]!,\n            marker: { text: markerText, x: markerX },\n            contentLeft,\n          }\n        }\n        totalHeight += inner.height\n        return inner.blocks\n      })\n\n      return { node, height: totalHeight, y: 0, items: measuredItems, contentLeft }\n    },\n  }\n}\n\n// --- Table plugin ---\n\nconst TABLE_ROW_HEIGHT = 33 // px-3 py-1.5 (12px) + text-sm line (~16px) + border (1px) + padding\nconst TABLE_HEADER_HEIGHT = 33\n\nexport function tablePlugin(): EmbedPlugin {\n  return {\n    name: 'table',\n    match: (node) => node.kind === 'table',\n    measure: (node) => {\n      const table = node as TableNode\n      const height = TABLE_HEADER_HEIGHT + (table.rows.length - 1) * TABLE_ROW_HEIGHT + 2 // 2 = border\n      return { kind: 'computed', height }\n    },\n  }\n}\n\nexport function renderTable(node: TableNode): React.ReactNode {\n  if (node.rows.length === 0) return null\n  const [headerRow, ...bodyRows] = node.rows\n  const align = node.align\n\n  return (\n    <div className=\"min-w-0 overflow-x-auto border border-border rounded-md\">\n      <table className=\"w-full\">\n        {headerRow && (\n          <thead className=\"border-b border-border\">\n            <tr>\n              {headerRow.map((cell, i) => (\n                <th\n                  key={i}\n                  className=\"px-3 py-1.5 text-left text-xs text-muted-foreground\"\n                  style={align[i] ? { textAlign: align[i]! } : undefined}\n                >\n                  <FlowCellInlines items={cell} />\n                </th>\n              ))}\n            </tr>\n          </thead>\n        )}\n        <tbody>\n          {bodyRows.map((row, ri) => (\n            <tr key={ri} className=\"border-b border-border last:border-0\">\n              {row.map((cell, ci) => (\n                <td\n                  key={ci}\n                  className=\"px-3 py-1.5 text-sm\"\n                  style={align[ci] ? { textAlign: align[ci]! } : undefined}\n                >\n                  <FlowCellInlines items={cell} />\n                </td>\n              ))}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  )\n}\n\n/** Render inline items inside a table cell — reuses the same inline rendering as flow mode. */\nfunction FlowCellInlines({ items }: { items: import('../core/types').InlineItem[] }) {\n  return (\n    <>\n      {items.map((item, i) => {\n        switch (item.kind) {\n          case 'text': {\n            const Tag = item.font === 'bold' || item.font === 'boldItalic' ? 'strong' : item.font === 'italic' ? 'em' : item.font === 'strikethrough' ? 'del' : 'span'\n            if (item.font === 'boldItalic') return <strong key={i}><em>{item.text}</em></strong>\n            return <Tag key={i}>{item.text}</Tag>\n          }\n          case 'code':\n            return <code key={i} className=\"bg-foreground/[0.06] rounded px-1 py-0.5 text-[0.9em]\">{item.text}</code>\n          case 'link':\n            return <a key={i} href={item.href} className=\"underline text-primary\"><FlowCellInlines items={item.items} /></a>\n          case 'break':\n            return <br key={i} />\n          default:\n            return null\n        }\n      })}\n    </>\n  )\n}\n\n// --- Default plugin set ---\n\nexport function defaultPlugins(): EmbedPlugin[] {\n  return [codeBlockPlugin(), blockquotePlugin(), listPlugin(), youtubePlugin(), imagePlugin(), hrPlugin(), tablePlugin()]\n}\n",
      "type": "registry:lib",
      "target": "lib/pretext-md/react/plugins.tsx"
    },
    {
      "path": "lib/pretext-md/react/index.ts",
      "content": "export { Markdown } from './renderer'\nexport { PretextMdContext, usePretextMdConfig, defaultConfig } from './context'\nexport type { PretextMdConfig } from './context'\nexport {\n  defaultPlugins,\n  codeBlockPlugin,\n  blockquotePlugin,\n  listPlugin,\n  youtubePlugin,\n  imagePlugin,\n  hrPlugin,\n  tablePlugin,\n} from './plugins'\n",
      "type": "registry:lib",
      "target": "lib/pretext-md/react/index.ts"
    }
  ]
}
