Cookbook: Common Patterns

Copy Markdown View Source

Practical recipes for common tasks with Quillon.

Document Creation

Creating a Blog Post

def create_blog_post(title, author, content_paragraphs) do
  Quillon.document([
    Quillon.heading(1, title),
    Quillon.paragraph([
      Quillon.text("By "),
      Quillon.text(author, [:italic])
    ]),
    Quillon.divider(),
    | Enum.map(content_paragraphs, &Quillon.paragraph/1)
  ])
end

Creating a FAQ Section

def create_faq(questions_and_answers) do
  items = Enum.flat_map(questions_and_answers, fn {question, answer} ->
    [
      Quillon.heading(3, question),
      Quillon.paragraph(answer)
    ]
  end)

  Quillon.document([
    Quillon.heading(2, "Frequently Asked Questions")
    | items
  ])
end

Creating a Code Tutorial

def create_tutorial(title, steps) do
  content = Enum.flat_map(steps, fn {description, code, language} ->
    [
      Quillon.paragraph(description),
      Quillon.code_block(code, language)
    ]
  end)

  Quillon.document([
    Quillon.heading(1, title)
    | content
  ])
end

Working with Lists

Creating a Nested List

def nested_list do
  Quillon.bullet_list([
    Quillon.list_item([
      Quillon.paragraph("First level item"),
      Quillon.bullet_list([
        Quillon.list_item([Quillon.paragraph("Nested item 1")]),
        Quillon.list_item([Quillon.paragraph("Nested item 2")])
      ])
    ]),
    Quillon.list_item([Quillon.paragraph("Another first level")])
  ])
end

Converting List Types

# Toggle between bullet and ordered list
list = Quillon.bullet_list([...])
ordered = Quillon.toggle_list_type(list)  # Now an ordered list
bullet = Quillon.toggle_list_type(ordered)  # Back to bullet

Extracting List Items as Text

def list_to_strings({type, _attrs, items}) when type in [:bullet_list, :ordered_list] do
  Enum.map(items, fn {:list_item, _, children} ->
    children
    |> Enum.flat_map(&extract_text/1)
    |> Enum.join("")
  end)
end

defp extract_text({:paragraph, _, children}), do: Enum.map(children, &extract_text/1)
defp extract_text({:text, %{text: text}, _}), do: [text]
defp extract_text(_), do: []

Working with Tables

Creating a Data Table

def create_table(headers, rows) do
  header_row = Quillon.table_row(
    Enum.map(headers, fn h ->
      Quillon.table_cell([Quillon.paragraph(h)])
    end),
    header: true
  )

  data_rows = Enum.map(rows, fn row ->
    Quillon.table_row(
      Enum.map(row, fn cell ->
        Quillon.table_cell([Quillon.paragraph(to_string(cell))])
      end)
    )
  end)

  Quillon.table([header_row | data_rows])
end

# Usage
create_table(
  ["Name", "Age", "City"],
  [
    ["Alice", 30, "New York"],
    ["Bob", 25, "London"]
  ]
)

Adding a Row to an Existing Table

def append_row(table, cell_values) do
  {:table, attrs, rows} = table
  row_count = length(rows)

  new_row = Quillon.table_row(
    Enum.map(cell_values, fn value ->
      Quillon.table_cell([Quillon.paragraph(to_string(value))])
    end)
  )

  Quillon.add_row(table, row_count)
  |> then(fn {:ok, t} ->
    Quillon.update(t, [row_count], fn _ -> new_row end)
  end)
end

Tree Traversal

Finding All Nodes of a Type

def find_all(node, type) do
  find_all(node, type, [])
end

defp find_all({node_type, _attrs, children} = node, type, acc) do
  acc = if node_type == type, do: [node | acc], else: acc

  Enum.reduce(children, acc, fn child, acc ->
    find_all(child, type, acc)
  end)
end

Extracting All Text

def extract_all_text(node) do
  extract_all_text(node, [])
  |> Enum.reverse()
  |> Enum.join("")
end

defp extract_all_text({:text, %{text: text}, _}, acc), do: [text | acc]

defp extract_all_text({_type, _attrs, children}, acc) do
  Enum.reduce(children, acc, &extract_all_text/2)
end

Counting Words

def word_count(doc) do
  doc
  |> extract_all_text()
  |> String.split(~r/\s+/, trim: true)
  |> length()
end

Walking the Tree with Path

def walk_with_path(node, fun, path \\ []) do
  fun.(node, path)

  {_type, _attrs, children} = node

  children
  |> Enum.with_index()
  |> Enum.each(fn {child, index} ->
    walk_with_path(child, fun, path ++ [index])
  end)
end

# Usage: Print all nodes with their paths
walk_with_path(doc, fn node, path ->
  IO.inspect({path, Quillon.type(node)})
end)

