Packages
mdex
0.12.4
0.13.3
0.13.2
0.13.1
0.13.0
0.12.5
retired
0.12.4
0.12.3
0.12.2
0.12.1
0.12.0
0.11.7
0.11.6
0.11.5
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.0
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.2
0.6.1
0.6.0
0.5.0
0.4.3
0.4.2
0.4.1
0.4.0
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.18
0.1.17
0.1.16
0.1.15
0.1.14
0.1.13
0.1.12
0.1.11
0.1.10
0.1.9
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
Fast and extensible Markdown for Elixir
Security advisory:
This version has known vulnerabilities.
View advisories
Current section
Files
Jump to
Current section
Files
lib/mdex/delta_converter.ex
defmodule MDEx.DeltaConverter do
@moduledoc false
alias MDEx.Document
@typedoc "Delta operation map"
@type delta_op :: %{
required(:insert) => String.t(),
optional(:attributes) => map()
}
@typedoc "Complete Delta document"
@type delta :: %{ops: [delta_op()]}
@typedoc "Conversion options"
@type options :: keyword()
@doc """
Convert an MDEx document to Quill Delta format.
## Examples
iex> doc = %MDEx.Document{nodes: [%MDEx.Text{literal: "Hello"}]}
iex> MDEx.DeltaConverter.convert(doc, %{})
{:ok, [%{"insert" => "Hello"}]}
"""
@spec convert(Document.t(), options()) :: {:ok, [delta_op()]} | {:error, term()}
def convert(%Document{nodes: nodes}, options) do
try do
# Initialize list_depth if not provided
options = Keyword.put_new(options, :list_depth, 0)
ops = convert_nodes(nodes, [], options)
{:ok, ops}
rescue
error -> {:error, error}
catch
error -> {:error, error}
end
end
# Convert a list of nodes, accumulating operations and current formatting state
@spec convert_nodes([term()], [map()], options()) :: [delta_op()]
defp convert_nodes([], _current_attrs, _options), do: []
defp convert_nodes([node | rest], current_attrs, options) do
node_ops = convert_node(node, current_attrs, options)
rest_ops = convert_nodes(rest, current_attrs, options)
# Add extra newline between consecutive paragraphs only
case {node, rest} do
{%MDEx.Paragraph{}, [%MDEx.Paragraph{} | _]} ->
# Paragraph followed by another paragraph - add extra newline for visual separation
node_ops ++ [%{"insert" => "\n"}] ++ rest_ops
_ ->
node_ops ++ rest_ops
end
end
# Convert individual nodes to delta operations
@spec convert_node(term(), [map()], options()) :: [delta_op()]
defp convert_node(node, current_attrs, options) do
# Check for custom converters first
custom_converters = Keyword.get(options, :custom_converters, %{})
case Map.get(custom_converters, node.__struct__) do
converter when is_function(converter, 2) ->
case converter.(node, options) do
:skip -> []
{:error, reason} -> throw({:custom_converter_error, reason})
ops when is_list(ops) -> ops
other -> throw({:custom_converter_error, "Custom converter returned invalid value: #{inspect(other)}"})
end
nil ->
# Use default conversion
default_convert_node(node, current_attrs, options)
end
end
# Default conversion for all node types
@spec default_convert_node(term(), [map()], options()) :: [delta_op()]
defp default_convert_node(node, current_attrs, options)
# Document - process child nodes
defp default_convert_node(%MDEx.Document{nodes: nodes}, current_attrs, options) do
convert_nodes(nodes, current_attrs, options)
end
# Paragraph - process children, add paragraph break at end
defp default_convert_node(%MDEx.Paragraph{nodes: nodes}, current_attrs, options) do
child_ops = convert_nodes(nodes, current_attrs, options)
child_ops ++ [%{"insert" => "\n"}]
end
# Text - insert literal content
defp default_convert_node(%MDEx.Text{literal: text}, current_attrs, _options) do
attrs = merge_attributes(current_attrs)
if map_size(attrs) == 0 do
[%{"insert" => text}]
else
[%{"insert" => text, "attributes" => attrs}]
end
end
# Strong (bold) - add bold attribute to children
defp default_convert_node(%MDEx.Strong{nodes: nodes}, current_attrs, options) do
new_attrs = [%{"bold" => true} | current_attrs]
convert_nodes(nodes, new_attrs, options)
end
# Emph (italic) - add italic attribute to children
defp default_convert_node(%MDEx.Emph{nodes: nodes}, current_attrs, options) do
new_attrs = [%{"italic" => true} | current_attrs]
convert_nodes(nodes, new_attrs, options)
end
# Code (inline) - add code attribute to children
defp default_convert_node(%MDEx.Code{literal: code}, current_attrs, _options) do
attrs = merge_attributes([%{"code" => true} | current_attrs])
[%{"insert" => code, "attributes" => attrs}]
end
# Headings - process children with header attribute on line break
defp default_convert_node(%MDEx.Heading{nodes: nodes, level: level}, current_attrs, options) do
child_ops = convert_nodes(nodes, current_attrs, options)
header_attrs = merge_attributes([%{"header" => level} | current_attrs])
child_ops ++ [%{"insert" => "\n", "attributes" => header_attrs}]
end
# SoftBreak - insert space (Quill doesn't distinguish soft/hard breaks in plain text)
defp default_convert_node(%MDEx.SoftBreak{}, _current_attrs, _options) do
[%{"insert" => " "}]
end
# LineBreak - insert explicit line break
defp default_convert_node(%MDEx.LineBreak{}, _current_attrs, _options) do
[%{"insert" => "\n"}]
end
# Phase 2: Exotic Nodes with Custom Attributes
# Strikethrough - add strike attribute to children
defp default_convert_node(%MDEx.Strikethrough{nodes: nodes}, current_attrs, options) do
new_attrs = [%{"strike" => true} | current_attrs]
convert_nodes(nodes, new_attrs, options)
end
# Underline - add underline attribute to children
defp default_convert_node(%MDEx.Underline{nodes: nodes}, current_attrs, options) do
new_attrs = [%{"underline" => true} | current_attrs]
convert_nodes(nodes, new_attrs, options)
end
# Link - add link attribute to children
defp default_convert_node(%MDEx.Link{url: url, nodes: nodes}, current_attrs, options) do
new_attrs = [%{"link" => url} | current_attrs]
convert_nodes(nodes, new_attrs, options)
end
# Image - use custom insert object
defp default_convert_node(%MDEx.Image{url: url, title: title}, _current_attrs, _options) do
image_data = %{"image" => url}
image_data = if title, do: Map.put(image_data, "alt", title), else: image_data
[%{"insert" => image_data}]
end
# BlockQuote - apply blockquote attribute to newlines
defp default_convert_node(%MDEx.BlockQuote{nodes: nodes}, current_attrs, options) do
child_ops = convert_nodes(nodes, current_attrs, options)
apply_block_format(child_ops, %{"blockquote" => true})
end
# CodeBlock - insert code with code-block attribute
defp default_convert_node(%MDEx.CodeBlock{literal: code, info: info}, _current_attrs, _options) do
attrs = %{"code-block" => true}
attrs = if info && info != "", do: Map.put(attrs, "code-block-lang", info), else: attrs
[
%{"insert" => code},
%{"insert" => "\n", "attributes" => attrs}
]
end
# ThematicBreak (HR) - convert to text representation
defp default_convert_node(%MDEx.ThematicBreak{}, _current_attrs, _options) do
[%{"insert" => "***\n"}]
end
# Lists - support for bullet and ordered
defp default_convert_node(%MDEx.List{nodes: items}, current_attrs, options) do
convert_nodes(items, current_attrs, options)
end
defp default_convert_node(%MDEx.ListItem{list_type: list_type, nodes: children}, current_attrs, options) do
current_depth = Keyword.get(options, :list_depth, 0)
# Separate paragraph content from nested lists
{paragraph_content, nested_lists} =
Enum.split_with(children, fn node ->
not match?(%MDEx.List{}, node)
end)
# Process paragraph content first
paragraph_ops =
Enum.flat_map(paragraph_content, fn
%MDEx.Paragraph{nodes: paragraph_children} ->
convert_nodes(paragraph_children, current_attrs, options)
node ->
convert_node(node, current_attrs, options)
end)
# Create list attributes with proper indent
list_attr =
case list_type do
:bullet -> "bullet"
:ordered -> "ordered"
_ -> "bullet"
end
list_attrs = %{"list" => list_attr}
list_attrs =
if current_depth > 0 do
Map.put(list_attrs, "indent", current_depth)
else
list_attrs
end
# Add the list item newline
list_item_ops = paragraph_ops ++ [%{"insert" => "\n", "attributes" => list_attrs}]
# Process nested lists after the current item with increased depth
nested_ops =
Enum.flat_map(nested_lists, fn nested_list ->
nested_options = Keyword.put(options, :list_depth, current_depth + 1)
convert_node(nested_list, current_attrs, nested_options)
end)
list_item_ops ++ nested_ops
end
# TaskItem - block-level attribute on newline
defp default_convert_node(%MDEx.TaskItem{checked: checked, nodes: children}, current_attrs, options) do
# Extract content from children (usually paragraphs)
child_ops =
Enum.flat_map(children, fn
%MDEx.Paragraph{nodes: paragraph_children} ->
convert_nodes(paragraph_children, current_attrs, options)
node ->
convert_node(node, current_attrs, options)
end)
# Apply task attribute as block-level on the newline
task_attrs = %{"list" => "bullet", "task" => checked}
child_ops ++ [%{"insert" => "\n", "attributes" => task_attrs}]
end
# FootnoteReference - inline attribute
defp default_convert_node(%MDEx.FootnoteReference{name: name}, current_attrs, _options) do
attrs = merge_attributes([%{"footnote_ref" => name} | current_attrs])
[%{"insert" => "[^#{name}]", "attributes" => attrs}]
end
# FootnoteDefinition - block-level attribute on newline
defp default_convert_node(%MDEx.FootnoteDefinition{name: name, nodes: children}, current_attrs, options) do
child_ops = convert_nodes(children, current_attrs, options)
# Apply footnote definition to the final newline
case List.last(child_ops) do
%{"insert" => "\n", "attributes" => attrs} ->
List.replace_at(child_ops, -1, %{
"insert" => "\n",
"attributes" => Map.put(attrs, "footnote_definition", name)
})
%{"insert" => "\n"} ->
List.replace_at(child_ops, -1, %{
"insert" => "\n",
"attributes" => %{"footnote_definition" => name}
})
_ ->
# No trailing newline, add one with the attribute
child_ops ++ [%{"insert" => "\n", "attributes" => %{"footnote_definition" => name}}]
end
end
# Subscript and superscript - inline attributes
defp default_convert_node(%MDEx.Subscript{nodes: children}, current_attrs, options) do
new_attrs = [%{"subscript" => true} | current_attrs]
convert_nodes(children, new_attrs, options)
end
defp default_convert_node(%MDEx.Superscript{nodes: children}, current_attrs, options) do
new_attrs = [%{"superscript" => true} | current_attrs]
convert_nodes(children, new_attrs, options)
end
# Math - inline attribute with type
defp default_convert_node(%MDEx.Math{literal: math, display_math: display?}, current_attrs, _options) do
math_type = if display?, do: "display", else: "inline"
attrs = merge_attributes([%{"math" => math_type} | current_attrs])
[%{"insert" => math, "attributes" => attrs}]
end
# Alerts - custom attributes for type and styling
defp default_convert_node(%MDEx.Alert{alert_type: type, title: title, nodes: children}, current_attrs, options) do
child_ops = convert_nodes(children, current_attrs, options)
alert_attrs = %{"alert" => Atom.to_string(type)}
alert_attrs = if title, do: Map.put(alert_attrs, "alert_title", title), else: alert_attrs
apply_block_format(child_ops, alert_attrs)
end
# SpoileredText - inline attribute
defp default_convert_node(%MDEx.SpoileredText{nodes: children}, current_attrs, options) do
new_attrs = [%{"spoiler" => true} | current_attrs]
convert_nodes(children, new_attrs, options)
end
# WikiLinks - link with wikilink attribute
defp default_convert_node(%MDEx.WikiLink{url: url, nodes: children}, current_attrs, options) do
new_attrs = [%{"link" => url, "wikilink" => true} | current_attrs]
convert_nodes(children, new_attrs, options)
end
# Tables - standard Quill Delta format with table-cell-line, table-row, and table attributes
defp default_convert_node(%MDEx.Table{nodes: rows}, current_attrs, options) do
# Process all rows and collect their ops
row_ops =
rows
|> Enum.with_index()
|> Enum.flat_map(fn {row, row_index} ->
row_id = "row-#{row_index + 1}"
convert_table_row(row, row_id, current_attrs, options)
end)
# Add final table newline
row_ops ++ [%{"insert" => "\n", "attributes" => %{"table" => true}}]
end
# ShortCode - already processed to emoji, treat as text
defp default_convert_node(%MDEx.ShortCode{emoji: emoji}, current_attrs, _options) do
attrs = merge_attributes(current_attrs)
if map_size(attrs) == 0 do
[%{"insert" => emoji}]
else
[%{"insert" => emoji, "attributes" => attrs}]
end
end
# Raw - pass through as-is
defp default_convert_node(%MDEx.Raw{literal: content}, current_attrs, _options) do
attrs = merge_attributes(current_attrs)
if map_size(attrs) == 0 do
[%{"insert" => content}]
else
[%{"insert" => content, "attributes" => attrs}]
end
end
# FrontMatter - block-level attribute
defp default_convert_node(%MDEx.FrontMatter{literal: content}, _current_attrs, _options) do
[
%{"insert" => content},
%{"insert" => "\n", "attributes" => %{"front_matter" => true}}
]
end
# HTML nodes - block vs inline attributes
defp default_convert_node(%MDEx.HtmlBlock{literal: html}, _current_attrs, _options) do
[
%{"insert" => html},
%{"insert" => "\n", "attributes" => %{"html" => "block"}}
]
end
defp default_convert_node(%MDEx.HtmlInline{literal: html}, current_attrs, _options) do
attrs = merge_attributes([%{"html" => "inline"} | current_attrs])
[%{"insert" => html, "attributes" => attrs}]
end
# Fallback for unknown node types
defp default_convert_node(node, current_attrs, options) do
# Unknown node type - try to extract text content or skip
extract_text_content(node, current_attrs, options)
end
# Helper to extract text content from unknown nodes
defp extract_text_content(%{literal: text}, current_attrs, _options) when is_binary(text) do
attrs = merge_attributes(current_attrs)
if map_size(attrs) == 0 do
[%{"insert" => text}]
else
[%{"insert" => text, "attributes" => attrs}]
end
end
defp extract_text_content(%{nodes: nodes}, current_attrs, options) when is_list(nodes) do
# Try to process child nodes for containers we don't explicitly handle
convert_nodes(nodes, current_attrs, options)
end
defp extract_text_content(_node, _current_attrs, _options) do
# Unknown node structure - skip it
[]
end
# Helper function to convert table rows with proper IDs
defp convert_table_row(%MDEx.TableRow{header: _is_header, nodes: cells}, row_id, current_attrs, options) do
# Process all cells in the row
cell_ops =
cells
|> Enum.with_index()
|> Enum.flat_map(fn {cell, cell_index} ->
cell_id = "cell-#{cell_index + 1}"
convert_table_cell(cell, row_id, cell_id, current_attrs, options)
end)
# Add row-ending newline
cell_ops ++ [%{"insert" => "\n", "attributes" => %{"table-row" => row_id}}]
end
# Helper function to convert table cells with proper attributes
defp convert_table_cell(%MDEx.TableCell{nodes: children}, row_id, cell_id, current_attrs, options) do
# Process cell content recursively for formatting
cell_content = convert_nodes(children, current_attrs, options)
# Add table-cell-line attribute to the cell content newline
# If the cell content ends with a newline (from paragraph), modify it
case List.last(cell_content) do
%{"insert" => "\n", "attributes" => attrs} ->
List.replace_at(cell_content, -1, %{
"insert" => "\n",
"attributes" => Map.put(attrs, "table-cell-line", %{"row" => row_id, "cell" => cell_id})
})
%{"insert" => "\n"} ->
List.replace_at(cell_content, -1, %{
"insert" => "\n",
"attributes" => %{"table-cell-line" => %{"row" => row_id, "cell" => cell_id}}
})
_ ->
# No trailing newline, add one with table-cell-line attribute
cell_content ++
[
%{
"insert" => "\n",
"attributes" => %{"table-cell-line" => %{"row" => row_id, "cell" => cell_id}}
}
]
end
end
# Merge attribute maps from the attribute stack
@spec merge_attributes([map()]) :: map()
defp merge_attributes(attr_list) do
Enum.reduce(attr_list, %{}, fn attrs, acc ->
Map.merge(acc, attrs)
end)
end
# Apply block formatting to newline characters
@spec apply_block_format([delta_op()], map()) :: [delta_op()]
defp apply_block_format(ops, block_attributes) do
Enum.map(ops, fn op ->
case op do
%{"insert" => "\n", "attributes" => attrs} ->
%{"insert" => "\n", "attributes" => Map.merge(attrs, block_attributes)}
%{"insert" => "\n"} ->
%{"insert" => "\n", "attributes" => block_attributes}
other ->
other
end
end)
end
end