{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"typeset","title":"Typeset","description":"Set rendered markdown, such as a chat answer or a document, in the kit's type, with a map for the components prop of a markdown renderer. Search terms: prose, typography, rich text, react-markdown, streamdown.","dependencies":[],"registryDependencies":["@siteplane/base","@siteplane/button","@siteplane/code","@siteplane/native-table","@siteplane/separator"],"files":[{"path":"packages/ui/src/components/typeset/typeset.tsx","content":"\"use client\";\n\nimport {\n  Children,\n  isValidElement,\n  type ComponentProps,\n  type JSX,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n} from \"react\";\n\nimport { cn } from \"../../system/utils/cn\";\nimport { slotClass } from \"../../system/utils/slot-class\";\nimport { Button } from \"../button\";\nimport { Code, CodeBlock } from \"../code\";\nimport {\n  NativeTable,\n  NativeTableBody,\n  NativeTableCell,\n  NativeTableFooter,\n  NativeTableHead,\n  NativeTableHeader,\n  NativeTableRow,\n} from \"../native-table\";\nimport { Separator } from \"../separator\";\n\nimport \"./typeset.css\";\n\n/**\n * How roomy the text is. `chat` is the kit's body type with a tight rhythm,\n * for a message; `document` is a size up with more room between blocks.\n */\nexport type TypesetDensity = \"chat\" | \"document\";\n\nexport type TypesetProps = ComponentProps<\"div\"> & {\n  /** Defaults to `chat`. */\n  density?: TypesetDensity | undefined;\n};\n\n/**\n * Kit typography for rendered markdown: it sets the kit's body type on its\n * container and stacks the blocks with a gap. The blocks are the Typeset parts\n * and the kit's own Code, CodeBlock and table, which `typesetComponents` maps\n * the elements of a markdown renderer to. The three values that tune it are\n * the custom properties `--sp-typeset-size`, `--sp-typeset-leading` and\n * `--sp-typeset-flow`.\n */\nexport function Typeset({\n  className,\n  density = \"chat\",\n  ...props\n}: TypesetProps): ReactElement {\n  return (\n    <div\n      className={cn(slotClass(\"typeset\", props), className)}\n      data-density={density}\n      data-slot=\"typeset\"\n      {...props}\n    />\n  );\n}\n\n/** A paragraph. */\nexport function TypesetParagraph({\n  className,\n  ...props\n}: ComponentProps<\"p\">): ReactElement {\n  return (\n    <p\n      className={cn(slotClass(\"typeset-paragraph\", props), className)}\n      data-slot=\"typeset-paragraph\"\n      {...props}\n    />\n  );\n}\n\nexport type TypesetHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;\n\nexport type TypesetHeadingProps = ComponentProps<\"h1\"> & {\n  /**\n   * The heading level, which is also the element. Levels 1 to 3 have a size\n   * of their own; 4 to 6 share the body size in a heavier weight. Defaults\n   * to 2.\n   */\n  level?: TypesetHeadingLevel | undefined;\n};\n\nconst headingTags = {\n  1: \"h1\",\n  2: \"h2\",\n  3: \"h3\",\n  4: \"h4\",\n  5: \"h5\",\n  6: \"h6\",\n} as const;\n\n/** A heading, from `h1` to `h6` by `level`. */\nexport function TypesetHeading({\n  className,\n  level = 2,\n  ...props\n}: TypesetHeadingProps): ReactElement {\n  const Tag = headingTags[level];\n  return (\n    <Tag\n      className={cn(slotClass(\"typeset-heading\", props), className)}\n      data-level={level}\n      data-slot=\"typeset-heading\"\n      {...props}\n    />\n  );\n}\n\nexport type TypesetListProps = Omit<ComponentProps<\"ol\">, \"ref\"> & {\n  /** A numbered list (`ol`) instead of a bulleted one (`ul`). */\n  ordered?: boolean | undefined;\n  ref?: Ref<HTMLOListElement | HTMLUListElement> | undefined;\n};\n\n/** A bulleted or numbered list, with a gap between its items. */\nexport function TypesetList({\n  className,\n  ordered = false,\n  ...props\n}: TypesetListProps): ReactElement {\n  // An `ul` takes the same attributes as far as it uses any; the casts only\n  // give both elements one type.\n  const Tag = (ordered ? \"ol\" : \"ul\") as \"ol\";\n  return (\n    <Tag\n      className={cn(slotClass(\"typeset-list\", props), className)}\n      data-ordered={ordered ? \"\" : undefined}\n      data-slot=\"typeset-list\"\n      {...(props as ComponentProps<\"ol\">)}\n    />\n  );\n}\n\n/** An item of a list. */\nexport function TypesetListItem({\n  className,\n  ...props\n}: ComponentProps<\"li\">): ReactElement {\n  return (\n    <li\n      className={cn(slotClass(\"typeset-list-item\", props), className)}\n      data-slot=\"typeset-list-item\"\n      {...props}\n    />\n  );\n}\n\n/**\n * A link, in the kit's link look: the inline link Button, underlined in the\n * text colour, which fades on hover and press and keeps the kit's focus ring.\n */\nexport function TypesetLink({\n  children,\n  className,\n  ...props\n}: ComponentProps<\"a\">): ReactElement {\n  return (\n    <Button\n      className={className}\n      data-slot=\"typeset-link\"\n      profile=\"inline\"\n      render={<a {...props} />}\n      variant=\"link\"\n    >\n      {children}\n    </Button>\n  );\n}\n\n/** A quote: its text is quieter, and a bar marks its start. */\nexport function TypesetQuote({\n  className,\n  ...props\n}: ComponentProps<\"blockquote\">): ReactElement {\n  return (\n    <blockquote\n      className={cn(slotClass(\"typeset-quote\", props), className)}\n      data-slot=\"typeset-quote\"\n      {...props}\n    />\n  );\n}\n\n/** A thematic break: the kit's Separator, with the slot of a Typeset part. */\nexport function TypesetRule(\n  props: ComponentProps<typeof Separator>,\n): ReactElement {\n  return <Separator data-slot=\"typeset-rule\" {...props} />;\n}\n\n/** Strong text, in the kit's strong weight. */\nexport function TypesetStrong({\n  className,\n  ...props\n}: ComponentProps<\"strong\">): ReactElement {\n  return (\n    <strong\n      className={cn(slotClass(\"typeset-strong\", props), className)}\n      data-slot=\"typeset-strong\"\n      {...props}\n    />\n  );\n}\n\n/** The text a node renders, for the code of a fence. */\nfunction textOf(node: ReactNode): string {\n  if (typeof node === \"string\") return node;\n  if (typeof node === \"number\") return String(node);\n  if (Array.isArray(node)) return node.map(textOf).join(\"\");\n  if (isValidElement<{ children?: ReactNode }>(node))\n    return textOf(node.props.children);\n  return \"\";\n}\n\nexport type TypesetCodeBlockProps = Omit<ComponentProps<\"pre\">, \"title\"> & {\n  /**\n   * Whether the closing fence has not come yet: the copy action stays\n   * disabled until it has. Streamdown tells this with\n   * `useIsCodeFenceIncomplete`.\n   */\n  streaming?: boolean | undefined;\n};\n\n/**\n * A fenced code block, as a markdown renderer passes it to `pre`: a `code`\n * element, with `language-x` as its class when the fence names a language. It\n * renders the kit's CodeBlock with that language, which it also shows as the\n * title, and the copy action; the language-less fence has the floating copy\n * action. `typesetComponents.pre` is this part.\n */\nexport function TypesetCodeBlock({\n  children,\n  streaming,\n  ...props\n}: TypesetCodeBlockProps): ReactElement {\n  const fence = Children.toArray(children).find(\n    (\n      child,\n    ): child is ReactElement<{ children?: ReactNode; className?: string }> =>\n      isValidElement(child),\n  );\n  const language = /(?:^|\\s)language-(\\S+)/.exec(\n    fence?.props.className ?? \"\",\n  )?.[1];\n  // The fence ends its last line with a line break of its own.\n  const value = textOf(fence ? fence.props.children : children).replace(\n    /\\n$/,\n    \"\",\n  );\n  return (\n    <CodeBlock\n      copyable\n      language={language}\n      streaming={streaming}\n      title={language}\n      value={value}\n      {...props}\n    />\n  );\n}\n\n/*\n * The map for the `components` prop of a markdown renderer, react-markdown or\n * Streamdown: each element becomes a Typeset part, or the kit component that\n * owns it. A renderer passes the hast `node` along with the props of the\n * element; the parts below drop it, since a DOM element must not get it.\n */\n\ntype MarkdownProps<Tag extends keyof JSX.IntrinsicElements> =\n  ComponentProps<Tag> & { node?: unknown };\n\nfunction withoutNode<Props extends { node?: unknown }>(\n  props: Props,\n): Omit<Props, \"node\"> {\n  const { node, ...rest } = props;\n  void node;\n  return rest;\n}\n\n/**\n * The props of a table element without `node` and without `style`. A markdown\n * table part forwards no inline style: the one thing a renderer puts in it is\n * the alignment of a column, which becomes the part's `align`, and the rest of\n * the style is dropped, so the table keeps the look of its owner.\n */\nfunction withoutNodeAndStyle<Props extends { node?: unknown; style?: unknown }>(\n  props: Props,\n): Omit<Props, \"node\" | \"style\"> {\n  const { node, style, ...rest } = props;\n  void node;\n  void style;\n  return rest;\n}\n\ntype CellAlignment = \"center\" | \"end\" | \"start\" | undefined;\n\n/** The alignment a markdown table gives a column, as a style or an attribute. */\nfunction cellAlignment(\n  align: string | undefined,\n  style: ComponentProps<\"td\">[\"style\"],\n): CellAlignment {\n  const value = style?.textAlign ?? align;\n  if (value === \"center\") return \"center\";\n  if (value === \"right\" || value === \"end\") return \"end\";\n  if (value === \"left\" || value === \"start\") return \"start\";\n  return undefined;\n}\n\nfunction MarkdownHeading(level: TypesetHeadingLevel) {\n  return function Heading(props: MarkdownProps<\"h1\">) {\n    return <TypesetHeading level={level} {...withoutNode(props)} />;\n  };\n}\n\nfunction MarkdownTableHead(props: MarkdownProps<\"th\">) {\n  const { align, ...rest } = withoutNodeAndStyle(props);\n  return (\n    <NativeTableHead align={cellAlignment(align, props.style)} {...rest} />\n  );\n}\n\nfunction MarkdownTableCell(props: MarkdownProps<\"td\">) {\n  const { align, ...rest } = withoutNodeAndStyle(props);\n  return (\n    <NativeTableCell align={cellAlignment(align, props.style)} {...rest} />\n  );\n}\n\n/**\n * Typeset parts for the elements of rendered markdown, for the `components`\n * prop of react-markdown and Streamdown:\n *\n * - paragraphs, headings (`h1` to `h6`), lists, list items, links, quotes,\n *   rules and strong text are the Typeset parts;\n * - inline code is the kit's Code, and a fence (`pre` with its `code`) is\n *   CodeBlock with the fence's language (see TypesetCodeBlock);\n * - a table is the kit's NativeTable, with its rows and cells. A table in\n *   written text has no hover, since nothing in it acts, and takes no style:\n *   a column's alignment becomes the part's `align`, the rest is dropped.\n *\n * The renderer itself is the app's; the kit has none.\n */\nexport const typesetComponents = {\n  a: (props: MarkdownProps<\"a\">) => <TypesetLink {...withoutNode(props)} />,\n  blockquote: (props: MarkdownProps<\"blockquote\">) => (\n    <TypesetQuote {...withoutNode(props)} />\n  ),\n  code: (props: MarkdownProps<\"code\">) => <Code {...withoutNode(props)} />,\n  h1: MarkdownHeading(1),\n  h2: MarkdownHeading(2),\n  h3: MarkdownHeading(3),\n  h4: MarkdownHeading(4),\n  h5: MarkdownHeading(5),\n  h6: MarkdownHeading(6),\n  hr: () => <TypesetRule />,\n  li: (props: MarkdownProps<\"li\">) => (\n    <TypesetListItem {...withoutNode(props)} />\n  ),\n  ol: (props: MarkdownProps<\"ol\">) => (\n    <TypesetList ordered {...withoutNode(props)} />\n  ),\n  p: (props: MarkdownProps<\"p\">) => (\n    <TypesetParagraph {...withoutNode(props)} />\n  ),\n  pre: (props: MarkdownProps<\"pre\">) => (\n    <TypesetCodeBlock {...withoutNode(props)} />\n  ),\n  strong: (props: MarkdownProps<\"strong\">) => (\n    <TypesetStrong {...withoutNode(props)} />\n  ),\n  table: (props: MarkdownProps<\"table\">) => (\n    <NativeTable hoverable={false} {...withoutNodeAndStyle(props)} />\n  ),\n  tbody: (props: MarkdownProps<\"tbody\">) => (\n    <NativeTableBody {...withoutNodeAndStyle(props)} />\n  ),\n  td: MarkdownTableCell,\n  tfoot: (props: MarkdownProps<\"tfoot\">) => (\n    <NativeTableFooter {...withoutNodeAndStyle(props)} />\n  ),\n  th: MarkdownTableHead,\n  thead: (props: MarkdownProps<\"thead\">) => (\n    <NativeTableHeader {...withoutNodeAndStyle(props)} />\n  ),\n  tr: (props: MarkdownProps<\"tr\">) => (\n    <NativeTableRow hoverable={false} {...withoutNodeAndStyle(props)} />\n  ),\n  ul: (props: MarkdownProps<\"ul\">) => <TypesetList {...withoutNode(props)} />,\n};\n","type":"registry:ui","target":"~/src/siteplane/components/typeset/typeset.tsx"},{"path":"packages/ui/src/components/typeset/typeset.css","content":"@layer theme, base, components, siteplane, utilities;\n\n@layer siteplane {\n  /* Rendered markdown. The container sets the kit's body type and stacks its\n   * blocks with `gap` (S10), so a block adds its own space above itself and no\n   * block restyles the one before it while a reply streams: no rule below\n   * reads :last-child, :has() or :empty, which change as content arrives.\n   *\n   * Three custom properties tune it, as the density sets them: the size of the\n   * text, its leading (a line height) and the flow (the gap between blocks).\n   * The flow is a length from the size, so it does not change with the\n   * font-size of whatever it is read on. A caller overrides them in a style\n   * or with a utility class.\n   *\n   * Chat is the kit's own body type, 14 px on a 21 px line, with a tight\n   * rhythm for a message. Document is a size up with a roomier rhythm. */\n  :where(.sp-typeset) {\n    --sp-typeset-size: var(--sp-rw-font-size-default);\n    --sp-typeset-leading: var(--sp-rw-line-height-default);\n    --sp-typeset-flow: var(--sp-typeset-size);\n    display: grid;\n    grid-template-columns: minmax(0, 1fr);\n    gap: var(--sp-typeset-flow);\n    min-width: 0;\n    font-size: var(--sp-typeset-size);\n    line-height: var(--sp-typeset-leading);\n    /* Long values wrap (rulebook K5): a URL or an identifier never pushes the\n     * thread wider. */\n    overflow-wrap: anywhere;\n    text-align: start;\n  }\n\n  .sp-typeset[data-density=\"document\"] {\n    --sp-typeset-size: var(--sp-rw-font-16);\n    --sp-typeset-leading: var(--sp-rw-line-27);\n    --sp-typeset-flow: calc(var(--sp-typeset-size) * 1.5);\n  }\n\n  /* Headings step up from the body size in the ratios of the kit's own type\n   * (20, 18, 16 and 14 px at the chat size), so they scale with the density.\n   * Titles take the title weight and the small ones the label weight, as\n   * the kit's text roles do (Theming). A heading has half a flow of extra\n   * space above it, in its own padding and not in a margin, so it belongs to\n   * what follows more than to what comes before; the first block has none. */\n  :where(.sp-typeset-heading) {\n    padding-block-start: calc(var(--sp-typeset-flow) * 0.5);\n    font-size: 1em;\n    font-weight: var(--sp-rw-font-weight-label);\n    line-height: 1.5;\n  }\n\n  :where(.sp-typeset-heading:first-child) {\n    padding-block-start: 0;\n  }\n\n  :where(.sp-typeset-heading[data-level=\"1\"]) {\n    font-size: calc(20em / 14);\n    font-weight: var(--sp-rw-font-weight-title);\n  }\n\n  :where(.sp-typeset-heading[data-level=\"2\"]) {\n    font-size: calc(18em / 14);\n    font-weight: var(--sp-rw-font-weight-title);\n  }\n\n  :where(.sp-typeset-heading[data-level=\"3\"]) {\n    font-size: calc(16em / 14);\n    font-weight: var(--sp-rw-font-weight-title);\n  }\n\n  :where(.sp-typeset-strong) {\n    font-weight: var(--sp-rw-font-weight-strong);\n  }\n\n  /* A list keeps its marks (the list semantics stay) and takes the flow's\n   * smaller step between its items. Its mark is quieter than the text. */\n  :where(.sp-typeset-list) {\n    display: grid;\n    gap: var(--sp-rw-space-1-5);\n    padding-inline-start: 1.625em;\n    list-style-position: outside;\n    list-style-type: disc;\n  }\n\n  :where(.sp-typeset-list[data-ordered]) {\n    list-style-type: decimal;\n  }\n\n  :where(.sp-typeset-list-item .sp-typeset-list:not([data-ordered])) {\n    list-style-type: circle;\n  }\n\n  :where(.sp-typeset-list-item)::marker {\n    color: var(--sp-rw-fg-tertiary);\n  }\n\n  /* A list item is a list-item box, so it cannot be a grid, which would put\n   * each run of its text in a cell of its own. The blocks inside it (a second\n   * paragraph of a loose item, a nested list, a code block, a table) are\n   * spaced by the margin above them, the one place a margin is the tool;\n   * the owner of a block never sets its margin. */\n  :where(.sp-typeset-list-item > .sp-typeset-paragraph:not(:first-child)),\n  :where(.sp-typeset-list-item > .sp-typeset-list),\n  :where(.sp-typeset-list-item > .sp-typeset-quote),\n  :where(.sp-typeset-list-item > .sp-typeset-rule),\n  :where(.sp-typeset-list-item > .sp-code-block),\n  :where(.sp-typeset-list-item > .sp-table-container) {\n    margin-block-start: var(--sp-rw-space-1-5);\n  }\n\n  /* A quote is a bar at the start and quieter text. Its own blocks are\n   * stacked by the same flow. */\n  :where(.sp-typeset-quote) {\n    display: grid;\n    gap: var(--sp-typeset-flow);\n    padding-inline-start: var(--sp-rw-space-4);\n    border-inline-start: calc(var(--sp-rw-border-default) * 3) solid\n      var(--sp-rw-fg-quaternary);\n    color: var(--sp-rw-fg-secondary);\n  }\n\n  /* A table scrolls sideways in its own container, so a long value cannot\n   * push the thread wider (K5) and its cells keep words whole: they wrap\n   * between words only, as Native Table's do outside Typeset. Before, they\n   * took the wrap anywhere from the container above, and on a phone a name\n   * broke mid-word (Lukas, 30 October 2026). */\n  :where(.sp-typeset .sp-table-container) {\n    overflow-wrap: normal;\n  }\n}\n","type":"registry:ui","target":"~/src/siteplane/components/typeset/typeset.css"},{"path":"packages/ui/src/components/typeset/index.ts","content":"export * from \"./typeset\";\n","type":"registry:ui","target":"~/src/siteplane/components/typeset/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/typeset.md","categories":["Chat & AI"],"meta":{"avoidWhen":["Avoid for the labels and text of an interface, such as a card, a form or a dialog; kit components set their own type.","Avoid for text that people edit; Typeset only presents rendered markdown.","Avoid for a data table that sorts, selects or paginates; Table owns that, and a table in written text only presents its cells."],"description":"Set rendered markdown, such as a chat answer or a document, in the kit's type, with a map for the components prop of a markdown renderer.","knownLimitations":["The kit has no markdown renderer: render markdown with react-markdown, Streamdown or another renderer that takes a components map, and pass typesetComponents.","The map covers paragraphs, headings, lists, links, quotes, rules, strong text, inline code, code fences and tables. Images, emphasis and strikethrough render as the renderer renders them.","A code fence highlights only once the app has turned on the optional code-highlight item; until then, and without it, a fence shows plain text.","A table takes no inline style from the renderer: the alignment of a column becomes the alignment of its cells, and the rest is dropped.","Which markdown and HTML is allowed, and how links and images are checked, is the renderer's."],"providerNotes":["Depends on Code, Button, Separator and Native Table, which the registry installs with Typeset."],"releaseStatus":"preview","searchTerms":["typeset","markdown","prose","typography","rich text","react-markdown","streamdown"],"useWhen":["Use to render the markdown of an AI answer, through typesetComponents in react-markdown or Streamdown, in the kit's type.","Use for written text with headings, lists, quotes, code and tables, such as a help article or a long answer, in the document density."]}}