Transformations

Converting Headings to a Table of Contents

def table_of_contents(doc) do
  headings = find_all(doc, :heading)

  items = Enum.map(headings, fn {:heading, %{level: level}, children} ->
    text = children
           |> Enum.map(fn {:text, %{text: t}, _} -> t end)
           |> Enum.join("")

    # Indent based on level
    indent = String.duplicate("  ", level - 1)
    Quillon.list_item([Quillon.paragraph("#{indent}#{text}")])
  end)

  Quillon.bullet_list(items)
end

Stripping All Formatting

def strip_formatting(doc) do
  transform_tree(doc, fn
    {:text, %{text: text}, []} ->
      {:text, %{text: text, marks: []}, []}

    node ->
      node
  end)
end

defp transform_tree({type, attrs, children}, fun) do
  transformed_children = Enum.map(children, &transform_tree(&1, fun))
  fun.({type, attrs, transformed_children})
end

Adding IDs to All Blocks

def add_ids(doc) do
  {doc, _counter} = add_ids(doc, 0)
  doc
end

defp add_ids({type, attrs, children}, counter) do
  {children, counter} = Enum.map_reduce(children, counter, &add_ids/2)

  if Quillon.block?({type, attrs, children}) do
    attrs = Map.put(attrs, :id, "block_#{counter}")
    {{type, attrs, children}, counter + 1}
  else
    {{type, attrs, children}, counter}
  end
end

Validation Patterns

Validating Before Save

def save_document(doc) do
  case Quillon.validate(doc) do
    {:ok, valid_doc} ->
      # Save to database
      {:ok, persist(valid_doc)}

    {:error, errors} ->
      {:error, format_errors(errors)}
  end
end

defp format_errors(errors) do
  Enum.map(errors, fn {path, message} ->
    "At #{inspect(path)}: #{message}"
  end)
end

Custom Validation Rules

def validate_max_heading_level(doc, max_level) do
  headings = find_all(doc, :heading)

  invalid = Enum.filter(headings, fn {:heading, %{level: level}, _} ->
    level > max_level
  end)

  if invalid == [] do
    :ok
  else
    {:error, "Found #{length(invalid)} headings exceeding level #{max_level}"}
  end
end

JSON Patterns

Round-trip Testing

def round_trip(doc) do
  json = Quillon.to_json(doc)
  {:ok, parsed} = Quillon.from_json(json)

  # Verify equality (you might need custom comparison)
  doc == parsed
end

Storing in Ecto

# In your schema
schema "documents" do
  field :content, :map  # Stores JSON
  timestamps()
end

# Converting
def changeset(doc, attrs) do
  content = case attrs[:content] do
    %{} = json -> json
    tuple when is_tuple(tuple) -> Quillon.to_json(tuple)
    _ -> nil
  end

  doc
  |> cast(%{content: content}, [:content])
  |> validate_quillon_doc(:content)
end

defp validate_quillon_doc(changeset, field) do
  validate_change(changeset, field, fn _, value ->
    case Quillon.from_json(value) do
      {:ok, doc} ->
        case Quillon.validate(doc) do
          {:ok, _} -> []
          {:error, errors} -> [{field, "invalid document: #{inspect(errors)}"}]
        end
      {:error, msg} ->
        [{field, "invalid JSON: #{msg}"}]
    end
  end)
end

Performance Patterns

Lazy Document Building

# Instead of building large documents all at once,
# build incrementally with streams

def build_large_doc(items_stream) do
  children = items_stream
  |> Stream.map(&item_to_block/1)
  |> Enum.to_list()

  Quillon.document(children)
end

Caching Computed Values

defmodule DocumentCache do
  def get_word_count(doc) do
    # Cache key could be a hash of the document
    key = :erlang.phash2(doc)

    case :persistent_term.get({__MODULE__, :word_count, key}, nil) do
      nil ->
        count = compute_word_count(doc)
        :persistent_term.put({__MODULE__, :word_count, key}, count)
        count

      count ->
        count
    end
  end
end

Integration Patterns

Phoenix LiveView Integration

# In your LiveView
def mount(_params, _session, socket) do
  doc = Quillon.document([
    Quillon.paragraph("Start typing...")
  ])

  {:ok, assign(socket, doc: doc, json: Quillon.to_json(doc))}
end

def handle_event("update_doc", %{"content" => json}, socket) do
  case Quillon.from_json(json) do
    {:ok, doc} ->
      {:noreply, assign(socket, doc: doc, json: json)}

    {:error, _} ->
      {:noreply, socket}
  end
end

API Response Formatting

def render("document.json", %{document: doc}) do
  %{
    data: Quillon.to_json(doc),
    meta: %{
      word_count: word_count(doc),
      block_count: count_blocks(doc)
    }
  }
end