import gleam/list
import gleam/set.{type Set}
import gleam/string
import grapheme_util
import tagg_error.{type TaggError}
/// This represents the start/end of a custom tag. For example, this may be
/// used to identify where a opening tag or closing tag exists in
/// the HTML. At this stage, we're not intending to identify opening/closing tag
/// pairs or to parse the attributes inside the tags; we're just trying to
/// identify where the custom opening or closing tags are in the document.
pub type RawTag {
RawTag(
name: String,
start: Int,
end: Int,
is_closing_tag: Bool,
is_self_closing_tag: Bool,
attrs_text: String,
)
}
/// Once we've identified where the custom opening/closing tags exist in the
/// document, we'll separate the document into "sections"; a section will either
/// be a custom tag that we'll transform into HTML, or a fragment of text that
/// does not contain any custom tags. It's called a "raw" section because we've
/// only done minimal parsing so far, and are going to parse each "RawSection"
/// to create a "Section" with more specific attributes.
pub type RawSection {
RawTextSection(text: String, start: Int, end: Int)
RawTagSection(tag: RawTag)
}
/// Gets the attributes text inside an HTML tag, once the tag name has been
/// identified. For example, if we're parsing the following HTML:
/// ```html
///
/// ```
/// This function will return the following:
/// ```
/// items="people" item="person" index="i"
/// ```
fn get_attrs_text(s: String) -> #(String, String) {
grapheme_util.get_chars_matching_func(s, "", grapheme_util.is_not_gt)
}
/// Recursively parses a string to get a list of custom HTML tags (e.g. )
/// in the document.
fn get_tags_rec(
s: String,
i: Int,
custom_tag_names: Set(String),
acc: List(RawTag),
) -> Result(List(RawTag), TaggError) {
case s {
" rest -> get_tags_rec(rest, i + 2, custom_tag_names, acc)
"" <> rest -> {
// the start of the '<' grapheme
let start = i
let i = i + 2
let #(tag_name, rest, num_graphemes) =
grapheme_util.get_name_ignore_ws(rest)
let i = i + num_graphemes
case set.contains(custom_tag_names, tag_name) {
True -> {
// this is a closing tag for a custom tag, we need to parse it and
// store it
case rest {
">" <> rest ->
get_tags_rec(rest, i + 1, custom_tag_names, [
RawTag(tag_name, start, i, True, False, ""),
..acc
])
_ -> Error(tagg_error.TagNotClosedError("Closing tag not closed for tag: " <> tag_name))
}
}
False -> {
// this is not a custom tag, there's no reason to further parse it
get_tags_rec(rest, i, custom_tag_names, acc)
}
}
}
"<" <> rest -> {
// the start of the '<' grapheme
let start = i
let i = i + 1
let #(tag_name, rest, num_graphemes) =
grapheme_util.get_name_ignore_ws(rest)
let i = i + num_graphemes
case set.contains(custom_tag_names, tag_name) {
True -> {
// this is a custom tag, we need to parse it
let #(attrs_text, rest) = get_attrs_text(rest)
let i = i + string.length(attrs_text)
case rest {
">" <> rest -> {
case string.ends_with(attrs_text, "/") {
True ->
get_tags_rec(rest, i + 1, custom_tag_names, [
RawTag(
tag_name,
start,
i,
False,
True,
string.drop_right(attrs_text, 1),
),
..acc
])
False ->
get_tags_rec(rest, i + 1, custom_tag_names, [
RawTag(tag_name, start, i, False, False, attrs_text),
..acc
])
}
}
_ -> Error(tagg_error.TagNotClosedError("Closing tag not closed for tag: " <> tag_name))
}
}
False -> {
// this is not a custom tag, there's no reason to further parse it
get_tags_rec(rest, i, custom_tag_names, acc)
}
}
}
_ -> {
case string.pop_grapheme(s) {
Ok(#(_grapheme, rest)) ->
get_tags_rec(rest, i + 1, custom_tag_names, acc)
Error(_) -> Ok(acc)
}
}
}
}
/// Parses a string to get a list of custom HTML tags (e.g. ) in the
/// document.
fn get_tags(s: String, custom_tag_names: Set(String)) -> Result(List(RawTag), TaggError) {
case get_tags_rec(s, 0, custom_tag_names, []) {
Ok(tags) -> Ok(list.reverse(tags))
Error(err) -> Error(err)
}
}
// Note that "s" stores the _unprocessed_ strings (this string gets smaller as
// we process tags, in an attempt to be more efficient with our string
// operations). The "i", however, represents our current position with
// reference to the _original_ HTML string (this is because the 'start' and
// 'end' in the 'RawTag' type are relative to the original HTML string).
fn get_raw_sections_rec(
tags: List(RawTag),
s: String,
i: Int,
acc: List(RawSection),
) -> List(RawSection) {
case list.first(tags) {
Ok(tag) -> {
case i < tag.start {
True -> {
// we need to process a text section and a tag that follows
let text_len = tag.start - i
let tag_len = tag.end - tag.start + 1
get_raw_sections_rec(
list.drop(tags, 1),
string.drop_left(s, text_len + tag_len),
tag.end + 1,
[
RawTagSection(tag),
RawTextSection(string.slice(s, 0, text_len), i, tag.start - 1),
..acc
],
)
}
False ->
// we need to process only a tag
get_raw_sections_rec(
list.drop(tags, 1),
string.drop_left(s, tag.end - tag.start + 1),
tag.end + 1,
[RawTagSection(tag), ..acc],
)
}
}
Error(_) -> {
case string.is_empty(s) {
True -> acc
False -> [RawTextSection(s, i, i + string.length(s) - 1), ..acc]
}
}
}
}
/// Divide the HTML document into Text or Tag sections. Identifying each
/// "section" comes after we've identified where the custom tags exist in the
/// document.
///
/// A Text section is HTML that doesn't contain any custom tags (but may contain
/// variables, e.g. @person.first_name).
///
/// A Tag section is HTML inside a custom tag (e.g. inside a tag).
///
/// We do not parse every HTML tag inside the document for performance and
/// memory usage reasons.
pub fn get_raw_sections(
s: String,
custom_tag_names: Set(String),
) -> Result(List(RawSection), TaggError) {
case get_tags(s, custom_tag_names) {
Ok(tags) -> Ok(list.reverse(tags |> get_raw_sections_rec(s, 0, [])))
Error(err) -> Error(err)
}
}