Laughter.Rewriter (Laughter v0.3.0)

Copy Markdown

Builds immutable HTML rewrite plans and executes them in one native pass.

alias Laughter.Rewriter

plan =
  Rewriter.new()
  |> Rewriter.remove("script")
  |> Rewriter.set_attribute("a[href]", "rel", "nofollow")
  |> Rewriter.remove_attribute("img", "onclick")

{:ok, output} = Rewriter.rewrite(plan, html)

Builder functions do not call native code. Plans can be reused and passed between processes. Rules run in registration order for each matching element; the last attribute write wins. Removing an element removes its content too. Selectors match the input HTML, not the output of earlier mutations. Inserted HTML is emitted as-is, not parsed again or matched by later rules.

Content operations

prepend_text/3, append_text/3, before_text/3, after_text/3, replace_text/3, and set_inner_text/3 escape &, <, and >. Their _html counterparts insert trusted markup verbatim. Content accepts UTF-8 iodata, even when the input document uses a different encoding. These operations are not a sanitizer or a JavaScript/CSS escaping API.

Ordering and conflicts in declarative plans

  • Repeated before and append insertions preserve registration order; repeated prepend and after insertions appear in reverse order.
  • The last replace or remove wins for an element. Attribute changes cannot resurrect it. Surrounding before/after insertions survive.
  • Inner operations are ignored on removed, replaced, or void elements.
  • set_inner replaces original content and discards earlier inner insertions. Later prepend/append operations are retained.
  • Replacing or removing an ancestor suppresses output from its original descendants, including their mutations.

Use rewrite/2 for complete documents or stream/3 for lazy chunked input and output. The parser's memory limit does not bound total input or output size; streaming has a separate per-write output limit.

The legacy on_element/3 and on_text/3 callback API remains available, but registrations are process-local and cannot be mixed with declarative rules.

Summary

Functions

Inserts content after matched elements. Inserts HTML verbatim; only pass trusted markup.

Inserts content after matched elements. Escapes &, <, and > before insertion.

Appends content inside matched elements. Inserts HTML verbatim; only pass trusted markup.

Appends content inside matched elements. Escapes &, <, and > before insertion.

Inserts content before matched elements. Inserts HTML verbatim; only pass trusted markup.

Inserts content before matched elements. Escapes &, <, and > before insertion.

Cancels a message-driven session and discards pending output.

Grants one output credit to a message-driven session.

Accepts EOF for a message-driven session; requires output credit.

Creates an empty plan without allocating native resources.

Registers a legacy element callback in the calling process.

Registers a legacy text callback in the calling process.

Prepends content inside matched elements. Inserts HTML verbatim; only pass trusted markup.

Prepends content inside matched elements. Escapes &, <, and > before insertion.

Removes matched elements, including their content.

Removes an attribute from matched elements. Missing attributes are ignored.

Replaces matched elements and their content. Inserts HTML verbatim; only pass trusted markup.

Replaces matched elements and their content. Escapes &, <, and > before insertion.

Replies to a dynamic element request with a list of mutations.

Rewrites a complete document in one native pass, returning a binary.

Sets an attribute on matched elements. Values are escaped by LOL HTML.

Replaces the content inside matched elements. Inserts HTML verbatim; only pass trusted markup.

Replaces the content inside matched elements. Escapes &, <, and > before insertion.

Starts an optional message-driven rewrite session linked to the caller.

Lazily rewrites an enumerable of binary or iodata chunks into binary chunks.

Accepts one bounded input chunk without waiting for handler replies.

Types

config()

@type config() :: t()

content_operation()

@type content_operation() ::
  ((((((((((:prepend_text | :prepend_html) | :append_text) | :append_html)
         | :before_text)
        | :before_html)
       | :after_text)
      | :after_html)
     | :replace_text)
    | :replace_html)
   | :set_inner_text)
  | :set_inner_html

element_handler()

@type element_handler() :: (String.t(), [{String.t(), String.t()}] ->
                        [element_mutation()])

