bugfix20250107.1
全站通知:

模块:ACandy

来自卡拉彼丘WIKI_BWIKI_哔哩哔哩
跳到导航 跳到搜索

ACandy 是一个构建 HTML 的纯 Lua 模块。利用 Lua 的语法糖和元表,ACandy 提供了一个易用的 DSL 来从 Lua 构建 HTML。

瞄一瞄

local acandy = require 'acandy'
local a, some, Fragment = acandy.a, acandy.some, acandy.Fragment

local example = Fragment {
   a.h1['#top heading heading-1'] 'Hello!',
   a.div { class="container", style="margin: 0 auto;",
      a.p {
         'My name is ', a.dfn('ACandy'), ', a module for building HTML.',
         a.br,
         'Thank you for your visit.',
      },
      a.p 'visitors:',
      a.ul / some.li('Alice', 'Bob', 'Carol', '...'),
   },
}
print(example)

输出(经过格式化):

<h1 id="top" class="heading heading-1">Hello!</h1>
<div style="margin: 0 auto;" class="container">
   <p>
      My name is <dfn>ACandy</dfn>, a module for building HTML.<br>
      Thank you for your visit.
   </p>
   <p>visitors:</p>
   <ul>
      <li>Alice</li>
      <li>Bob</li>
      <li>Carol</li>
      <li>...</li>
   </ul>
</div>

这篇文档中,代表元素属性的字符串用双引号,其他字符串用单引号,这仅为我的个人习惯,你可以自行决定。

导入

local acandy = require('acandy')
local a = acandy.a

a 是所有元素的入口,这是因为:

  • a 是 ACandy 的首字母;
  • a 很短,打起来方便;
  • a.xxx 可以理解为英语的“一个 xxx”。

创建元素

local elem = a.p {
   class="my-paragraph", style="color: #114514;",
   'This sentence is inside a ', a.code('<p>'), ' element.',
}
print(elem)

在这段代码中,a.p是一个返回<p>元素的函数,参数为一个表,表的键值对和序列分别表示元素的属性和子结点,其他元素同理。若仅有一个子结点且不需要设置属性,可以直接将该结点作为函数参数,所以 a.code('...')a.code({ '...' }) 是等价的。

该代码的输出,格式化后(下同)如下。

<p class="my-paragraph" style="color: #114514;">
   This sentence is inside a <code>&lt;p&gt;</code> element.
</p>

a.xxxASCII 大小写不敏感的,因此 a.diva.Diva.DIV……是同一个值(即 rawequal(a.div, a.Div) == truerawequal(a.div, a.DIV) == true),它们都将变成<div></div>

属性

通过表的键值对为元素提供属性。值可以是以下内容:

  • nilfalse 表示没有此属性;
  • true 表示此为布尔值属性,例如,a.script { async=true } 表示 <script async></script>
  • 其余值,将会对其 tostring,并转义其中的 &<> 和 NBSP。

子结点

通过表的序列部分为元素提供子结点。除 nil 之外的值均可作为子结点。当序列化时,它们遵循以下规则。

默认情形

元素、字符串、数字、布尔值等后文没有提到的值均适用于以下规则。

在元素字符串化时,对这些值尝试 tostring,并转义 &<> 和 NBSP。如果不期望自动的转义,可以将内容放在 acandy.Raw 中。

在下面这个例子中,我们将三个元素(<p>)作为 <article> 的子结点,并分别将字符串、数字、布尔值作为 <p> 的元素。结果显而易见。

local elem = a.article {
   a.p 'Lorem ipsum...',  -- or `a.p { 'Lorem ipsum...' }`
   a.p(2),  -- or `a.p { 2 }`
   a.p(true),  -- or `a.p { true }`
}
print(elem)
<article>
   <p>Lorem ipsum...</p>
   <p>2</p>
   <p>true</p>
</article>

列表

在序列化时,如果一个结点是类列表的,ACandy 将递归序列化列表中的子结点。

顺便一提,由 acandy.Fragment 返回的表(如 Fragment { 1, 2, 3 })是类列表的,因为它们的元表的 '__acandy_list_like' 字段被设置为 true

特别地,如果一个表不被认为是类列表的,如 a.p { 1, 2, 3 } 返回的表,根据默认规则,它将直接通过 tostring 转换为字符串,所以确保它实现了 __tostring 元方法。

local list1 = { '3', '4' }
local list2 = { '2', list1 }
local elem = a.div { '1', list2 }
print(elem)
<p>1234</p>

函数

可以将函数作为子结点,这相当于调用函数,并将返回值作为子结点,唯一的区别在于函数将被推迟到 tostring 时调用。

