AshUi.Markdown (ash_ui v0.1.0)

Copy Markdown View Source

Markdown source -> sanitized AshUi.SafeHTML.

This is the single shared markdown seam for AshUi views. It lives beside AshUi.SafeHTML because SafeHTML is the only sanctioned way to put non-escaped markup into a render tree: the constructor vocabulary is closed (AshUi.ViewConstructor), so a view keeps emitting an ordinary text(...) node and instead changes what that expression evaluates to.

Callers build the value where the row is built (a calculation), never in the view:

calculate :body_html, :term, fn records, _context ->
  Enum.map(records, &AshUi.Markdown.to_safe_html(&1.plain_text))
end

Why this is self-contained

Rendering is done here rather than through a markdown library because adding any dependency to this package forces Mix to re-resolve and bump unrelated transitive pins. The supported syntax is deliberately a small, chat-shaped subset, which is tractable to render directly and keeps the package dependency-free.

Supported: paragraphs, #..###### headings, **bold**/__bold__, *italic*/_italic_, ~~strikethrough~~, `code spans`, fenced code blocks, unordered (-/*/+) and ordered (1.) lists, > blockquotes, thematic breaks, GFM pipe tables, and [text](href) links. Single newlines inside a paragraph become <br>, which matches how chat bodies are written.

A table is recognised only when a header row is followed immediately by a delimiter row (|---|:-:|--:|), so ordinary prose containing a | is never mistaken for one. Rows are padded or truncated to the header's column count, so a ragged row cannot shift later cells into the wrong column.

Sanitization

SafeHTML means "emit these bytes verbatim" and performs no escaping of its own, so everything here is whitelisted by construction rather than filtered after the fact. Message bodies are user input and assistant replies are model output; neither is trusted.

  • Only the tags this module writes are ever emitted -- there is no path from source text to an arbitrary tag name.
  • Every run of text is escaped through AshUi.RenderTarget.Html.html_escape/1, so raw HTML in the source is inert: a user who types <script>alert(1)</script> sees that text.
  • The only attributes ever emitted are href and rel on <a>, and data-align on a table cell, whose value is drawn from the closed set center/right rather than from the source.
  • href is scheme-checked against @safe_schemes after control characters are stripped, so javascript:, data:, vbscript: and obfuscations such as java&#9;script: are dropped, leaving a plain <a> with no href.
  • Every <a> carries rel="noopener noreferrer".
  • Images never render: ![alt](src) degrades to its alt text.

No class or style attribute is ever emitted -- markdown output is painted by descendant-element CSS under the owning [part=...] node, matching the existing [part="agent_markdown"] code rule.

Summary

Functions

Parses one GFM pipe-table block into raw cell text and closed alignments.

Splits source into consecutive {:table, text} and {:prose, text} runs.

Renders markdown source into sanitized HTML iodata.

Renders markdown source into a sanitized AshUi.SafeHTML struct, suitable as the text content of a render-tree node.

Renders markdown inline syntax without adding a block wrapper.

Functions

parse_table(source)

@spec parse_table(term()) ::
  {:ok, %{headers: [map()], rows: [[map()]]}} | {:error, :not_a_table}

Parses one GFM pipe-table block into raw cell text and closed alignments.

Callers can then render the table with ordinary AshUi table/cell nodes, preserving interactive content inside cells without reimplementing the markdown table grammar.

split_tables(source)

@spec split_tables(term()) :: [{:table | :prose, binary()}]

Splits source into consecutive {:table, text} and {:prose, text} runs.

Table syntax is known only to this module, so callers that must treat a table as an indivisible unit ask here rather than re-deriving the grammar. Structural consumers can then pass each table block to parse_table/1 and keep interactive children inside their owning cells.

Concatenating the returned texts reproduces source exactly.

to_iodata(source)

@spec to_iodata(term()) :: iodata()

Renders markdown source into sanitized HTML iodata.

to_safe_html(source)

@spec to_safe_html(term()) :: AshUi.SafeHTML.t()

Renders markdown source into a sanitized AshUi.SafeHTML struct, suitable as the text content of a render-tree node.

to_safe_inline_html(source)

@spec to_safe_inline_html(term()) :: AshUi.SafeHTML.t()

Renders markdown inline syntax without adding a block wrapper.

This is intended for structural consumers such as table cells that still need AshUi-owned interactive children between formatted text runs.