element_mutation()

@type element_mutation() ::
  :remove
  | :noop
  | {:set_attribute, String.t(), String.t()}
  | {:remove_attribute, String.t()}
  | {content_operation(), String.t()}

handler_id()

@type handler_id() :: non_neg_integer()

mutation()

@type mutation() ::
  :remove
  | {:set_attribute, %{name: String.t(), value: String.t()}}
  | {:remove_attribute, String.t()}
  | {content_operation(), String.t()}

reply_mutation()

@type reply_mutation() ::
  :remove
  | {:set_attribute, String.t(), String.t()}
  | {:remove_attribute, String.t()}
  | {content_operation(), iodata()}

rule()

@type rule() :: %{selector: String.t(), mutation: mutation()}

t()

@opaque t()

text_handler()

@type text_handler() :: (String.t(), boolean() -> [text_mutation()])

text_mutation()

@type text_mutation() ::
  :remove
  | :noop
  | {:replace_html
     | :replace_text
     | :before_html
     | :before_text
     | :after_html
     | :after_text, String.t()}

Functions

after_html(plan, selector, content)

@spec after_html(t(), String.t(), iodata()) :: t()

Inserts content after matched elements. Inserts HTML verbatim; only pass trusted markup.

Content must be UTF-8 iodata, regardless of the document encoding.

after_text(plan, selector, content)

@spec after_text(t(), String.t(), iodata()) :: t()

Inserts content after matched elements. Escapes &, <, and > before insertion.

Content must be UTF-8 iodata, regardless of the document encoding.

append_html(plan, selector, content)

@spec append_html(t(), String.t(), iodata()) :: t()

Appends content inside matched elements. Inserts HTML verbatim; only pass trusted markup.

Content must be UTF-8 iodata, regardless of the document encoding.

append_text(plan, selector, content)

@spec append_text(t(), String.t(), iodata()) :: t()

Appends content inside matched elements. Escapes &, <, and > before insertion.

Content must be UTF-8 iodata, regardless of the document encoding.

before_html(plan, selector, content)

@spec before_html(t(), String.t(), iodata()) :: t()

Inserts content before matched elements. Inserts HTML verbatim; only pass trusted markup.

Content must be UTF-8 iodata, regardless of the document encoding.

before_text(plan, selector, content)

@spec before_text(t(), String.t(), iodata()) :: t()

Inserts content before matched elements. Escapes &, <, and > before insertion.

Content must be UTF-8 iodata, regardless of the document encoding.

cancel(session)

@spec cancel(pid()) :: :ok | {:error, term()}

Cancels a message-driven session and discards pending output.

demand(session)

@spec demand(pid()) :: :ok | {:error, term()}

Grants one output credit to a message-driven session.

finish(session)

@spec finish(pid()) :: {:ok, reference()} | {:error, term()}

Accepts EOF for a message-driven session; requires output credit.

new(opts \\ [])

@spec new(keyword()) :: t()

Creates an empty plan without allocating native resources.