local elem = a.ul {
   a.li 'item 1',
   a.li {
      function ()  -- returns string
         return 'item 2'
      end,
   },
   function ()  -- returns element
      return a.li 'item 3'
   end,
   function ()  -- returns list
      local list = {}
      for i = 4, 6 do
         list[#list+1] = a.li('item '..i)
      end
      return list
   end,
}
print(elem)
<ul>
   <li>item 1</li>
   <li>item 2</li>
   <li>item 3</li>
   <li>item 4</li>
   <li>item 5</li>
   <li>item 6</li>
</ul>

方括号语法(设置元素属性)

在方括号内放置字符串可以快速设置 idclass

local elem = a.div['#my-id my-class-1 my-class-2'] {
   a.p 'You know what it is.',
}
print(elem)

在方括号内放置类表值可以设置元素属性,属性不局限于 idclass。这让复用属性变得更方便。

local attr = {
   id="my-id",
   class="my-class-1 my-class-2",
}
local elem = a.div[attr] {
   a.p 'You know what it is.',
}
print(elem)

上面两段代码的输出均为:

<div id="my-id" class="my-class-1 my-class-2">
   <p>You know what it is.</p>
</div>

斜杠语法(面包屑)

elem1 / elem2 / ... / elemN / tail_value

相当于:

elem1(
   elem2(
      ...(
         elemN(tail_value)
      )
   )
)

有点像 CSS 的子组合器 >,只不过它用于创建元素而不是选择元素。

前提是 elem1elemN 不是空元素已构建元素

例子:

local link_item = a.li / a.a
local text = 'More coming soon...'
local elem = (
   a.header['site-header'] / a.nav / a.ul {
      link_item { href="/home", 'Home' },
      link_item { href="/posts", 'Posts' },
      link_item { href="/about", 'About' },
      a.li / text,
   }
)
print(elem)
<header class="site-header">
   <nav>
      <ul>
         <li><a href="/home">Home</a></li>
         <li><a href="/posts">Posts</a></li>
         <li><a href="/about">About</a></li>
         <li>More coming soon...</li>
      </ul>
   </nav>
</header>

acandy.some

local frag1 = some.xxx(arg1, arg2, ...)
local frag2 = some.xxx[attr](arg1, arg2, ...)

相当于:

local frag1 = Fragment {
   a.xxx(arg1),
   a.xxx(arg2),
   ...,
}
local frag2 = Fragment {
   a.xxx[attr](arg1),
   a.xxx[attr](arg2),
   ...,
}

例子:

local some = acandy.some
local items = a.ul {
   some.li['my-li']('item 1', 'item 2'),
   some.li('item 3', 'item 4'),
}
print(items)
<ul>
   <li class="my-li">item 1</li>
   <li class="my-li">item 2</li>
   <li>item 3</li>
   <li>item 4</li>
</ul>

元素实例

如果一个元素是 a.div(...)a.div[...](...) 这类进行函数调用得出的元素,则称它为“已构建元素”(暂定);已构建元素作为面包屑末端的元素时,该面包屑同样返回一个已构建元素;而 a.diva.div[...] 则不属于已构建元素。

对于一个已构建的元素 elem,它有如下属性:

  • elem.tag_name:元素的标签名,可以重新赋值。
  • elem.attributes:一个表,存储着元素的所有属性,对此表的更改会生效于元素本身;不可重新赋值。
  • elem.children:一个 Fragment,存储着元素的所有子结点,对此表的更改会生效于元素本身;不可重新赋值。
  • elem.some_attributesome_attribute 为字符串):相当于 elem.attributes.some_attribute
  • elem[n]n 为整数):相当于 elem.children[n]

例子:

local elem = a.ol { id="my-id",
   a.li 'item 1',
}

-- get
elem.tag_name  --> 'ol'

elem.children[1]  --> a.li 'item 1'
elem[1] == elem.children[1]  --> true

elem.attributes.id  --> 'my-id'
elem.id == elem.attributes.id  --> true

-- set
elem.tag_name = 'ul'

elem.children:insert(a.li 'item 2')
elem[3] = a.li 'item 3'

elem.attributes.id = 'new-id'
elem.style = 'color: blue;'

print(elem)
<ul id="new-id" style="color: blue;">
   <li>item 1</li>
   <li>item 2</li>
   <li>item 3</li>
</ul>

结点构造器

acandy.Fragment

Fragment 承载多个元素。Fragment 和普通表的仅有的区别就是:

  • 设置了 __tostring,可以得到 HTML 字符串;
  • 设置了 __index,可以以类似面向对象的形式调用 table.inserttable.removetable 库中所有以表为第一个参数的方法。

可以通过 Fragment()Fragment({}) 创建一个空的 Fragment。

当仅有一个元素时,Fragment(<child>)Fragment({ <child> }) 等价。

例子:

local Fragment = acandy.Fragment
local frag = Fragment {
   a.p 'First paragraph.',
   a.p 'Second paragraph.',
}
frag:insert(a.p('Third paragraph.'))
print(frag)
<p>First paragraph.</p>
<p>Second paragraph.</p>
<p>Third paragraph.</p>

acandy.Raw

Raw 用于使字符串在最终不被转义。它接收任意类型的值,并调用 tostring,存储于内部。

  • 设置了 __tostring,可以通过 tostring 得到对应字符串;
  • 设置了 __concat,可以通过 .. 连接两个由 Raw 得到的对象。

例子:

local Raw = acandy.Raw
local elem = a.ul {
   a.li 'foo <br> bar',
   a.li(Raw 'foo <br> bar'),
   a.li(Raw('foo <b')..Raw('r> bar')),
   a.li { Raw('foo <b'), Raw('r> bar') },
}
<ul>
   <li>foo &lt;br&gt; bar</li>
   <li>foo <br> bar</li>
   <li>foo <br> bar</li>
   <li>foo <br> bar</li>
</ul>

acandy.Comment

Comment 创建一个注释结点。

local elem = a.p {
   'Hello, ',
   acandy.Comment 'This is a comment.',
   'world!',
   acandy.Comment(),
}
print(elem)
<p>Hello, <!--This is a comment.-->world!<!----></p>

配置

ACandy 默认为 HTML 模式(目前只有 HTML 模式),并预定义了一些 HTML 空元素和原始文本元素(见 Module:ACandy/config)。

ACandy 不支持修改全局配置,要修改配置,请创建一个配置后的 ACandy 实例,其函数签名如下。

type Config = {
   void_elements: { [string]: true },
   raw_text_elements: { [string]: true },
}
function acandy.ACandy(output_type: 'html', modify_config?: (config: Config) -> ()): table

其中,output_type 参数目前只能传入 'html'modify_config 参数(可选)是一个函数,接收一个表作为参数,无返回值。传入该函数的表是新配置的基础,你可以在函数中修改这个值,例如:

local acandy = require('acandy').ACandy('html', function (config)
   -- add a void element
   config.void_elements['my-void-element'] = true
   -- remove `br` from void elements
   config.void_elements.br = nil
   -- add a raw text element
   config.raw_text_elements['my-raw-text-element'] = true
   -- remove `script` from raw text elements
   config.raw_text_elements.script = nil
end)
local a = acandy.a
print(
   acandy.Fragment {
      a['my-void-element'],
      a.br,
      a['my-raw-text-element'] '< > &',
      a.script 'let val = 2 > 1',
   }
)
<my-void-element>
<br></br>
<my-raw-text-element>< > &</my-raw-text-element>
<script>let val = 2 &gt; 1</script>

