defmodule MDExCustomHeadingId do @moduledoc ~S""" MDEx plugin to support custom heading IDs using `{#id}` syntax. Transforms heading Markdown with the widely accepted `{#id}` syntax into a heading HTML with the custom ID, optionally prefixed by the `:header_ids` extension option (see `t:MDEx.Document.options/0`). Automatically enables unsafe HTML rendering (required for rendering HTML in a plugin). The `{#id}` marker must be at the end of the heading and preceded by at least one space. IDs may contain only letters, numbers, hyphens, and underscores (a subset of valid HTML fragment identifiers). Headings that do not contain an ID marker are returned unmodified. Custom ID markers can be escaped with `\\{#id}` (backslash before the opening brace) to prevent processing. > #### Element ID Uniqueness {: .info} > > HTML requires element IDs to be unique within a document. This plugin does not > validate uniqueness - it's the author's responsibility to ensure custom IDs don't > collide with each other or with auto-generated heading IDs from MDEx. ## Examples ```elixir iex> doc = MDEx.new(markdown: ~S"## Match {#custom-id}", plugins: [MDExCustomHeadingId]) iex> MDEx.to_html!(doc) ~S(

Match

) iex> MDEx.to_markdown!(doc) ~S(

Match

) iex> doc = MDEx.new(markdown: ~S"## Match {#custom}", plugins: [MDExCustomHeadingId], extension: [header_ids: "user-content-"]) iex> MDEx.to_html!(doc) ~S(

Match

) iex> doc = MDEx.new(markdown: ~S"## No Match{#custom-id}", plugins: [MDExCustomHeadingId]) iex> MDEx.to_html!(doc) "

No Match{#custom-id}

" iex> MDEx.to_markdown!(doc) ~S"## No Match{\#custom-id}" iex> doc = MDEx.new(markdown: ~S"## No Match `{#custom-id}`", plugins: [MDExCustomHeadingId]) iex> MDEx.to_html!(doc) "

No Match {#custom-id}

" iex> MDEx.to_markdown!(doc) "## No Match `{#custom-id}`" iex> doc = MDEx.new(markdown: ~S"## Escaped \\{#custom-id}", plugins: [MDExCustomHeadingId]) iex> MDEx.to_html!(doc) "

Escaped {#custom-id}

" iex> MDEx.to_markdown!(doc) ~S"## Escaped {\#custom-id}" ``` """ alias MDEx.Document @doc """ Attaches the custom heading ID plugin to an MDEx document. This is the standard entry point for MDEx plugins. It can be used either in the `plugins` option when creating a document, or by calling it directly on a document. ## Examples ```elixir MDEx.new(markdown: ~S"## Heading {#custom-id}") |> MDExCustomHeadingId.attach() |> MDEx.to_html!() ``` """ def attach(document, _options \\ []) do document |> Document.append_steps(custom_header_ids: &process_headers/1) |> Document.put_options(render: [unsafe: true]) end defp process_headers(document) do prefix = document |> Document.get_option(:extension, []) |> Keyword.get(:header_ids, "") Document.update_nodes(document, MDEx.Heading, &process_heading(&1, prefix)) end defp process_heading(%MDEx.Heading{nodes: nodes} = heading, prefix) do case classify_heading(nodes) do # Case 1: No match - do nothing :no_match -> heading # Case 2: Match - replace with HTML literal block {:match, id, nodes} -> render_heading(heading.level, prefix, id, nodes) # Case 3: Escaped - remove escape `\`s, return as Markdown heading {:escaped, nodes} -> %{heading | nodes: nodes} end end defp render_heading(level, prefix, id, nodes) do id = if prefix == "", do: id, else: "#{prefix}#{id}" html = Enum.join( [ ~s(), ~s(", render_nodes(nodes), ~s() ], "" ) %MDEx.HtmlBlock{literal: html} end @spec classify_heading([Document.md_node()]) :: {:escaped, [Document.md_node()]} | {:match, String.t(), [Document.md_node()]} | :no_match defp classify_heading(nodes) do case List.last(nodes) do %MDEx.Text{literal: literal} -> check_escaped(literal, nodes) _ -> :no_match end end defp check_escaped(literal, nodes) do case Regex.run(~r/^(.*?\s+)\\(\{\s*#[-\w]+\s*\})\s*$/, literal) do [_, text_part, braces] -> {:escaped, update_last_node(nodes, text_part <> braces)} nil -> check_match(literal, nodes) end end defp check_match(literal, nodes) do case Regex.run(~r/^(.*?)\s+\{\s*#([-\w]+)\s*\}\s*$/, literal) do [_, text_part, id] -> {:match, id, update_last_node(nodes, String.trim(text_part))} nil -> :no_match end end defp update_last_node(nodes, new_literal) do List.update_at(nodes, -1, fn _ -> %MDEx.Text{literal: new_literal} end) end defp render_nodes(nodes) do nodes |> Document.wrap() |> MDEx.to_html!() |> String.trim() end end