Options are :encoding (default "utf-8") and :max_memory (default 1_048_576 bytes, for LOL HTML's internal buffers only). Unknown options and invalid option types raise ArgumentError; unsupported encodings and invalid CSS selectors return errors when the plan is executed.

on_element(plan, selector, handler)

@spec on_element(t(), String.t(), element_handler()) :: handler_id()

Registers a legacy element callback in the calling process.

Use the declarative builders for native execution. Callback registration and rewriting must happen in the same process, as in the original API.

on_text(plan, selector, handler)

@spec on_text(t(), String.t(), text_handler()) :: handler_id()

Registers a legacy text callback in the calling process.

prepend_html(plan, selector, content)

@spec prepend_html(t(), String.t(), iodata()) :: t()

Prepends content inside matched elements. Inserts HTML verbatim; only pass trusted markup.

Content must be UTF-8 iodata, regardless of the document encoding.

prepend_text(plan, selector, content)

@spec prepend_text(t(), String.t(), iodata()) :: t()

Prepends content inside matched elements. Escapes &, <, and > before insertion.

Content must be UTF-8 iodata, regardless of the document encoding.

remove(plan, selector)

@spec remove(t(), String.t()) :: t()

Removes matched elements, including their content.

remove_attribute(plan, selector, name)

@spec remove_attribute(t(), String.t(), String.t()) :: t()

Removes an attribute from matched elements. Missing attributes are ignored.

replace_html(plan, selector, content)

@spec replace_html(t(), String.t(), iodata()) :: t()

Replaces matched elements and their content. Inserts HTML verbatim; only pass trusted markup.

Content must be UTF-8 iodata, regardless of the document encoding.

replace_text(plan, selector, content)

@spec replace_text(t(), String.t(), iodata()) :: t()

Replaces matched elements and their content. Escapes &, <, and > before insertion.

Content must be UTF-8 iodata, regardless of the document encoding.

reply(session, request_ref, mutations)

@spec reply(pid(), reference(), [reply_mutation()]) :: :ok | {:error, term()}

Replies to a dynamic element request with a list of mutations.

rewrite(plan, html)

@spec rewrite(t(), iodata()) :: {:ok, binary()} | {:error, term()}

Rewrites a complete document in one native pass, returning a binary.

Declarative plans execute on a dirty CPU scheduler, without worker threads, per-element messages, or Elixir callbacks. Rules are decoded once per call.

set_attribute(plan, selector, name, value)

@spec set_attribute(t(), String.t(), String.t(), String.t()) :: t()

Sets an attribute on matched elements. Values are escaped by LOL HTML.

set_inner_html(plan, selector, content)

@spec set_inner_html(t(), String.t(), iodata()) :: t()

Replaces the content inside matched elements. Inserts HTML verbatim; only pass trusted markup.

Content must be UTF-8 iodata, regardless of the document encoding.

set_inner_text(plan, selector, content)

@spec set_inner_text(t(), String.t(), iodata()) :: t()

Replaces the content inside matched elements. Escapes &, <, and > before insertion.

Content must be UTF-8 iodata, regardless of the document encoding.

start_link(plan, opts)

@spec start_link(
  t(),
  keyword()
) :: GenServer.on_start()

Starts an optional message-driven rewrite session linked to the caller.

Requires selector: "...". The owner defaults to the caller. See Laughter.Rewriter.Session for the demand/reply protocol, limits, and supervision.

stream(chunks, plan, opts \\ [])

@spec stream(Enumerable.t(), t(), keyword()) :: Enumerable.t()

Lazily rewrites an enumerable of binary or iodata chunks into binary chunks.

File.stream!("input.html", [], 65_536)
|> Laughter.Rewriter.stream(plan)
|> Stream.into(File.stream!("output.html"))
|> Stream.run()

Each enumeration opens a fresh native session and decodes the plan once. Output is emitted as it becomes available; chunk boundaries need not match the input and may split encoded characters. EOF flushes any pending bytes. Early halt or an exception closes the session without flushing. Legacy callbacks are not supported.

Options and bounds

  • :chunk_size — maximum bytes per native write (default 65_536). Larger source chunks are split lazily.
  • :max_output_bytes — maximum buffered output from one native write or EOF flush (default 1_048_576). Expansion beyond this limit fails the session instead of accumulating unbounded output.

Limits must be positive integers. Together with the plan's :max_memory, these bound native parser/output buffering independently of document length. They do not bound the plan's size, upstream chunk allocations, or output retained by the consumer. Each iodata source chunk is converted to a binary before splitting; use bounded upstream chunks for bounded end-to-end memory.

Native errors raise Laughter.Rewriter.Error during enumeration. Previously emitted output cannot be rolled back. Exceptions from the source or consumer propagate unchanged, with session cleanup still performed.

write(session, chunk)

@spec write(pid(), iodata()) :: {:ok, reference()} | {:error, term()}

Accepts one bounded input chunk without waiting for handler replies.