要想在整个项目使用这个配置,可以导出配置后的 ACandy 实例,然后在其他文件中导入这个实例。

-- my_acandy.lua
return require('acandy').ACandy('html', function (config)
   -- ...
end)

-- other files
local acandy = require('my_acandy')

概念

类表值

类表(table-like)值是指可以当作表来读取的值。当且仅当一个值 t 符合以下条件时,该值被认为是类表值:

  • 满足任意一条:
    • t 是一个表,且未设置元表。
    • t 的元表的 '__acandy_table_like' 字段为 true(可通过 getmetatable(t).__acandy_table_like = true 设置)。使用者需要确保 t 能够:
      • 通过 t[k] 读取内容;
      • 通过 #t 获取序列长度;
      • 通过 pairs(t)ipairs(t) 遍历键值。
      ACandy 仅检查元表 '__acandy_table_like' 字段,不会检查 t 是否满足上述条件。

类列表值

类列表(list-like)值是指可以当作序列来读取的值。当且仅当一个值 t 符合以下条件时,该值被认为是类列表值:

  • 满足任意一条:
    • t 是一个类表值
    • t 的元表的 '__acandy_list_like' 字段为 true(可通过 getmetatable(t).__acandy_list_like = true 设置)。使用者需要确保 t 能够:
      • 通过 t[k] 读取内容;
      • 通过 #t 获取序列长度;
      • 通过 ipairs(t) 遍历值。
      ACandy 仅检查元表 '__acandy_list_like' 字段,不会检查 t 是否满足上述条件。

--[[
# ACandy

A module for building HTML.
一个用于构建HTML的模块。

Version requirement: Lua 5.1 or higher

GitHub: https://github.com/AmeroHan/ACandy
MIT License
Copyright (c) 2023 - 2024 AmeroHan
]]

