Lumis.Formatter.HTML (Lumis v0.10.0)

Copy Markdown View Source

The HTML pieces the built-in formatters are assembled from.

A module implementing Lumis.Formatter gets an event stream and has to turn it into markup; these are the parts of that job worth not writing again.

Every function here calls into the same code the built-in :html_inline and :html_linked formatters use, so output built with them is styled by the same theme stylesheets and escapes the same characters.

Example

defmodule MyFormatter do
  @behaviour Lumis.Formatter

  alias Lumis.Formatter.HTML

  @impl true
  def render(source, events, options) do
    language = Keyword.fetch!(options, :language)
    attrs = HTML.span_attrs(theme: Keyword.get(options, :theme), language: language)

    body =
      Enum.map(events, fn
        {:start, %{scope: scope}} -> HTML.open_span(attrs, scope)
        :end -> "</span>"
        {:source, %{start: start, end: stop}} ->
          HTML.escape(binary_part(source, start, stop - start))

        # Lumis adds event kinds over time. Render the ones you know.
        _event -> []
      end)

    [
      HTML.open_pre_tag(theme: Keyword.get(options, :theme)),
      HTML.open_code_tag(language),
      body,
      HTML.closing_tags()
    ]
  end
end

Summary

Functions

Every highlight scope mapped to the CSS class :html_linked gives it.

The closing </code> tag.

The closing </pre> tag.

Both closing tags, in the order open_code_tag/1 and open_pre_tag/1 opened them.

Attributes for the <code> tag for a language.

Escapes &, <, >, " and '.

Escapes a value going inside a double-quoted attribute.

Escapes { and }, which a templating language such as HEEx would otherwise read.

The CSS class a highlighted line carries, or nil when the line is not highlighted.

Whether a line falls inside a list of line numbers and ranges.

Attributes for the <pre> tag used by a multi-theme block.

The opening <code> tag for a language.

The opening <pre> tag for a multi-theme block.

The opening <pre> tag, carrying a theme's own colors when one is given.

An opening <span> for a scope, with its attributes from a span_attrs/1 table.

An opening tag built from an attribute keyword list, with every value escaped.

Attributes for the <pre> tag used by inline and linked HTML.

The event stream rendered into HTML lines, one string per line.

A theme name with everything but letters, digits, - and _ replaced by -.

The CSS class for a scope, or "l-text" for one Lumis does not know.

Every scope's <span> attributes for one theme and language.

A <span> with a theme's colors written inline, with text escaped.

A scope's <span> attributes, read out of a span_attrs/1 table.

A <span> carrying a scope's CSS class, with text escaped.

The class attribute for a scope, for output styled by a theme stylesheet.

A <span> carrying every theme's style as CSS custom properties, with text escaped.

Every scope's <span> attributes for a set of themes, as CSS custom properties.

The CSS text-decoration value for a Lumis.Theme.TextDecoration.

Whether a name is one HTML can carry on an attribute.

Wraps one rendered line in the <span> the built-in HTML formatters emit.

Functions

classes()

@spec classes() :: %{required(String.t()) => String.t()}

Every highlight scope mapped to the CSS class :html_linked gives it.

Read once and keep it: scope_to_class/1 looks a scope up in this, and calling it per token is the whole reason the table exists.

close_code_tag()

@spec close_code_tag() :: String.t()

The closing </code> tag.

iex> Lumis.Formatter.HTML.close_code_tag()
"</code>"

close_pre_tag()

@spec close_pre_tag() :: String.t()

The closing </pre> tag.

iex> Lumis.Formatter.HTML.close_pre_tag()
"</pre>"

closing_tags()

@spec closing_tags() :: String.t()

Both closing tags, in the order open_code_tag/1 and open_pre_tag/1 opened them.

iex> Lumis.Formatter.HTML.closing_tags()
"</code></pre>"

code_attrs(language, attrs \\ [])

@spec code_attrs(String.t() | nil, Lumis.html_attrs()) :: Lumis.html_attrs()

Attributes for the <code> tag for a language.

attrs is merged after the language class and the translate and tabindex defaults. Classes are unioned; every other authored value wins.

escape(text)

@spec escape(String.t()) :: String.t()

Escapes &, <, >, " and '.

iex> Lumis.Formatter.HTML.escape(~s|a < b && c|)
"a &lt; b &amp;&amp; c"

escape_attr(value)

@spec escape_attr(String.t()) :: String.t()

Escapes a value going inside a double-quoted attribute.

