shadimage

pretext-md

Measured markdown renderer powered by pretext. DOM-free text measurement, plugin architecture, virtualizable.

lib/pretext-md/core/types.ts

// Layout IR types for pretext-md
// These types bridge the gap between mdast (remark's AST) and pretext measurement.

// --- Font configuration ---

export type FontConfig = {
  body: string       // e.g. '14px "Inter", sans-serif'
  bold: string       // e.g. 'bold 14px "Inter", sans-serif'
  italic: string     // e.g. 'italic 14px "Inter", sans-serif'
  boldItalic: string // e.g. 'bold italic 14px "Inter", sans-serif'
  code: string       // e.g. '13px "Fira Code", monospace'
  h1: string
  h2: string
  h3: string
  h4: string
  h5: string
  h6: string
}

export type LineHeightConfig = {
  body: number    // e.g. 20
  code: number    // e.g. 18
  h1: number      // e.g. 32
  h2: number      // e.g. 28
  h3: number      // e.g. 24
  h4: number
  h5: number
  h6: number
}

export type MeasureConfig = {
  maxWidth: number
  fonts: FontConfig
  lineHeights: LineHeightConfig
  plugins?: EmbedPlugin[]
  // Internal — used by container plugins to thread nesting state
  _quoteRails?: number[]
  _contentLeft?: number
}

// --- Block-level nodes (parser output) ---

export type BlockNode =
  | ParagraphNode
  | HeadingNode
  | CodeBlockNode
  | BlockquoteNode
  | ListNode
  | ThematicBreakNode
  | ImageNode
  | TableNode

export type ParagraphNode = {
  kind: 'paragraph'
  items: InlineItem[]
}

export type HeadingNode = {
  kind: 'heading'
  level: 1 | 2 | 3 | 4 | 5 | 6
  items: InlineItem[]
}

export type CodeBlockNode = {
  kind: 'code-block'
  lang: string
  code: string
  meta?: string
}

export type BlockquoteNode = {
  kind: 'blockquote'
  children: BlockNode[]
}

export type ListNode = {
  kind: 'list'
  ordered: boolean
  start?: number
  items: BlockNode[][]
}

export type ThematicBreakNode = {
  kind: 'hr'
}

export type ImageNode = {
  kind: 'image'
  src: string
  alt?: string
}

export type TableAlignType = 'left' | 'center' | 'right' | null

export type TableNode = {
  kind: 'table'
  align: TableAlignType[]
  rows: InlineItem[][][] // rows → cells → inline items
}

// --- Inline items (within paragraphs/headings) ---

export type InlineItem =
  | TextItem
  | CodeItem
  | LinkItem
  | BreakItem

export type TextItem = {
  kind: 'text'
  text: string
  font: FontStyle
}

export type CodeItem = {
  kind: 'code'
  text: string
}

export type LinkItem = {
  kind: 'link'
  items: InlineItem[]
  href: string
}

export type BreakItem = {
  kind: 'break'
}

// font style resolved during inline layout based on FontConfig
export type FontStyle = 'body' | 'bold' | 'italic' | 'boldItalic' | 'strikethrough'

// --- Measured output ---

export type MeasuredBlock = {
  node: BlockNode
  height: number
  y: number
  lines?: MeasuredLine[]          // for paragraph/heading/code blocks
  children?: MeasuredBlock[]      // for blockquote inner blocks
  items?: MeasuredBlock[][]       // for list items (each item = array of measured blocks)
  // Marker/rail positioning — measured coordinates for precise rendering
  marker?: { text: string; x: number }       // list bullet/number position
  quoteRails?: number[]                       // blockquote rail x-positions (one per nesting level)
  contentLeft?: number                        // x-offset where content starts (after markers/rails)
}

export type MeasuredLine = {
  fragments: LineFragment[]
  width: number
  y: number // offset within block
}

export type LineFragment = {
  text: string
  width: number
  font: string      // resolved CSS font string
  fontStyle: FontStyle
  leadingGap: number // marginLeft gap before this fragment
  href?: string
  isCode?: boolean
  isStrikethrough?: boolean
}

export type MeasureResult = {
  height: number
  lineCount: number
  blocks: MeasuredBlock[]
}

// --- Plugin system ---

export type EmbedMeasurement =
  | { kind: 'fixed'; height: number }
  | { kind: 'computed'; height: number }
  | { kind: 'aspect-ratio'; ratio: number; maxHeight?: number }

/** Context passed to plugins for recursive measurement. */
export type PluginContext = {
  measureBlocks: (blocks: BlockNode[], config: MeasureConfig) => MeasureResult
  config: MeasureConfig
}

export type EmbedPlugin = {
  name: string
  match: (node: BlockNode) => boolean
  /** Simple measurement — returns a height. For leaf blocks (code, image, hr). */
  measure: (node: any, maxWidth: number, ctx?: PluginContext) => EmbedMeasurement
  /** Full measurement — returns MeasuredBlock with children/lines. For container blocks (blockquote, list). */
  measureBlock?: (node: any, ctx: PluginContext) => MeasuredBlock
}

No selection tags yet — empty means no evidence, not lack of support.

DependenciesBundle weight · light

@chenglou/pretextunifiedremark-parseremark-gfm