local acandy_submodules
acandy_submodules = setmetatable({
load_classes = function ()
local getmt = getmetatable
local setmt = setmetatable
local tostring = tostring

local classes = {}

---@class StringSymbol: Symbol
---@field getter fun(t: table): string
local SYM_STRING = {}

function SYM_STRING.getter(t)
	return t[SYM_STRING]
end

classes.SYM_STRING = SYM_STRING


---@type {[metatable]: true, register: fun(self: self, mt: metatable): metatable}
local node_mts = setmt({
	---Register a metatable as a node metatable.
	register = function (self, mt)
		self[mt] = true
		return mt
	end,
}, { __mode = 'k' })
---To judge when a table with `__tostring` is being serialized, whether or not
---escape the string. If the table is an acandy node, it should not be escaped,
---as the node itself will handle the escaping.
classes.node_mts = node_mts


---@class Comment

local Comment_mt = node_mts:register {
	__tostring = function (self)
		return '<!--'..(self[SYM_STRING] or '')..'-->'
	end,
}
---Comment.
---
---Defined at https://html.spec.whatwg.org/#comments
---@param content string?
---@return Comment
---@nodiscard
function classes.Comment(content)
	if content then
		-- the text must not start with the string ">" or "->",
		-- nor contain the strings "<!--", "-->", or "--!>",
		-- nor end with the string "<!-"
		local s = '--'..content..'-'
		if
			s:find('<!--', 1, true)
			or s:find('-->', 1, true)
			or content:find('--!>', 1, true)
		then
			error('invalid comment content: '..('%q'):format(content), 2)
		end
	end
	return setmt({ [SYM_STRING] = content }, Comment_mt)
end

---@class Doctype
-- Specs:
--   HTML:
--     definition: https://html.spec.whatwg.org/#the-doctype
--     serialization: https://html.spec.whatwg.org/#serialising-html-fragments
--   XML: https://www.w3.org/TR/xml/#sec-prolog-dtd
-- TODO: support any doctype

local Doctype_mt = node_mts:register {
	__tostring = function ()
		return '<!DOCTYPE html>'
	end,
}
classes.Doctype = {
	---HTML5 doctype shortcut.
	---@type Doctype
	HTML = setmetatable({}, Doctype_mt),
}


---@class Raw
---@operator concat(Raw): Raw

local Raw_mt  ---@type metatable
Raw_mt = node_mts:register {
	__tostring = SYM_STRING.getter,
	__concat = function (left, right)
		if getmt(left) ~= Raw_mt or getmt(right) ~= Raw_mt then
			error('Raw object can only be concatenated with another Raw object', 2)
		end
		return setmt({ [SYM_STRING] = left[SYM_STRING]..right[SYM_STRING] }, Raw_mt)
	end,
	__newindex = function ()
		error('Raw object is immutable', 2)
	end,
}

---Create a Raw object, which would not be encoded when converted to string.
---@param content any value to be converted to string by `tostring()`
---@return Raw
---@nodiscard
function classes.Raw(content)
	return setmt({ [SYM_STRING] = tostring(content) }, Raw_mt)
end

return classes
end,
load_utils = function ()
local utils = {}

local pairs = pairs
local ipairs = ipairs
local s_gsub = string.gsub


---Shallow copy a table's sequence part using `ipairs`.
---@generic T
---@param from T[]
---@param into T[]?
---@return T[]
function utils.copy_ipairs(from, into)
	into = into or {}
	for i, v in ipairs(from) do
		into[i] = v
	end
	return into
end

---Shallow copy a table using `pairs`.
---@param from table
---@param into table?
---@return table
function utils.copy_pairs(from, into)
	into = into or {}
	for k, v in pairs(from) do
		into[k] = v
	end
	return into
end

---Shallow copy a table without calling `__pairs` metamethod.
---@param from table
---@param into table?
---@return table
function utils.raw_shallow_copy(from, into)
	into = into or {}
	for k, v in next, from do
		into[k] = v
	end
	return into
end

---Apply `func` to each element of `...` and return a table.
---@generic TIn
---@generic TOut
---@param func fun(arg: TIn): TOut
---@vararg TIn
---@return TOut[]
function utils.map_varargs(func, ...)
	local n = select('#', ...)
	local t = { ... }
	for i = 1, n do
		t[i] = func(t[i])
	end
	return t
end

---@generic T
---@param list T[]
---@return {[T]: true}
function utils.list_to_bool_dict(list)
	local dict = {}
	for _, v in ipairs(list) do
		dict[v] = true
	end
	return dict
end

local NBSP = '\194\160'
local ENTITY_ENCODE_MAP = {
	['&'] = '&amp;',
	[NBSP] = '&nbsp;',
	['"'] = '&quot;',
	['<'] = '&lt;',
	['>'] = '&gt;',
}

---Replace `&`, NBSP, `"`, `<`, `>` with entities.
---@param str string | number
---@return string
function utils.attr_encode(str)
	return (s_gsub(str, '\194?[\160&"<>]', ENTITY_ENCODE_MAP))
end

---Replace `&`, NBSP, `<`, `>` with entities.
---@param str string | number
---@return string
function utils.html_encode(str)
	return (s_gsub(str, '\194?[\160&<>]', ENTITY_ENCODE_MAP))
end

local NON_CUSTOM_NAMES = {
	['annotation-xml'] = true,
	['color-profile'] = true,
	['font-face'] = true,
	['font-face-src'] = true,
	['font-face-uri'] = true,
	['font-face-format'] = true,
	['font-face-name'] = true,
	['missing-glyph'] = true,
}
---defined at https://html.spec.whatwg.org/#prod-pcenchar
local PCEN_CHAR_RANGES = {
	{ 0x2D,    0x2E },  -- '-', '.'
	{ 0x30,    0x39 },  -- 0-9
	{ 0x5F,    0x5F },  -- '_'
	{ 0x61,    0x7A },  -- a-z
	{ 0xB7,    0xB7 },
	{ 0xC0,    0xD6 },
	{ 0xD8,    0xF6 },
	{ 0xF8,    0x37D },
	{ 0x37F,   0x1FFF },
	{ 0x200C,  0x200D },
	{ 0x203F,  0x2040 },
	{ 0x2070,  0x218F },
	{ 0x2C00,  0x2FEF },
	{ 0x3001,  0xD7FF },
	{ 0xF900,  0xFDCF },
	{ 0xFDF0,  0xFFFD },
	{ 0x10000, 0xEFFFF },
}
---@param code_point integer
---@return boolean
local function is_pcen_char_code(code_point)
	for _, range in ipairs(PCEN_CHAR_RANGES) do
		if code_point >= range[1] and code_point <= range[2] then
			return true
		end
	end
	return false
end

---Return truthy value when `name` is a valid HTML tag name, otherwise falsy value.
---
---Defined at:
---- https://html.spec.whatwg.org/#syntax-tag-name
---- https://html.spec.whatwg.org/#valid-custom-element-name
---@param name any
---@return any
function utils.is_html_tag_name(name)
	if type(name) ~= 'string' then
		return false
	elseif name:find('^%w+$') then
		return true
	elseif NON_CUSTOM_NAMES[name:lower()] then
		return true
	end
	---@cast name string
	local subs1, subs2 = name:match('^%l(.*)%-(.*)$')
	if not subs1 then
		return false
	end

	---@param s string
	---@return boolean
	local function validate(s)
		if s:find('^[%-%.%d_%l]*$') then
			return true
		end
		for cp in mw.ustring.gcodepoint(s) do
			if not is_pcen_char_code(cp) then
				return false
			end
		end
		return true
	end

	return validate(subs1) and validate(subs2)
end

---Return truthy value when `name` is a valid HTML attribute name, otherwise falsy value.
---
---Defined at:
---- https://html.spec.whatwg.org/#syntax-attribute-name
---@param name any
---@return any
function utils.is_html_attr_name(name)
	if type(name) ~= 'string' then
		return false
	elseif name:find('[%z\1-\31\127 "\'>/=]') then
		return false
	end
	return true
end

---@param str string
---@return table
function utils.parse_shorthand_attrs(str)
	-- parse id
	local id = nil
	str = s_gsub(str, '#([^%s#]*)', function (s)
		if s == '' then
			error('empty id found in '..('%q'):format(str), 4)
		end
		if id then
			error('two or more ids found in '..('%q'):format(str), 4)
		end
		id = s
		return ''
	end)

	-- parse class
	local class = str:gsub('^%s*(.-)%s*$', '%1'):gsub('%s+', ' ')

	return {
		id = id,
		class = class ~= '' and class or nil,
	}
end

return utils
end,
load_config = function ()
-- ref: https://html.spec.whatwg.org/#elements-2

local DEFAULT_CONFIG = {
	html = {
		void_elements = {
			'area',
			'base', 'br',
			'col',
			'embed',
			'hr',
			'img',
			'input',
			'link',
			'meta',
			'param',
			'source',
			'track',
			'wbr',
		},
		raw_text_elements = {
			'script', 'style',
		},
	},
}

local utils = acandy_submodules.utils

local module = {}

---ACandy configuration.
---@class Config
---@field void_elements {[string]: boolean}
---@field raw_text_elements {[string]: boolean}

---@param output_type 'html'
---@param modify_config fun(config: Config)?
---@nodiscard
function module.parse_config(output_type, modify_config)
	local base_config = DEFAULT_CONFIG[output_type]
	assert(base_config, 'unsupported output type: '..output_type)

	local config = {
		void_elements = utils.list_to_bool_dict(base_config.void_elements),
		raw_text_elements = utils.list_to_bool_dict(base_config.raw_text_elements),
	}
	if modify_config then
		modify_config(config)
	end
	return config.void_elements, config.raw_text_elements
end

return module
end,
}, { __index = function(t, module_name)
	local loader_key = 'load_'..module_name
	local mod = t[loader_key]()
	t[module_name] = mod
	t[loader_key] = nil
	return mod
end
})

