%%%------------------------------------------------------------------- %%% @author Claude Code %%% @copyright (C) 2025, erlsci %%% @doc HTML Renderer for erlmd AST %%% %%% This module renders the erlmd Abstract Syntax Tree to HTML output. %%% It maintains compatibility with the original erlmd HTML output format. %%% %%% @end %%% Created : 2025 by Claude Code %%%------------------------------------------------------------------- -module(erlmd_html). -include("../include/types.hrl"). -export([ render/1, % render(#document{}) -> string() render_block/1, % render_block(block()) -> string() render_inline/1 % render_inline(inline()) -> string() ]). -define(NBSP, 160). -define(SPACE, 32). -define(LF, 10). %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%% %%% Public API %%% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %% @doc Render a document to HTML -spec render(#document{}) -> string(). render(#document{blocks = Blocks}) -> HTML = lists:map(fun render_block/1, Blocks), string:strip(lists:flatten(HTML), both, $\n). %% @doc Render a single block element -spec render_block(block()) -> string(). render_block(#paragraph{content = Content}) -> InlineHTML = lists:map(fun render_inline/1, Content), ContentStr = lists:flatten(InlineHTML), % Strip leading and trailing spaces (both sides) Stripped = string:strip(string:strip(ContentStr, left, $ ), right, $ ), "

" ++ Stripped ++ "

\n"; render_block(#header{level = Level, content = Content}) -> Tag = "h" ++ integer_to_list(Level), InlineHTML = lists:map(fun render_inline/1, Content), ContentStr = string:strip(lists:flatten(InlineHTML), right), "<" ++ Tag ++ ">" ++ ContentStr ++ "\n\n"; render_block(#blockquote{blocks = Blocks}) -> BlockHTML = lists:map(fun render_block/1, Blocks), "\n
\n " ++ lists:flatten(BlockHTML) ++ "
"; render_block(#code_block{content = Content}) -> Encoded = htmlencode(Content), "
" ++ Encoded ++ "\n
\n\n"; render_block(#horizontal_rule{}) -> "
"; render_block(#list{type = Type, items = Items, tight = Tight}) -> Tag = case Type of ul -> "ul"; ol -> "ol" end, ItemsHTML = lists:map(fun(Item) -> render_list_item(Item, Tight) end, Items), "<" ++ Tag ++ ">\n" ++ lists:flatten(ItemsHTML) ++ "\n"; render_block(#html_block{content = Content}) -> Content; render_block(#blank_line{}) -> "\n". %% @doc Render a single inline element -spec render_inline(inline()) -> string(). render_inline(#text{content = Content}) -> htmlencode_text(Content); render_inline(#emphasis{content = Content}) -> InlineHTML = lists:map(fun render_inline/1, Content), "" ++ lists:flatten(InlineHTML) ++ ""; render_inline(#strong{content = Content}) -> InlineHTML = lists:map(fun render_inline/1, Content), "" ++ lists:flatten(InlineHTML) ++ ""; render_inline(#code_span{content = Content, delimiter = Delim}) -> Encoded = htmlencode(Content), case Delim of single -> "" ++ Encoded ++ ""; double -> "
" ++ Encoded ++ "
" end; render_inline(#link{text = Text, url = Url, title = Title}) -> TextHTML = lists:map(fun render_inline/1, Text), TitleAttr = case Title of "" -> ""; _ -> " title=\"" ++ Title ++ "\"" end, "" ++ lists:flatten(TextHTML) ++ ""; render_inline(#image{alt_text = AltText, url = Url, title = Title}) -> AltHTML = lists:map(fun render_inline/1, AltText), "\"""; render_inline(#line_break{type = Type}) -> case Type of soft -> "\n"; hard -> "
\n" end; render_inline(#html_inline{content = Content}) -> Content. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%% %%% Internal Functions %%% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %% @doc Render a list item render_list_item(#list_item{content = Blocks, tight = ItemTight}, _ListTight) -> % Use the item's own tight flag, not the list's overall tight flag BlockHTML = case ItemTight of true -> % Tight items don't wrap content in

tags lists:map(fun strip_para/1, Blocks); false -> % Loose items keep

tags lists:map(fun render_block/1, Blocks) end, ContentStr = string:strip(lists:flatten(BlockHTML), right, $\n), "

  • " ++ ContentStr ++ "
  • \n". %% @doc Strip

    tags from paragraph for tight lists strip_para(#paragraph{content = Content}) -> InlineHTML = lists:map(fun render_inline/1, Content), Ret = lists:flatten(InlineHTML), string:strip(Ret, right, $\n) ++ "\n"; strip_para(Block) -> % Non-paragraph blocks get rendered normally render_block(Block). %% @doc Encode HTML special characters (for code blocks and code spans) htmlencode(List) -> htmlencode(List, []). htmlencode([], Acc) -> lists:flatten(lists:reverse(Acc)); htmlencode([$& | Rest], Acc) -> htmlencode(Rest, ["&" | Acc]); htmlencode([$< | Rest], Acc) -> htmlencode(Rest, ["<" | Acc]); htmlencode([$> | Rest], Acc) -> htmlencode(Rest, [">" | Acc]); htmlencode([?NBSP | Rest], Acc) -> htmlencode(Rest, [" " | Acc]); htmlencode([Else | Rest], Acc) -> htmlencode(Rest, [Else | Acc]). %% @doc Encode text content (handles special entities and characters) htmlencode_text(Text) when is_list(Text) -> htmlencode_text(Text, []); htmlencode_text(Text) -> Text. htmlencode_text([], Acc) -> lists:flatten(lists:reverse(Acc)); % Preserve HTML entities that are already encoded htmlencode_text([$&, $c, $o, $p, $y, $; | Rest], Acc) -> htmlencode_text(Rest, ["©" | Acc]); htmlencode_text([$&, $a, $m, $p, $; | Rest], Acc) -> htmlencode_text(Rest, ["&" | Acc]); htmlencode_text([$& | Rest], Acc) -> htmlencode_text(Rest, ["&" | Acc]); htmlencode_text([$< | Rest], Acc) -> htmlencode_text(Rest, ["<" | Acc]); htmlencode_text([?NBSP | Rest], Acc) -> htmlencode_text(Rest, [" " | Acc]); htmlencode_text([H | Rest], Acc) -> htmlencode_text(Rest, [H | Acc]).