The helpers here already escape every attribute they build; this is for a formatter that assembles its own tags. The escape set is escape/1's, which is safe for CSS in an attribute because the HTML parser decodes the entity before the CSS parser sees it.

iex> Lumis.Formatter.HTML.escape_attr(~s|x"><script>|)
"x&quot;&gt;&lt;script&gt;"

iex> Lumis.Formatter.HTML.escape_attr("font-family: 'Fira Code'")
"font-family: &#39;Fira Code&#39;"

escape_braces(text)

@spec escape_braces(String.t()) :: String.t()

Escapes { and }, which a templating language such as HEEx would otherwise read.

iex> Lumis.Formatter.HTML.escape_braces("%{a: 1}")
"%&lbrace;a: 1&rbrace;"

highlight_line_class(lines, line_number, options \\ [])

@spec highlight_line_class([pos_integer() | Range.t()], pos_integer(), keyword()) ::
  String.t() | nil

The CSS class a highlighted line carries, or nil when the line is not highlighted.

Reads lines the way line_is_highlighted/2 does, including range steps and direction.

Options

  • :class — the class to use, taking precedence over :default_class
  • :default_class — what to fall back to, e.g. "l-highlighted"

Example

iex> Lumis.Formatter.HTML.highlight_line_class([1, 3..5], 4, default_class: "l-highlighted")
"l-highlighted"

iex> Lumis.Formatter.HTML.highlight_line_class([1, 3..5], 2, default_class: "l-highlighted")
nil

line_is_highlighted(lines, line_number)

@spec line_is_highlighted([pos_integer() | Range.t()], pos_integer()) :: boolean()

Whether a line falls inside a list of line numbers and ranges.

Lines are 1-based, matching the data-line wrap_line/3 writes and the :highlight_lines option the built-in formatters take.

A range's step counts: 1..9//2 is five lines, not nine. Stepped ranges stay compact across the native boundary, regardless of their declared span.

iex> Lumis.Formatter.HTML.line_is_highlighted([1, 3..5], 4)
true

iex> Lumis.Formatter.HTML.line_is_highlighted([1, 3..5], 2)
false

iex> Lumis.Formatter.HTML.line_is_highlighted([1..9//2], 4)
false

multi_themes_pre_attrs(options \\ [])

@spec multi_themes_pre_attrs(keyword()) :: Lumis.html_attrs()

Attributes for the <pre> tag used by a multi-theme block.

Carries lumis, lumis-themes and one class per theme name, so a stylesheet can select the active theme, and the same normal colors span_multi_themes_attrs/1 writes per scope.

Options

  • :themes (required) — the same keyword list or map span_multi_themes_attrs/1 takes
  • :default_theme — the theme written inline; "light-dark()" writes both the light and dark themes into CSS light-dark() calls
  • :css_variable_prefix (default "--lumis") — the custom property prefix
  • :class — appended to the classes above
  • :attrs — additional attribute keyword list, merged after generated values

open_code_tag(language, attrs \\ [])

@spec open_code_tag(String.t() | nil, Lumis.html_attrs()) :: String.t()

The opening <code> tag for a language.

iex> Lumis.Formatter.HTML.open_code_tag("elixir")
~s|<code class="language-elixir" translate="no" tabindex="0">|

A formatter reads its language from the :language option render/3 receives, which Lumis has already resolved by detection when the caller named none.

An optional second argument supplies the attribute keyword list accepted by code_attrs/2.

open_multi_themes_pre_tag(options \\ [])

@spec open_multi_themes_pre_tag(keyword()) :: String.t()

The opening <pre> tag for a multi-theme block.

Accepts the same options as multi_themes_pre_attrs/1.

Example

iex> Lumis.Formatter.HTML.open_multi_themes_pre_tag(themes: [light: "github_light"], default_theme: "light")
~s|<pre class="lumis lumis-themes light" style="color:#1f2328; background-color:#ffffff;">|

open_pre_tag(options \\ [])

@spec open_pre_tag(keyword()) :: String.t()

The opening <pre> tag, carrying a theme's own colors when one is given.

Accepts the same options as pre_attrs/1.

Example

iex> Lumis.Formatter.HTML.open_pre_tag(class: "my-block")
~s|<pre class="lumis my-block">|

open_span(attrs, scope)

@spec open_span(%{required(String.t()) => String.t()}, String.t() | nil) :: String.t()

An opening <span> for a scope, with its attributes from a span_attrs/1 table.

A scope the theme does not style opens a bare <span>, so every <span> still pairs with the </span> an :end event writes.

open_tag(name, attrs \\ [])

@spec open_tag(String.t(), Lumis.html_attrs()) :: String.t()

An opening tag built from an attribute keyword list, with every value escaped.

This is what pre_attrs/1, multi_themes_pre_attrs/1 and code_attrs/2 are built for: merge their result with your own attributes, then render the whole thing here instead of assembling the string yourself.

iex> Lumis.Formatter.HTML.open_tag("pre", class: "lumis", hidden: true)
~s|<pre class="lumis" hidden>|

Raises ArgumentError for a name HTML cannot carry, per valid_attr_name?/1.

pre_attrs(options \\ [])

@spec pre_attrs(keyword()) :: Lumis.html_attrs()

Attributes for the <pre> tag used by inline and linked HTML.

Options

  • :theme (Lumis.Theme.t/0 or a theme name) — writes the theme's normal colors into a style attribute, the way :html_inline does
  • :class — appended to the lumis class every Lumis block carries
  • :attrs — additional attribute keyword list. Classes are unioned, styles are appended, and every other value wins.

render_lines_from_events(source, events, attrs)

@spec render_lines_from_events(String.t(), [Lumis.Formatter.event(term())], %{
  required(String.t()) => String.t()
}) :: [String.t()]

The event stream rendered into HTML lines, one string per line.

A <span> that crosses a newline is closed at the end of one line and reopened at the start of the next, so every line's tags nest on their own and can be wrapped with wrap_line/3 independently. That closing and reopening is the part worth not writing again.

Each returned line contains only its content, without LF or CRLF terminators. A final newline ends the last line rather than adding an empty one. Join wrap_line/3 results with "\n" to build an HTML block.

attrs maps a scope to the attributes its <span> carries, so the whole render costs one call rather than one per token: a span_attrs/1 or span_multi_themes_attrs/1 table, or for class-based output, Map.new(classes(), fn {scope, _} -> {scope, span_linked_attrs(scope)} end). A scope the table does not carry opens a bare <span>.

Event kinds this build does not render — annotations, and anything a newer Lumis adds — are skipped rather than raising.

Example

iex> events = [{:start, %{scope: "keyword", language: "elixir"}}, {:source, %{start: 0, end: 3}}, :end]
iex> Lumis.Formatter.HTML.render_lines_from_events("a\nb", events, %{"keyword" => ~s|class="l-keyword"|})
[~s|<span class="l-keyword">a</span>|, ~s|<span class="l-keyword">b</span>|]

sanitize_theme_name(name)

@spec sanitize_theme_name(String.t()) :: String.t()

A theme name with everything but letters, digits, - and _ replaced by -.

The form a theme name takes inside the CSS custom properties span_multi_themes_attrs/1 writes.

iex> Lumis.Formatter.HTML.sanitize_theme_name("Catppuccin Mocha")
"Catppuccin-Mocha"

scope_to_class(scope)

@spec scope_to_class(String.t() | nil) :: String.t()

The CSS class for a scope, or "l-text" for one Lumis does not know.

iex> Lumis.Formatter.HTML.scope_to_class("keyword.function")
"l-keyword-function"

iex> Lumis.Formatter.HTML.scope_to_class("not.a.scope")
"l-text"

span_attrs(options \\ [])

@spec span_attrs(keyword()) :: %{required(String.t()) => String.t()}

Every scope's <span> attributes for one theme and language.

The inline-style counterpart of classes/0, and the same advice applies: build it once per document and read it per token with open_span/2 or span_inline_attrs/2.

Options

  • :theme (Lumis.Theme.t/0 or a theme name) — resolves each scope to its style. Without one every scope's attributes are "", which is a <span> with nothing on it.
  • :language — the language whose specialized scopes to prefer, e.g. comment.elixir over comment. Defaults to "plaintext".
  • :italic (default false) — emit font-style: italic for a style that asks for it
  • :include_highlights (default false) — add data-highlight="<scope>"

span_inline(text, attrs, scope)

@spec span_inline(String.t(), %{required(String.t()) => String.t()}, String.t() | nil) ::
  String.t()

A <span> with a theme's colors written inline, with text escaped.

Convenient for a one-off; in a loop, hoist span_attrs/1 out and use open_span/2 so the table is not rebuilt per token.

span_inline_attrs(attrs, scope)

@spec span_inline_attrs(%{required(String.t()) => String.t()}, String.t() | nil) ::
  String.t()

A scope's <span> attributes, read out of a span_attrs/1 table.

Returns "" for a scope the theme styles in no way, which is a <span> with no attributes rather than one with an empty style.

span_linked(text, scope)

@spec span_linked(String.t(), String.t() | nil) :: String.t()

A <span> carrying a scope's CSS class, with text escaped.

iex> Lumis.Formatter.HTML.span_linked("defmodule", "keyword.function")
~s|<span class="l-keyword-function">defmodule</span>|

span_linked_attrs(scope)

@spec span_linked_attrs(String.t() | nil) :: String.t()

The class attribute for a scope, for output styled by a theme stylesheet.

iex> Lumis.Formatter.HTML.span_linked_attrs("keyword")
~s|class="l-keyword"|

span_multi_themes(text, attrs, scope)

@spec span_multi_themes(
  String.t(),
  %{required(String.t()) => String.t()},
  String.t() | nil
) ::
  String.t()

A <span> carrying every theme's style as CSS custom properties, with text escaped.

attrs is a span_multi_themes_attrs/1 table. The inline counterpart is span_inline/3, and the two differ only in which table they read.

span_multi_themes_attrs(options \\ [])

@spec span_multi_themes_attrs(keyword()) :: %{required(String.t()) => String.t()}

Every scope's <span> attributes for a set of themes, as CSS custom properties.

The multi-theme counterpart of span_attrs/1, and the same advice applies: build it once per document and read it per token with open_span/2. The result is a table open_span/2 and span_multi_themes/3 both read.

Each theme's style becomes --<prefix>-<theme> custom properties, so one document can be restyled by CSS alone. :default_theme names the one written inline; the rest stay variables.

Options

  • :themes (required) — the same [light: "github_light", dark: dark_theme] keyword list :html_multi_themes takes, or a map. The names become the CSS variable suffixes and the <pre> classes open_multi_themes_pre_tag/1 writes.
  • :default_theme — the theme written inline. "light-dark()" writes the colors of the themes named light and dark into CSS light-dark() calls; for font weight, font style and text decoration, a value the two share is an ordinary declaration, and a value they disagree on is one variable per theme with nothing inline, for a rule of your own to switch. Without one every theme is a variable and nothing is inline.
  • :css_variable_prefix (default "--lumis") — the custom property prefix
  • :language — the language whose specialized scopes to prefer, e.g. comment.elixir over comment. Defaults to "plaintext".
  • :italic (default false) — emit font-style: italic for a style that asks for it
  • :include_highlights (default false) — add data-highlight="<scope>"

style_to_css(style, options \\ [])

@spec style_to_css(Lumis.Theme.Style.t(), keyword()) :: String.t()

A Lumis.Theme.Style as inline CSS declarations.

The same declarations span_attrs/1 puts in a style attribute, for a formatter that styles something other than a <span>.

Options

  • :italic (default false) — emit font-style: italic for a style that asks for it
  • :separator (default " ") — what goes between declarations

Example

iex> Lumis.Formatter.HTML.style_to_css(%Lumis.Theme.Style{fg: "#ff79c6", bold: true})
"color: #ff79c6; font-weight: bold;"

text_decoration(text_decoration)

@spec text_decoration(Lumis.Theme.TextDecoration.t()) :: String.t()

The CSS text-decoration value for a Lumis.Theme.TextDecoration.

iex> Lumis.Formatter.HTML.text_decoration(%Lumis.Theme.TextDecoration{underline: :wavy, strikethrough: true})
"underline wavy line-through"

iex> Lumis.Formatter.HTML.text_decoration(%Lumis.Theme.TextDecoration{})
"none"

valid_attr_name?(name)

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

Whether a name is one HTML can carry on an attribute.

A name with a space, a quote, =, / or > in it cannot be escaped into safety, because a space alone splits it into two attributes and the second one can be an event handler. open_tag/2 rejects those rather than writing them.

iex> Lumis.Formatter.HTML.valid_attr_name?("data-copy")
true

iex> Lumis.Formatter.HTML.valid_attr_name?("x onclick=alert(1)")
false

wrap_line(line_number, content, options \\ [])

@spec wrap_line(pos_integer(), iodata(), keyword()) :: String.t()

Wraps one rendered line in the <span> the built-in HTML formatters emit.

Lines are 1-based, and data-line is what a "highlight these lines" feature and anchor links both key off.

content goes in verbatim. Pass content-only lines from render_lines_from_events/3, then join the wrapped lines with "\n".

Options

  • :class_suffix — appended to l-line verbatim, so pass a leading space, e.g. " l-highlighted"
  • :style — a style attribute for the line

Example

iex> Lumis.Formatter.HTML.wrap_line(2, "code")
~s|<span class="l-line" data-line="2">code</span>|