local type = type
local pairs = pairs
local getmt = getmetatable
local setmt = setmetatable
local assert = assert
local concat = table.concat
local ipairs = ipairs
local rawget = rawget
local rawset = rawset
local tostring = tostring

local utils = acandy_submodules.utils
local classes = acandy_submodules.classes
local node_mts = classes.node_mts
local config_module = acandy_submodules.config

---@class Symbol

local SYM_STRING = classes.SYM_STRING
local SYM_ATTR_MAP = {}  ---@type Symbol
local SYM_CHILDREN = {}  ---@type Symbol
local SYM_TAG_NAME = {}  ---@type Symbol

local KEY_LIST_LIKE = '__acandy_list_like'
local KEY_TABLE_LIKE = '__acandy_table_like'

---@param v any
---@return integer 1: list-like, 2: table-like, 0: others
---@nodiscard
local function container_level_of(v)
	local mt = getmt(v)
	if not mt then
		return type(v) == 'table' and 2 or 0
	elseif mt[KEY_TABLE_LIKE] == true then
		return 2
	elseif mt[KEY_LIST_LIKE] == true then
		return 1
	end
	return 0
end


---Append the serialized string of the Fragment to `buff`.
---Use len to avoid calling `#buff` repeatedly. This improves performance by
---~1/3.
---@param buff table
---@param frag table
---@param buff_len? integer length of `buff`, used to optimize performance.
---@param no_encode? boolean true to prevent encoding strings, e.g. when in <script>.
local function extend_str_buff_with_frag(buff, frag, buff_len, no_encode)
	if #frag == 0 then return end
	buff_len = buff_len or #buff

	local function append_serialized(node)
		local node_type = type(node)
		if container_level_of(node) >= 1 then  -- Fragment, list
			for _, child_node in ipairs(node) do
				append_serialized(child_node)
			end
		elseif node_type == 'function' then
			append_serialized(node())
		elseif node_type == 'string' then
			buff_len = buff_len + 1
			buff[buff_len] = no_encode and node or utils.html_encode(node)
		else  -- others: Raw, Element, boolean, number
			local str = tostring(node)
			if not (node_mts[getmt(node)] or no_encode) then
				str = utils.html_encode(str)
			end
			buff_len = buff_len + 1
			buff[buff_len] = str
		end
	end

	append_serialized(frag)
end

---@param buff string[]
---@param attr_map {[string]: string | number | boolean}
---@param buff_len integer?
---@return integer new_buff_len
local function extend_str_buff_with_attrs(buff, attr_map, buff_len)
	buff_len = buff_len or #buff
	for k, v in pairs(attr_map) do
		if v == true then
			-- boolean attributes
			-- https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#boolean_attributes
			buff_len = buff_len + 2
			buff[buff_len - 1] = ' '
			buff[buff_len] = k
		elseif v then  -- exclude the case `v == false`
			buff_len = buff_len + 5
			buff[buff_len - 4] = ' '
			buff[buff_len - 3] = k
			buff[buff_len - 2] = '="'
			buff[buff_len - 1] = utils.attr_encode(tostring(v))
			buff[buff_len] = '"'
		end
	end
	return buff_len
end


local Fragment_mt = node_mts:register {
	---Flat and concat the Fragment, returns string.
	---@param self Fragment
	---@return string
	__tostring = function (self)
		if #self == 0 then return '' end

		local buff = {}
		extend_str_buff_with_frag(buff, self, 0)
		return concat(buff)
	end,
	---ACandy fragment.
	---@class Fragment<T>: {[integer]: T}
	__index = {
		concat = table.concat,
		insert = table.insert,
		---@version <5.1
		---@diagnostic disable-next-line: deprecated
		maxn = table.maxn,  -- Lua 5.1 only
		move = table.move,
		remove = table.remove,
		sort = table.sort,
		unpack = table.unpack or unpack,  ---@diagnostic disable-line: deprecated
	},
	[KEY_LIST_LIKE] = true,
}

---Constructor of Fragment.
---@param children any?
---@return Fragment
---@nodiscard
local function Fragment(children)
	if container_level_of(children) >= 1 then
		return setmt(utils.copy_ipairs(children), Fragment_mt)
	end
	return setmt({ children }, Fragment_mt)
end

---@param breadcrumb Breadcrumb
---@return string[] tag_names
---@return ({[string]: string | number | boolean} | nil)[] attr_maps
---@nodiscard
local function clone_breadcrumb_tags_and_attrs(breadcrumb)
	local new_tag_names = {}
	local new_attr_maps = {}
	local orig_attr_maps = breadcrumb[SYM_ATTR_MAP]
	for i, tag_name in ipairs(breadcrumb[SYM_TAG_NAME]) do
		new_tag_names[i] = tag_name
		new_attr_maps[i] = orig_attr_maps[i]
	end
	return new_tag_names, new_attr_maps
end

---@param breadcrumb1 Breadcrumb
---@param breadcrumb2 Breadcrumb
---@return string[] tag_names
---@return ({[string]: string | number | boolean} | nil)[] attr_maps
---@nodiscard
local function connect_breadcrumbs(breadcrumb1, breadcrumb2)
	local new_tag_names, new_attr_maps = clone_breadcrumb_tags_and_attrs(breadcrumb1)
	local len = #new_tag_names
	local attr_maps2 = breadcrumb2[SYM_ATTR_MAP]
	for i, tag_name in ipairs(breadcrumb2[SYM_TAG_NAME]) do
		new_tag_names[len + i] = tag_name
		new_attr_maps[len + i] = attr_maps2[i]
	end
	return new_tag_names, new_attr_maps
end


