Current section

Files

Jump to
nldoc_conversion_writer_html lib nldoc conversion writer html.ex
Raw

lib/nldoc/conversion/writer/html.ex

defmodule NLdoc.Conversion.Writer.Html do
@moduledoc """
Writer for HTML, converting from Spec to HTML.
"""
alias NeoSaxy.SimpleForm
alias NLdoc.Conversion.Writer.Html.{Head, SimpleForm, State}
alias NLdoc.Spec.Preformatted
alias NLdoc.Spec.{
Asset,
BlockQuotation,
Content,
DefinitionDetails,
DefinitionList,
DefinitionTerm,
Document,
Footnote,
FootnoteReference,
Heading,
Image,
Link,
ListItem,
OrderedList,
Paragraph,
Table,
TableCell,
TableHeader,
TableRow,
Text,
UnorderedList
}
# This defines functions to be generated in the for-loops below.
#
# This is a tuple in format:
# 1. Struct from Spec.
# 2. HTML Tag that tis element will be wrapped in
# 3. Attributes to pass to &html_attributes/3
@element_mappings [
{BlockQuotation, "blockquote", [:cite]},
{DefinitionDetails, "dd", []},
{DefinitionList, "dl", []},
{DefinitionTerm, "dt", []},
{Document, "body", []},
{Footnote, "li", []},
{FootnoteReference, "a", []},
{Heading, "p", [:level]},
{Image, "img", []},
{Link, "a", [:uri, :purpose]},
{ListItem, "li", []},
{Paragraph, "p", []},
{Preformatted, "code", []},
{OrderedList, "ol", [:style_type, :start, :reversed]},
{Table, "table", []},
{TableCell, "td", [:rowspan, :colspan]},
{TableHeader, "th", [:rowspan, :colspan, :abbreviation, :scope]},
{TableRow, "tr", []},
{UnorderedList, "ul", [:style_type]}
]
@style_mappings [
bold: "strong",
italic: "em",
underline: "u",
strikethrough: "s",
code: "code",
superscript: "sup",
subscript: "sub",
mark: "mark"
]
@type html_tag_or_text() :: Floki.html_tag() | Floki.html_text()
@type acc() :: {[html_tag_or_text()], State.t()}
@type opt() :: {:pretty, boolean()}
@spec convert(NLdoc.Spec.Document.t() | [NLdoc.Spec.object()], [opt()]) :: String.t()
@doc """
Convert resources into raw HTML. Either pass a Document, or pass a List of resources.
## Examples
iex> [
...> %NLdoc.Spec.Heading{
...> level: 1,
...> children: [
...> %NLdoc.Spec.Text{text: "Hello "},
...> %NLdoc.Spec.Text{text: "World", styling: [:italic]}
...> ]
...> },
...> %NLdoc.Spec.Paragraph{
...> children: [
...> %NLdoc.Spec.Text{text: "Followed by a "},
...> %NLdoc.Spec.Text{text: "paragraph", styling: [:bold]},
...> %NLdoc.Spec.Text{text: "."}
...> ]
...> }
...> ]
...> |> NLdoc.Conversion.Writer.Html.convert()
"<h1>Hello <em>World</em></h1><p>Followed by a <strong>paragraph</strong>.</p>"
"""
def convert(resource, opts \\ [])
def convert(doc = %Document{}, opts),
do:
doc
|> add_start_state()
|> convert_resource()
|> elem(0)
|> SimpleForm.reverse()
|> Floki.raw_html(opts)
|> add_doctype()
def convert(resources, opts) when is_list(resources) do
{tags, _} = convert_resources({resources, {[], %State{}}})
tags
|> SimpleForm.reverse()
|> Floki.raw_html(opts)
end
@spec add_start_state(NLdoc.Spec.Document.t()) :: {NLdoc.Spec.Document.t(), acc()}
defp add_start_state(doc = %Document{footnotes: footnotes, assets: assets}),
do:
{doc,
{[],
%State{
assets: assets,
existing_footnote_ids: footnotes |> Enum.map(fn %Footnote{id: id} -> id end)
}}}
# Floki's Doctype support sucks, therefore doing string manipulation
@spec add_doctype(String.t()) :: String.t()
defp add_doctype(html),
do: "<!DOCTYPE html>\n" <> html
@spec convert_resources({[NLdoc.Spec.object()], acc()}) :: {[Floki.html_tag()], acc()}
defp convert_resources({resources, acc}) do
Enum.reduce(
resources,
{[], acc},
fn child, {html_elements, acc} ->
{html_element, acc} = convert_resource({child, acc})
if is_nil(html_element) do
{html_elements, acc}
else
{[html_element | html_elements], acc}
end
end
)
end
@spec convert_resource({NLdoc.Spec.object(), acc()}) :: {Floki.html_tag() | nil, acc()}
# The tag for Headings is determined by it's level
defp convert_resource({resource = %Heading{level: level, children: children}, acc})
when level <= 6 do
("h" <> Integer.to_string(level))
|> SimpleForm.tag()
|> tuple(acc)
|> put_children(children)
|> postprocess_element(resource)
end
for {struct_module, html_tag, attributes} <- @element_mappings do
if Map.has_key?(struct_module.__struct__(), :children) do
defp convert_resource({resource = %unquote(struct_module){children: children}, acc}),
do:
unquote(html_tag)
|> SimpleForm.tag()
|> SimpleForm.put_attributes(
unquote(html_tag)
|> html_attributes(resource, unquote(attributes), acc)
)
|> tuple(acc)
|> put_children(children)
|> postprocess_element(resource)
else
defp convert_resource({resource = %unquote(struct_module){}, acc}),
do:
unquote(html_tag)
|> SimpleForm.tag()
|> SimpleForm.put_attributes(
unquote(html_tag)
|> html_attributes(resource, unquote(attributes), acc)
)
|> tuple(acc)
|> postprocess_element(resource)
end
end
defp convert_resource({resource = %Text{text: text, styling: styling}, acc}),
do: text |> wrap_styling(styling) |> tuple(acc) |> postprocess_element(resource)
@spec postprocess_element(
{Floki.html_tag(), acc()},
NLdoc.Spec.object()
) :: {Floki.html_tag() | nil, acc()}
defp postprocess_element({node = {"body", _, _}, {elements, state}}, doc = %Document{}),
do:
"html"
|> SimpleForm.tag()
|> SimpleForm.put_children([
doc
|> footnote_tags(state)
|> Enum.reduce(
node,
fn child, body -> body |> SimpleForm.prepend_child(child) end
),
doc |> Head.element()
])
|> tuple({elements, state})
defp postprocess_element({{"a", attrs, []}, {elements, state}}, %FootnoteReference{
resource_id: resource_id
}) do
{num, state} = State.upsert_footnote_id(state, resource_id)
if is_nil(num) do
# Footnote referenced to does not exist.
{nil, {elements, state}}
else
"sup"
|> SimpleForm.tag()
|> SimpleForm.prepend_child(
"a"
|> SimpleForm.tag(attrs)
|> SimpleForm.prepend_child(num |> Integer.to_string())
)
|> tuple({elements, state})
end
end
defp postprocess_element({node = {"a", _, _}, acc}, %Link{
id: id,
text: text,
styling: styling
}),
do: {node, acc} |> put_children([%Text{id: id, text: text, styling: styling}])
defp postprocess_element({node = {"img", _, _}, acc}, %Image{caption: caption})
when caption != [] do
{html_caption, acc} = {SimpleForm.tag("figcaption"), acc} |> put_children(caption)
"figure"
|> SimpleForm.tag()
|> SimpleForm.prepend_child(node)
|> SimpleForm.prepend_child(html_caption)
|> tuple(acc)
end
defp postprocess_element({node = {"table", _, _}, acc}, %Table{caption: caption})
when caption != [] do
{html_caption, acc} = {SimpleForm.tag("caption"), acc} |> put_children(caption)
node
|> SimpleForm.append_child(html_caption)
|> tuple(acc)
end
defp postprocess_element({node = {"blockquote", _, _}, acc}, %BlockQuotation{caption: caption})
when caption != [] do
{html_footer, acc} = {SimpleForm.tag("footer"), acc} |> put_children(caption)
node
|> SimpleForm.prepend_child(html_footer)
|> tuple(acc)
end
defp postprocess_element({node = {"code", _, _}, acc}, %Preformatted{caption: []}),
do: "pre" |> SimpleForm.tag([], [node]) |> tuple(acc)
defp postprocess_element({node = {"code", _, _}, acc}, %Preformatted{caption: caption}) do
{html_caption, acc} = {SimpleForm.tag("figcaption"), acc} |> put_children(caption)
"figure"
|> SimpleForm.tag()
|> SimpleForm.prepend_child(SimpleForm.tag("pre", [], [node]))
|> SimpleForm.prepend_child(html_caption)
|> tuple(acc)
end
defp postprocess_element({node, acc}, _), do: {node, acc}
@spec put_children({Floki.html_tag(), acc()}, [NLdoc.Spec.object()]) ::
{Floki.html_tag(), acc()}
# Unwrap paragraph if consists of a single Paragraph.
defp put_children({node = {name, _attrs, _children}, acc}, [%Paragraph{children: children}])
when name in ["td", "th", "li", "caption", "figcaption", "footer"],
do: {node, acc} |> put_children(children)
defp put_children({node, acc}, children) do
{children_elements, acc} = children |> tuple(acc) |> convert_resources()
node
|> SimpleForm.put_children(children_elements)
|> tuple(acc)
end
@spec image_attributes_alt(alt_text :: String.t() | nil, decorative? :: boolean()) :: [
Floki.html_attribute()
]
defp image_attributes_alt(alt_text, decorative?)
defp image_attributes_alt(_alt_text, true),
# @see: https://www.w3.org/WAI/tutorials/images/decorative/
do: [{"alt", ""}]
defp image_attributes_alt(alt_text, false) do
if Content.discernible_text?(alt_text) do
[{"alt", alt_text}]
else
# The alt attribute is officially mandatory; it's meant to always be specified.
# See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/alt
[{"alt", ""}]
end
end
@spec html_attributes(
tag_name :: binary(),
resource :: NLdoc.Spec.object(),
keys :: [atom()],
acc :: acc()
) :: [Floki.html_attribute()]
@spec html_attributes(
tag_name :: binary(),
key :: atom(),
value :: any(),
acc :: acc()
) :: [Floki.html_attribute()]
defp html_attributes("img", resource = %Image{}, _keys, acc),
do:
html_attributes("img", :source, resource.source, acc) ++
image_attributes_alt(resource.alternative_text, resource.decorative)
defp html_attributes("img", :source, "#" <> source, {_, state}) do
asset = state |> State.find_asset(source)
if is_nil(asset) do
[]
else
[{"src", asset |> Asset.to_base64()}]
end
end
defp html_attributes("img", :source, source, _acc),
do: [{"src", source}]
defp html_attributes("li", %Footnote{id: id}, _keys, _acc),
do: [{"id", "fn-" <> id}]
defp html_attributes("a", %FootnoteReference{resource_id: resource_id}, _keys, _acc),
do: [{"href", "#fn-" <> resource_id}, {"role", "doc-noteref"}]
defp html_attributes(tag_name, resource, keys, acc)
when is_struct(resource) and is_list(keys),
do:
keys
|> Enum.flat_map(fn key ->
tag_name |> html_attributes(key, resource |> Map.get(key), acc)
end)
for html_tag <- ["ol", "ul"] do
# Don't output if equal to default. Save the planet by saving bytes.
defp html_attributes(unquote(html_tag), :start, 1, _acc), do: []
defp html_attributes(unquote(html_tag), :start, n, _acc),
do: [{"start", n |> Integer.to_string()}]
end
# Don't output if equal to default. Save the planet by saving bytes.
defp html_attributes("ol", :reversed, false, _acc), do: []
defp html_attributes("ol", :reversed, true, _acc), do: [{"reversed", ""}]
# Don't output if equal to default. Save the planet by saving bytes.
defp html_attributes("ul", :style_type, :disc, _acc), do: []
defp html_attributes("ul", :style_type, :circle, _acc), do: [{"class", "lst-circle"}]
defp html_attributes("ul", :style_type, :square, _acc), do: [{"class", "lst-square"}]
# Don't output if equal to default. Save the planet by saving bytes.
defp html_attributes("ol", :style_type, :decimal, _acc), do: []
# TODO: Recommended against using attribute, but is not deprecated. Research if we should use CSS.
defp html_attributes("ol", :style_type, :"lower-alpha", _acc), do: [{"type", "a"}]
defp html_attributes("ol", :style_type, :"upper-alpha", _acc), do: [{"type", "A"}]
defp html_attributes("ol", :style_type, :"lower-roman", _acc), do: [{"type", "i"}]
defp html_attributes("ol", :style_type, :"upper-roman", _acc), do: [{"type", "I"}]
defp html_attributes("p", :level, n, _acc),
do: [{"role", "heading"}, {"aria-level", n |> Integer.to_string()}]
for html_tag <- ["td", "th"] do
# @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/td#rowspan
# @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/th#rowspan
# @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLTableCellElement/rowSpan
# Don't output if equal to default or invalid. Save the planet by saving bytes.
defp html_attributes(unquote(html_tag), :rowspan, n, _acc)
when n <= 1,
do: []
# Values higher than 65534 are clipped to 65534.
defp html_attributes(unquote(html_tag), :rowspan, n, acc)
when n > 65_534,
do: html_attributes(unquote(html_tag), :rowspan, 65_534, acc)
# If its value is set to 0, the header cell will extends to the end of the table grouping
# section (<thead>, <tbody>, <tfoot>, even if implicitly defined), that the <th> or <td> belongs to.
defp html_attributes(unquote(html_tag), :rowspan, n, _acc) when n < 0,
do: []
defp html_attributes(unquote(html_tag), :rowspan, n, _acc),
do: [{"rowspan", n |> Integer.to_string()}]
# @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/td#colspan
# @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/th#colspan
# @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLTableCellElement/colSpan
# User agents dismiss values higher than 1000 as incorrect, setting to the default value (1).
# Don't output if equal to default or invalid. Save the planet by saving bytes.
defp html_attributes(unquote(html_tag), :colspan, n, _acc) when n <= 1 or n > 1000,
do: []
defp html_attributes(unquote(html_tag), :colspan, n, _acc),
do: [{"colspan", n |> Integer.to_string()}]
end
defp html_attributes("th", :scope, :column, _acc), do: [{"scope", "column"}]
defp html_attributes("th", :scope, :row, _acc), do: [{"scope", "row"}]
for {html_tag, html_attribute, key} <- [
{"th", "abbr", :abbreviation},
{"blockquote", "cite", :cite},
{"a", "aria-label", :purpose}
] do
defp html_attributes(unquote(html_tag), unquote(key), nil, _acc), do: []
defp html_attributes(unquote(html_tag), unquote(key), value, _acc) do
if Content.discernible_text?(value) do
[{unquote(html_attribute), value}]
else
[]
end
end
end
# Set referrerpolicy for privacy and security reasons.
# Keep target to default '_self' for accessibility reasons. Avoid confusion that
# may be caused by the appearance of new windows that were not requested by the user.
# Suddenly opening new windows can disorient users or be missed completely by some.
#
# @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#referrerpolicy
# @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target
# @see https://www.w3.org/TR/WCAG20-TECHS/H83.html
defp html_attributes("a", :uri, uri, _acc),
do: [{"href", uri}, {"referrerpolicy", "same-origin"}]
defp html_attributes(_, _, _, _), do: []
@spec footnote_tags(Document.t(), State.t()) :: [Floki.html_tag()]
defp footnote_tags(_doc, %State{ordered_footnote_ids: ordered_ids})
when map_size(ordered_ids) === 0,
do: []
defp footnote_tags(
%Document{footnotes: footnotes},
state = %State{ordered_footnote_ids: ordered_ids}
),
do:
"section"
|> SimpleForm.tag([{"class", "footnotes"}])
|> SimpleForm.prepend_child(
"ol"
|> SimpleForm.tag()
|> tuple({[], state})
|> put_children(
ordered_ids
|> Enum.sort_by(fn {_id, num} -> num end)
|> Enum.flat_map(fn {id, _num} -> footnotes |> find_footnotes(id) end)
)
|> elem(0)
)
|> List.wrap()
@spec find_footnotes(footnotes :: [Footnote.t()], id :: String.t()) :: [Footnote.t()]
defp find_footnotes(footnotes, id),
do: footnotes |> Enum.filter(fn %Footnote{id: footnote_id} -> footnote_id == id end)
@spec wrap_styling(html_tag_or_text(), [NLdoc.Spec.text_style()]) :: html_tag_or_text()
for {style, html_tag} <- @style_mappings do
defp wrap_styling(node, [unquote(style) | xs]),
do: {unquote(html_tag), [], [node]} |> wrap_styling(xs)
end
defp wrap_styling(node, []), do: node
defp tuple(a, b), do: {a, b}
end