🌏 English | 中文
This work uses Semantic Versioning
ACandy is a pure Lua module for building HTML, which takes advantage of Lua’s syntactic sugar and metatable, giving an intuitive DSL to build HTML from Lua.
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)
Output (formatted):
<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>
In this documentation, strings related to attributes are enclosed in double quotation marks while others single. It's just my personal preference and you can decide for yourself.
local acandy = require('acandy')
local a = acandy.a
a
is the entry point for all elements, because:
a
is ACandy’s first letter;a
is short to type;a.xxx
can be understood as “a xxx” in English.
local elem = a.p {
class="my-paragraph", style="color: #114514;",
'This sentence is inside a ', a.code('<p>'), ' element.',
}
print(elem)
In this code, a.p
is a function that returns an element. It takes a table as its argument, in which key-value pairs and sequences represent attributes and children of the element respectively, and the same applies to other elements. If there is only one child and no attributes need to be set, the child can be passed directly as the argument of the function, so a.code('...')
is equivalent to a.code({ '...' })
.
The output of this code, formatted (the same below), is as follows.
<p class="my-paragraph" style="color: #114514;">
This sentence is inside a <code><p></code> element.
</p>
Tip
- You don’t need to handle HTML escaping in strings. If you don't want automatic escaping, you can put the content in
acandy.Raw
. - Child nodes do not have to be elements or strings—although only these two types are shown here, any value that can be
tostring
is capable of a child node.
For HTML elements, a.xxx
is case-insensitive, so a.div
, a.Div
, a.DIV
, etc., are the same value and will all become <div></div>
. For other elements, a.xxx
is case-sensitive.
Attributes are provided to elements through key-value pairs in the table. The attribute values can be:
nil
andfalse
indicate no such attribute;true
indicates a boolean attribute, e.g.,a.script { async=true }
means<script async></script>
;- for any other value, try
tostring
on it, then escape&
,<
,>
and NBSP.
Child nodes are provided to elements through the sequence part of the table. Any value other than nil
can be a child node. When serializing, they follow the following rules.
Elements, strings, numbers, booleans, and all other values not mentioned later are applicable to the following rules.
When serializing, tostring
will be tried on these values and then escape &
, <
, >
and NBSP. If you don't want automatic escaping, you can put the content in acandy.Raw
.
In the following example, we use three elements (<p>
) as child nodes of <article>
, and use strings, numbers, and booleans as elements of <p>
. It is trivial to guess the result.
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>
When serializing, if a node is list-like, ACandy will recursively serialize the child nodes inside it.
By the way, tables returned by acandy.Fragment
(e.g., Fragment { 1, 2, 3 }
) are list-like, as their metatable has the '__acandy_list_like'
field set to true
.
Particularly, if a node has a table type but not considered list-like (e.g., table returned by a.p { 1, 2, 3 }
), it will be directly converted to string according to the default rule, so make sure __tostring
metamethod is implemented.
local list1 = { '3', '4' }
local list2 = { '2', list1 }
local elem = a.div { '1', list2 }
print(elem)
<p>1234</p>
Functions can be used as child nodes, which is equivalent to calling the function and using the return value as a child node, with the only difference being that the function will be deferred until tostring
is called.
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>
Tip
Child nodes are processed recursively, so you can return functions within functions.
Placing a string in brackets can quickly set id
and class
.
local elem = a.div['#my-id my-class-1 my-class-2'] {
a.p 'You know what it is.',
}
print(elem)
Placing a table-like value in brackets can set element attributes, not limited to id
and class
. This makes reusing attributes more convenient.
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)
Both of the above code snippets output:
<div id="my-id" class="my-class-1 my-class-2">
<p>You know what it is.</p>
</div>
elem1 / elem2 / ... / elemN / tail_value
is equivalent to:
elem1(
elem2(
...(
elemN(tail_value)
)
)
)
Kind of like CSS’s childe combinator >
, except that it is used to compose elements rather than select elements.
The premise is that elem1
..elemN
are not void elements or constructed elements.
Example:
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>
Tip
breadcrumbs can be cached, just like link_item
in the above example.
local frag1 = some.xxx(arg1, arg2, ...)
local frag2 = some.xxx[attr](arg1, arg2, ...)
is equivalent to:
local frag1 = Fragment {
a.xxx(arg1),
a.xxx(arg2),
...,
}
local frag2 = Fragment {
a.xxx[attr](arg1),
a.xxx[attr](arg2),
...,
}
Example:
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>
If an element is obtained by calling functions like a.div(...)
, a.div[...](...)
, it is called (tentatively) a "constructed element"; when a constructed element is the end of a breadcrumb, the breadcrumb also returns a constructed element; while a.div
, a.div[...]
are not constructed elements.
A constructed element elem
has the following properties:
elem.tag_name
: the tag name of the element, reassignable.elem.attributes
: a table that stores all the attributes of the element, changes to this table will take effect on the element itself; cannot be reassigned.elem.children
: aFragment
that stores all the child nodes of the element, changes to this table will take effect on the element itself; cannot be reassigned.elem.some_attribute
(some_attribute
is a string): equivalent toelem.attributes.some_attribute
.elem[n]
(n
is an integer): equivalent toelem.children[n]
.
Example:
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>
Fragment
holds multiple elements. The only differences between Fragment
and a regular table are:
- It has
__tostring
set, so you can get the HTML string; - It has
__index
set, so you can call all methods in thetable
library which take a table as the first parameter (e.g.,table.insert
,table.remove
) in an object-oriented manner.
You can create an empty Fragment with Fragment()
or Fragment({})
.
When there is only one element, Fragment(<child>)
is equivalent to Fragment({ <child> })
.
Example:
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>
Raw
prevents strings from being escaped in the final output. It accepts any type of value, calls tostring
, and stores it internally.
- It has
__tostring
set, so you can get the corresponding string withtostring
; - It has
__concat
set, so you can concatenate two objects obtained byRaw
with..
.
Example:
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 <br> bar</li>
<li>foo <br> bar</li>
<li>foo <br> bar</li>
<li>foo <br> bar</li>
</ul>
Comment
creates a comment node.
local elem = a.p {
'Hello, ',
acandy.Comment 'This is a comment.',
'world!',
acandy.Comment(),
}
print(elem)
<p>Hello, <!--This is a comment.-->world!<!----></p>
Currently only the HTML5 doctype is supported. It is accessed by Doctype.HTML
.
tostring(acandy.Doctype.HTML) --> '<!DOCTYPE html>'
function acandy.extend_env(env: table) -> nil
Extend the environment in place with acandy.a
as __index
, e.g., _ENV
. This makes it possible to directly use the tag name rather than tediously type a.
, unless there is a naming conflict with local variables or global variables.
Warning
It is not recommended to use this method on the global environment, as it may cause hard-to-detect naming conflicts.
local acandy = require 'acandy'
local a = acandy.a
acandy.extend_env(_ENV)
print(
-- normally you can access an element without `a.`
div {
-- use `a.table` to avoid the naming conflict with Lua's `table` module (a global value)
a.table {
tr { td 'foo' },
},
-- or use a different case
TABLE {
tr { td 'bar' },
}
ul {
-- use `a.a` to avoid the naming conflict with `a` from `acandy` (a local value)
li / a.a { href="/home", 'Home' },
-- or use a different case
li / A { href="/about", 'About' },
}
}
)
<div>
<table>
<tr><td>foo</td></tr>
</table>
<table>
<tr><td>bar</td></tr>
</table>
<ul>
<li><a href="/home">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</div>
function acandy.to_extended_env(env: table) -> table
Similar to acandy.extend_env
, but returns a new table instead of modifying the original table.
-- on Lua 5.2+
local function get_article()
local _ENV = acandy.to_extended_env(_ENV)
return (
article {
header / h2 'Title',
main {
p 'Paragraph 1',
p 'Paragraph 2',
}
}
)
end
-- on Lua 5.1
local get_article = setfenv(function ()
return (
article {
header / h2 'Title',
main {
p 'Paragraph 1',
p 'Paragraph 2',
}
}
)
end, acandy.to_extended_env(_G))
print(get_article())
<article>
<header>
<h2>Title</h2>
</header>
<main>
<p>Paragraph 1</p>
<p>Paragraph 2</p>
</main>
</article>
Table-like values are values that can be read as tables. A value t
is considered a table-like value if and only if it satisfies the following conditions:
-
Any of the following:
-
t
is a table and has no metatable. -
The
'__acandy_table_like'
field oft
’s metatable istrue
(can be set bygetmetatable(t).__acandy_table_like = true
). The user needs to ensure thatt
can:- read content through
t[k]
; - get the sequence length through
#t
; - traverse keys and values through
pairs(t)
andipairs(t)
.
ACandy only checks the metatable’s
'__acandy_table_like'
field and does not check whethert
meets the above conditions. - read content through
-
List-like values are values that can be read as sequences. A value t
is considered a list-like value if and only if it satisfies the following conditions:
-
Any of the following:
-
t
is a table-like value. -
The
'__acandy_list_like'
field oft
’s metatable istrue
(can be set bygetmetatable(t).__acandy_list_like = true
). The user needs to ensure thatt
can:- read content through
t[k]
; - get the sequence length through
#t
; - traverse values through
ipairs(t)
.
ACandy only checks the metatable’s
'__acandy_list_like'
field and does not check whethert
meets the above conditions. - read content through
-
Contributions of any form are welcomed, including bug reports, feature suggestions, documentation improvement, code optimization and so on!