Packages
nldoc_conversion_writer_html
1.2.21
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.44
1.2.43
1.2.42
1.2.41
1.2.40
1.2.39
1.2.38
1.2.37
1.2.36
1.2.35
1.2.34
1.2.33
1.2.32
1.2.31
1.2.30
1.2.29
1.2.28
1.2.27
1.2.26
1.2.25
1.2.24
1.2.23
1.2.22
1.2.21
1.2.20
1.2.19
1.2.18
1.2.17
1.2.16
1.2.15
1.2.14
1.2.13
1.2.12
1.2.11
1.2.10
1.2.9
1.2.8
1.2.7
1.2.6
1.2.5
1.2.4
1.2.3
1.2.2
1.2.1
1.2.0
1.1.0
1.1.0-alpha.2
1.1.0-alpha.1
1.0.7
1.0.6
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
Logic for writing NLdoc Spec to HTML.
Current section
Files
Jump to
Current section
Files
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