Ootempl.Block (ootempl v0.3.0)

Detects and parses block markers for hierarchical list iteration in tables.

Block markers allow creating nested table structures where rows are repeated for each item in a list, with support for header/body/footer sections.

Markers follow the syntax:

  • {{#list_key}} - Start iteration over a list
  • {{/list_key}} - End iteration block

Table Structure Example

Block markers are placed in dedicated rows that will be removed from output:

| {{#revcode_data}} |             |             |  <- Marker row (removed)
| {{revcode}}       | {{desc}}    | {{amount}}  |  <- Header row
| {{#children}}     |             |             |  <- Marker row (removed)
|                   | {{desc}}    | {{cost}}    |  <- Body row (repeated)
| {{/children}}     |             |             |  <- Marker row (removed)
|                   | Subtotal:   | {{subtotal}}|  <- Footer row
| {{/revcode_data}} |             |             |  <- Marker row (removed)

Data Scoping

  • Header/footer rows access parent item fields directly
  • Body rows access both parent and child fields
  • Child fields take precedence on name conflicts

Examples

iex> Ootempl.Block.detect_markers("{{#items}} content {{/items}}")
[
  %{type: :open, list_key: "items", position: 0},
  %{type: :close, list_key: "items", position: 19}
]

iex> Ootempl.Block.contains_markers?("{{#items}}")
true

iex> Ootempl.Block.contains_markers?("{{name}}")
false

Summary

Functions

Checks if the given text contains any block markers.

Detects all block markers in the given text.

Expands a block structure with the provided data.

Returns all row indices that should be removed from the original table.

Parses the table structure to identify block boundaries and row categories.

Validates that all block markers are properly paired.

Types

block_marker()

@type block_marker() :: %{
  type: :open | :close,
  list_key: String.t(),
  position: integer()
}

block_structure()

@type block_structure() :: %{
  list_key: String.t(),
  open_row_index: non_neg_integer(),
  close_row_index: non_neg_integer(),
  header_rows: [non_neg_integer()],
  body_block: block_structure() | nil,
  footer_rows: [non_neg_integer()]
}

Functions

contains_markers?(text)

@spec contains_markers?(String.t()) :: boolean()

Checks if the given text contains any block markers.

This is a quick check to determine if block processing is needed.

Parameters

  • text - The text to check

Returns

true if the text contains {{#...}} or {{/...}} markers, false otherwise.

Examples

iex> Ootempl.Block.contains_markers?("{{#items}}")
true

iex> Ootempl.Block.contains_markers?("{{name}}")
false

iex> Ootempl.Block.contains_markers?("plain text")
false

detect_markers(text)

@spec detect_markers(String.t()) :: [block_marker()]

Detects all block markers in the given text.

Returns a list of block markers in order of appearance with their positions.

Parameters

  • text - The text to scan for block markers

Returns

A list of marker maps, each containing:

  • :type - Either :open or :close
  • :list_key - The list variable name
  • :position - Character position in the text

Examples

iex> Ootempl.Block.detect_markers("{{#items}}content{{/items}}")
[
  %{type: :open, list_key: "items", position: 0},
  %{type: :close, list_key: "items", position: 17}
]

iex> Ootempl.Block.detect_markers("no markers here")
[]

expand_block(structure, rows, data)

@spec expand_block(block_structure(), [Ootempl.Xml.xml_element()], map()) :: [
  {Ootempl.Xml.xml_element(), map()}
]

Expands a block structure with the provided data.

For each item in the list:

  1. Clone header rows with parent item data
  2. If there's a nested body_block, recursively expand it
  3. Clone footer rows with parent item data

Marker-only rows are excluded from the output.

Parameters

  • structure - The block structure from parse_table_structure/2
  • rows - The original table rows
  • data - The data map containing the list to iterate

Returns

A list of {row_element, scoped_data} tuples ready for placeholder replacement.

marker_row_indices(structure)

@spec marker_row_indices(block_structure()) :: [non_neg_integer()]

Returns all row indices that should be removed from the original table.

These are the marker-only rows that contain block markers but no data placeholders.

parse_table_structure(rows, data)

@spec parse_table_structure([Ootempl.Xml.xml_element()], map()) ::
  {:ok, block_structure()} | {:error, term()}

Parses the table structure to identify block boundaries and row categories.

Analyzes table rows to build a block structure that identifies:

  • Which rows are marker-only rows (to be removed)
  • Which rows are header rows (before nested block)
  • Which rows form the body block (nested iteration)
  • Which rows are footer rows (after nested block)

Parameters

  • rows - List of table row XML elements
  • data - The data map to validate list keys against

Returns

  • {:ok, block_structure} with the parsed structure
  • {:error, reason} if parsing fails

Examples

# Single-level block
{:ok, structure} = Block.parse_table_structure(rows, data)
# => %{
#   list_key: "items",
#   open_row_index: 0,
#   close_row_index: 2,
#   header_rows: [1],
#   body_block: nil,
#   footer_rows: []
# }

validate_pairs(markers)

@spec validate_pairs([block_marker()]) :: :ok | {:error, String.t()}

Validates that all block markers are properly paired.

Uses stack-based validation to ensure each {{#key}} has a corresponding {{/key}} and detects orphaned or mismatched markers.

Parameters

Returns

  • :ok if all markers are properly paired
  • {:error, reason} if validation fails

Examples

iex> Ootempl.Block.validate_pairs([
...>   %{type: :open, list_key: "items", position: 0},
...>   %{type: :close, list_key: "items", position: 10}
...> ])
:ok

iex> Ootempl.Block.validate_pairs([
...>   %{type: :open, list_key: "items", position: 0}
...> ])
{:error, "Unmatched {{#items}} at position 0"}

iex> Ootempl.Block.validate_pairs([
...>   %{type: :close, list_key: "items", position: 0}
...> ])
{:error, "Orphan {{/items}} at position 0 (no matching {{#items}})"}

iex> Ootempl.Block.validate_pairs([
...>   %{type: :open, list_key: "items", position: 0},
...>   %{type: :close, list_key: "other", position: 10}
...> ])
{:error, "Mismatched block: found {{/other}} at position 10, expected {{/items}}"}