---@param self Breadcrumb
---@return string
---@nodiscard
local function breadcrumb_to_string(self)
	local tag_names = self[SYM_TAG_NAME]
	local attr_maps = self[SYM_ATTR_MAP]
	local result = {}

	for i, tag_name in ipairs(tag_names) do
		result[#result+1] = '><'
		result[#result+1] = tag_name
		if attr_maps[i] then
			extend_str_buff_with_attrs(result, attr_maps[i])
		end
	end
	result[1] = '<'

	for i = #tag_names, 1, -1 do
		result[#result+1] = '></'
		result[#result+1] = tag_names[i]
	end
	result[#result+1] = '>'

	return concat(result)
end


local function ErrorEmitter(msg, level)
	return function ()
		error(msg, level)
	end
end

local error_emitters = {
	unbuilt_elem_index = ErrorEmitter('attempt to access properties of a unbuilt element', 2),
	unbuilt_elem_newindex = ErrorEmitter('attempt to assign properties of a unbuilt element', 2),
	built_elem_div = ErrorEmitter('attempt to perform division on a built element', 2),
}

---@param output_type 'html'
---@param modify_config fun(config: Config)?
---@nodiscard
local function ACandy(output_type, modify_config)
	local void_elems, raw_text_elems = config_module.parse_config(output_type, modify_config)

	---A BareElement is an Element without any properties except tag name, e.g.,
	---`acandy.div`. It is immutable and can be cached and reused.
	---Indexing a BareElement would return a BuildingElement, and calling it would
	---return a BuiltElement. Both methods would not change the element itself.
	---
	---Example:
	---```lua
	---local bare_div = a.div
	---```
	---@class BareElement
	---@operator div(BareElement | BuildingElement | Breadcrumb): Breadcrumb
	---@operator div(any): BuiltElement
	---@overload fun(props: any): BuiltElement
	---@field [string | {[string]: string | number | boolean}] BuildingElement

	---A BuildingElement is an Element derived from attribute shorthand syntax. The
	---shorthand is a string of id and space-separated class names, and the syntax
	---is to index the BareElement with a shorthand string, i.e. to put it inside
	---the brackets followed after the tag name, e.g. `acandy.div['#id cls1 cls2']`.
	---
	---Example:
	---```lua
	---local building_div = a.div['#id cls1 cls2']
	---```
	---
	---Similar to BareElements, a BuildingElement can be called to get a
	---BuiltElement with properties set.
	---@class BuildingElement
	---@operator div(BareElement | BuildingElement | Breadcrumb): Breadcrumb
	---@operator div(any): BuiltElement
	---@overload fun(props: any): BuiltElement

	---A BuiltElement is an Element derived from a BareElement or a BuildingElement
	---by calling it, which would return the BuiltElement with properties set.
	---
	---```lua
	---local built_pre1 = a.pre {
	---	class = 'lang-lua';
	---	"print('Hello, ACandy!')",
	---}
	---
	---local built_pre2 = a.pre['lang-lua'] "print('Hello, ACandy!')"
	---```
	---
	---Although named "Built", it is still mutable. Its properties can be changed by
	---assigning.
	---@class BuiltElement
	---@field tag_name string
	---@field attributes {[string]: string | number | boolean}
	---@field children Fragment?
	---@field [string] string | number | boolean attribute value
	---@field [number] any child node

	---@class Breadcrumb
	---@operator div(BareElement | BuildingElement | Breadcrumb): Breadcrumb
	---@operator div(any): BuiltElement
	---@overload fun(props: any): BuiltElement


	local BareElement_mt  ---@type metatable
	local BuiltElement_mt  ---@type metatable
	local BuildingElement_mt  ---@type metatable


	---@param tag_name string
	---@return BareElement
	---@nodiscard
	local function BareElement(tag_name)
		local str
		if void_elems[tag_name] then
			str = '<'..tag_name..'>'
		else
			str = '<'..tag_name..'></'..tag_name..'>'
		end
		local elem = {
			[SYM_TAG_NAME] = tag_name,  ---@type string
			[SYM_STRING] = str,  ---@type string
		}
		return setmt(elem, BareElement_mt)
	end


	---@param tag_name string
	---@param attr_map {[string]: string | number | boolean}
	---@return BuildingElement
	---@nodiscard
	local function BuildingElement(tag_name, attr_map)
		local elem = {
			[SYM_TAG_NAME] = tag_name,
			[SYM_ATTR_MAP] = attr_map,
			[SYM_CHILDREN] = not void_elems[tag_name] and {} or nil,
		}
		return setmt(elem, BuildingElement_mt)
	end


	---@param tag_name string
	---@param attr_map {[string]: string | number | boolean}
	---@param children? any[]
	---@return BuiltElement
	---@nodiscard
	local function BuiltElement(tag_name, attr_map, children)
		assert(not (void_elems[tag_name] and children), 'void elements cannot have children')
		assert(void_elems[tag_name] or type(children) == 'table', 'non-void elements must have children')
		local elem = {
			[SYM_TAG_NAME] = tag_name,
			[SYM_ATTR_MAP] = attr_map,
			[SYM_CHILDREN] = children,
		}
		return setmt(elem, BuiltElement_mt)
	end


	---Convert the object into HTML code.
	---@param self BuildingElement | BuiltElement
	---@return string
	---@nodiscard
	local function elem_to_string(self)
		local tag_name = self[SYM_TAG_NAME]

		-- format open tag
		local result = { '<', tag_name }
		extend_str_buff_with_attrs(result, self[SYM_ATTR_MAP])
		result[#result+1] = '>'

		-- return without children or close tag when being a void element
		-- void element: https://developer.mozilla.org/en-US/docs/Glossary/Void_element
		if void_elems[tag_name] then
			return concat(result)
		end

		-- format children
		extend_str_buff_with_frag(result, self[SYM_CHILDREN], nil, raw_text_elems[tag_name])
		-- format close tag
		result[#result+1] = '</'
		result[#result+1] = tag_name
		result[#result+1] = '>'

		return concat(result)
	end


	---Return tag name, attribute or child node depending on the key.
	---@param self BuiltElement
	---@param key string | number
	---@nodiscard
	local function get_elem_prop(self, key)
		if key == 'tag_name' then
			return self[SYM_TAG_NAME]
		elseif key == 'children' then
			local children = rawget(self, SYM_CHILDREN)
			return children and setmt(children, Fragment_mt)
		elseif key == 'attributes' then
			return self[SYM_ATTR_MAP]
		elseif type(key) == 'string' then
			-- e.g. `elem.class`
			return self[SYM_ATTR_MAP][key]
		elseif type(key) == 'number' then
			-- e.g. `elem[1]`
			local children = rawget(self, SYM_CHILDREN)
			return children and children[key]  -- no error for ipairs
		end

		error("element property key's type is neither 'string' nor 'number'", 2)
	end


	---@param self BareElement | BuildingElement | BuiltElement
	---@param props any
	---@return BuiltElement
	---@nodiscard
	local function new_built_elem_from_props(self, props)
		local tag_name = self[SYM_TAG_NAME]  ---@type any
		local base_attr_map = rawget(self, SYM_ATTR_MAP)
		local new_attr_map = base_attr_map and utils.copy_pairs(base_attr_map) or {}
		local container_level = container_level_of(props)

		if void_elems[tag_name] then  -- void element, e.g. <br>, <img>
			if container_level == 2 then
				-- set attributes
				for k, v in pairs(props) do
					if type(k) == 'string' then
						if not utils.is_html_attr_name(k) then
							error('invalid attribute name: '..k, 2)
						end
						new_attr_map[k] = v
					end
				end
			end
			return BuiltElement(tag_name, new_attr_map)
		end

		local new_children = {}
		if container_level == 2 then
			for k, v in pairs(props) do
				local t = type(k)
				if t == 'number' then
					new_children[k] = v
				elseif t == 'string' then
					if not utils.is_html_attr_name(k) then
						error('invalid attribute name: '..k, 2)
					end
					new_attr_map[k] = v
				end
			end
		elseif container_level == 1 then
			utils.copy_ipairs(props, new_children)
		else  -- treat as a single child
			new_children[1] = props
		end

		return BuiltElement(tag_name, new_attr_map, new_children)
	end


	---Assign to tag name, attribute or child node depending on the key.
	---@param self BuildingElement | BuiltElement
	---@param key string | number
	---@param val any
	local function set_elem_prop(self, key, val)
		if key == 'tag_name' then
			-- e.g., elem.tag_name = 'div'
			if not utils.is_html_tag_name(val) then
				error('invalid tag name: '..val, 2)
			end  ---@cast val string

			val = val:lower()

			if self[SYM_TAG_NAME] == val then return end

			self[SYM_TAG_NAME] = val
			-- create/delete children table based on whether the element is a void element
			if void_elems[val] then
				rawset(self, SYM_CHILDREN, nil)
			elseif not rawget(self, SYM_CHILDREN) then
				rawset(self, SYM_CHILDREN, {})
			end
		elseif key == 'children' or key == 'attributes' then
			error('attempt to replace the '..key..' table of the element')
		elseif type(key) == 'string' then
			-- e.g., elem.class = 'content'
			if not utils.is_html_attr_name(key) then
				error('invalid attribute name: '..key, 2)
			end
			self[SYM_ATTR_MAP][key] = val
		elseif type(key) == 'number' then
			-- e.g., elem[1] = 'some text'
			local children = rawget(self, SYM_CHILDREN)
			if not children then
				error('attempt to assign child on a void element', 2)
			end
			children[key] = val
		else
			error("element property key's type is neither 'string' nor 'number'", 2)
		end
	end


	local Breadcrumb_mt  ---@type metatable

	---@param tag_names string[]
	---@param attr_maps ({[string]: string | number | boolean} | nil)[]
	---@return Breadcrumb
	---@nodiscard
	local function Breadcrumb(tag_names, attr_maps)
		return setmt({
			[SYM_TAG_NAME] = tag_names,
			[SYM_ATTR_MAP] = attr_maps,
		}, Breadcrumb_mt)
	end

	---@param breadcrumb Breadcrumb
	---@return BuiltElement root_elem, BuiltElement leaf_elem
	---@nodiscard
	local function breadcrumb_to_built_elem(breadcrumb)
		local tag_names = breadcrumb[SYM_TAG_NAME]
		local attr_maps = breadcrumb[SYM_ATTR_MAP]
		local n = #tag_names
		local leaf_elem = BuiltElement(tag_names[n], attr_maps[n] or {}, {})
		local parent_elem = leaf_elem
		for i = n - 1, 1, -1 do
			parent_elem = BuiltElement(tag_names[i], attr_maps[i] or {}, { parent_elem })
		end
		return parent_elem, leaf_elem
	end


	---@param left Breadcrumb
	---@param right any
	---@return Breadcrumb | BuiltElement
	local function breadcrumb_div(left, right)
		local right_mt = getmt(right)

		if right_mt == BareElement_mt or right_mt == BuildingElement_mt then
			local right_tag_name = right[SYM_TAG_NAME]
			local right_attr_map = rawget(right, SYM_ATTR_MAP)

			if void_elems[right_tag_name] then
				local root_elem, leaf_elem = breadcrumb_to_built_elem(left)
				leaf_elem[SYM_CHILDREN][1] = BuiltElement(right_tag_name, right_attr_map or {})
				return root_elem
			end

			local new_tag_names, new_attr_maps = clone_breadcrumb_tags_and_attrs(left)
			local n = #new_tag_names + 1
			new_tag_names[n] = right_tag_name
			new_attr_maps[n] = right_attr_map
			return Breadcrumb(new_tag_names, new_attr_maps)
		elseif right_mt == Breadcrumb_mt then
			return Breadcrumb(connect_breadcrumbs(left, right))
		end

		local root_elem, leaf_elem = breadcrumb_to_built_elem(left)
		leaf_elem[SYM_CHILDREN][1] = right
		return root_elem
	end


	---@param left BareElement | BuildingElement | any
	---@param right any | BareElement | BuildingElement
	---@return Breadcrumb | BuiltElement
	---@nodiscard
	local function elem_div(left, right)
		local left_mt = getmt(left)
		if left_mt ~= BareElement_mt and left_mt ~= BuildingElement_mt then
			error('attempt to div a '..type(left)..' with an element', 2)
		end
		---@diagnostic disable-next-line: assign-type-mismatch
		local tag_name = left[SYM_TAG_NAME]  ---@type string
		if void_elems[tag_name] then
			error('attempt to perform division on a void element', 2)
		end
		return breadcrumb_div(Breadcrumb({ tag_name }, { rawget(left, SYM_ATTR_MAP) }), right)
	end

	BareElement_mt = node_mts:register {
		__tostring = SYM_STRING.getter,  --> string
		---Semantic sugar for setting attributes.
		---e.g. `local elem = acandy.div['#id cls1 cls2']`
		---@param self BareElement
		---@param attrs string | table
		---@return BuildingElement
		__index = function (self, attrs)
			local attr_map
			if type(attrs) == 'string' then
				attr_map = utils.parse_shorthand_attrs(attrs)
			elseif container_level_of(attrs) == 2 then
				attr_map = {}
				for k, v in pairs(attrs) do
					if type(k) == 'string' then
						if not utils.is_html_attr_name(k) then
							error('invalid attribute name: '..k, 2)
						end
						attr_map[k] = v
					end
				end
			else
				error('invalid attributes: '..tostring(attrs), 2)
			end
			---@diagnostic disable-next-line: param-type-mismatch
			return BuildingElement(self[SYM_TAG_NAME], attr_map)
		end,
		__call = new_built_elem_from_props,  --> BuiltElement
		__div = elem_div,  --> Breadcrumb | BuiltElement
		__newindex = error_emitters.unbuilt_elem_newindex,
	}
	BuildingElement_mt = node_mts:register {
		__tostring = elem_to_string,  --> string
		__call = new_built_elem_from_props,  --> BuiltElement
		__div = elem_div,  --> Breadcrumb | BuiltElement
		__index = error_emitters.unbuilt_elem_index,
		__newindex = error_emitters.unbuilt_elem_newindex,
	}
	BuiltElement_mt = node_mts:register {
		__tostring = elem_to_string,  --> string
		__index = get_elem_prop,
		__newindex = set_elem_prop,
		__div = error_emitters.built_elem_div,
	}
	Breadcrumb_mt = node_mts:register {
		__tostring = breadcrumb_to_string,  --> string
		__call = function (self, props)  --> BuiltElement
			local root_elem, leaf_elem = breadcrumb_to_built_elem(self)
			local new_leaf_elem = new_built_elem_from_props(leaf_elem, props)
			leaf_elem[SYM_ATTR_MAP] = new_leaf_elem[SYM_ATTR_MAP]
			leaf_elem[SYM_CHILDREN] = new_leaf_elem[SYM_CHILDREN]
			return root_elem
		end,
		__div = function (left, right)  --> Breadcrumb | BuiltElement
			if getmt(left) ~= Breadcrumb_mt then
				error('attempt to div a '..type(left)..' with an breadcrumb', 2)
			end
			return breadcrumb_div(left, right)
		end,
		__index = error_emitters.unbuilt_elem_index,
		__newindex = error_emitters.unbuilt_elem_newindex,
	}


	---@class ElementEntry
	---@field [string] BareElement
	local a = setmt({}, {
		---When indexing a uncached tag name, return a constructor of that element.
		---@param key string
		---@return BareElement
		__index = function (self, key)
			if not utils.is_html_tag_name(key) then
				error('invalid tag name: '..tostring(key), 2)
			end

			local lower_key = key:lower()
			local bare_elem
			if lower_key ~= key then
				bare_elem = rawget(self, lower_key)
				if not bare_elem then
					bare_elem = BareElement(lower_key)
					self[lower_key] = bare_elem
				end
			else
				bare_elem = BareElement(key)
			end
			self[key] = bare_elem
			return bare_elem
		end,
	})

	---@class SomeBareElements
	---@field [string] fun(props: any): BuiltElement[]
	---@overload fun(props: any): BuiltElement[]

	---@class SomeElementsEntry
	---@field [string] SomeBareElements
	local some = setmt({}, {
		__index = function (_, key)
			local bare_elem = a[key]
			local mt = {}

			function mt:__index(shorthand)
				local building_elem = bare_elem[shorthand]
				return function (...)
					---@diagnostic disable-next-line: param-type-mismatch
					return setmt(utils.map_varargs(building_elem, ...), Fragment_mt)
				end
			end

			function mt:__call(...)
				---@diagnostic disable-next-line: param-type-mismatch
				return setmt(utils.map_varargs(bare_elem, ...), Fragment_mt)
			end

			return setmt({}, mt)
		end,
	})

	---@class ACandy
	local acandy = {
		a = a,
		some = some,
		Comment = classes.Comment,
		Doctype = classes.Doctype,
		Fragment = Fragment,
		Raw = classes.Raw,
	}

	return acandy
end

local ACANDY_EXPORTED_NAMES = {
	a = true,
	some = true,
	-- classes
	Comment = true,
	Doctype = true,
	Fragment = true,
	Raw = true,
}

---@class ACandyModule: ACandy
local acandy_module = setmt({
	ACandy = ACandy,
}, {
	__index = function (self, k)
		if not ACANDY_EXPORTED_NAMES[k] then
			return nil
		end
		local default_acandy = ACandy('html')
		utils.copy_pairs(default_acandy, self)
		assert(
			default_acandy[k] ~= nil,
			('`acandy[%q]` should exist but not found, please contact the author'):format(k)
		)
		return default_acandy[k]
	end,
})
return acandy_module