1-- vim: ft=lua ts=2 sw=2
2
3-- Syntactic Sugar {{{
4local function rine(val) -- Return (val) If it's Not Empty (non-zero-length)
5 return (val and #val > 0) and val
6end
7local function rit(a) -- Return (a) If it's Table
8 return (type(a) == "table") and a
9end
10local noop = function() end
11local esc = function(s) return string.gsub(s, "([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%" .. "%1") end
12local str = tostring
13local char = string.char
14local opts = rit(htmlparser_opts) or
15 {} -- needed for silent/noerr/noout/nonl directives, also needed to be defined before `require` in such case
16local prn = opts.silent and noop or function(l, f, ...)
17 local fd = (l == "i") and "stdout" or "stderr"
18 local t = (" [%s] "):format(l:upper())
19 io[fd]
20 :write('[HTMLParser]' .. t .. f:format(...)
21 .. (opts.nonl or "\n")
22 )
23end
24local err = opts.noerr and noop or function(f, ...) prn("e", f, ...) end
25local out = opts.noout and noop or function(f, ...) prn("i", f, ...) end
26local line = debug and function(lvl) return debug.getinfo(lvl or 2).currentline end or noop
27local dbg = opts.debug and function(f, ...) prn("d", f:gsub("#LINE#", str(line(3))), ...) end or noop
28-- }}}
29-- Requires {{{
30local ElementNode = require "ElementNode"
31local voidelements = require "voidelements"
32--}}}
33HtmlParser = {}
34local function parse(text, limit) -- {{{
35 local opts = rine(opts) -- use top-level opts-table (the one, defined before requiring the module), if exists
36 or rit(htmlparser_opts) -- or defined after requiring (but before calling `parse`)
37 or {} -- fallback otherwise
38 opts.looplimit = opts.looplimit or htmlparser_looplimit
39
40 local text = str(text)
41 local limit = limit or opts.looplimit or 1000
42 local tpl = false
43
44 if not opts.keep_comments then -- Strip (or not) comments {{{
45 text = text:gsub("<!%-%-.-%-%->", "") -- Many chances commented code will have syntax errors, that'll lead to parser failures
46 end -- }}}
47
48 local tpr = {}
49
50 if not opts.keep_danger_placeholders then -- {{{ little speedup by cost of potential parsing breakages
51 -- search unused "invalid" bytes {{{
52 local busy, i = {}, 0;
53 repeat -- {{{
54 local cc = char(i)
55 if not (text:match(cc)) then -- {{{
56 if not (tpr["<"]) or not (tpr[">"]) then -- {{{
57 if not (busy[i]) then -- {{{
58 if not (tpr["<"]) then -- {{{
59 tpr["<"] = cc;
60 elseif not (tpr[">"]) then
61 tpr[">"] = cc;
62 end -- }}}
63 busy[i] = true
64 dbg("c:{%s}||cc:{%d}||tpr[c]:{%s}", str(c), cc:byte(), str(tpr[c]))
65 dbg("busy[i]:{%s},i:{%d}", str(busy[i]), i)
66 dbg("[FindPH]:#LINE# Success! || i=%d", i)
67 else -- if !busy
68 dbg("[FindPH]:#LINE# Busy! || i=%d", i)
69 end -- if !busy -- }}}
70 dbg("c:{%s}||cc:{%d}||tpr[c]:{%s}", c, cc:byte(), str(tpr[c]))
71 dbg("%s", str(busy[i]))
72 else -- if < or >
73 dbg("[FindPH]:#LINE# Done!", i)
74 break
75 end -- if < or > -- }}}
76 else -- text!match(cc)
77 dbg("[FindPH]:#LINE# Text contains this byte! || i=%d", i)
78 end -- text!match(cc) -- }}}
79 local skip = 1
80 if i == 31 then
81 skip = 96 -- ASCII
82 end
83 i = i + skip
84 until (i == 255) -- }}}
85 i = nil
86 --- }}}
87
88 if not (tpr["<"]) or not (tpr[">"]) then
89 err(
90 "Impossible to find at least two unused byte codes in this HTML-code. We need it to escape bracket-contained placeholders inside tags.")
91 err(
92 "Consider enabling 'keep_danger_placeholders' option (to silence this error, if parser wasn't failed with current HTML-code) or manually replace few random bytes, to free up the codes.")
93 else
94 dbg("[FindPH]:#LINE# Found! || '<'=%d, '>'=%d", tpr["<"]:byte(), tpr[">"]:byte())
95 end
96
97 -- dbg("tpr[>] || tpr[] || #busy%d")
98
99 -- g {{{
100 local function g(id, ...)
101 local arg = { ... }
102 local orig = arg[id]
103 arg[id] = arg[id]:gsub("(.)", tpr)
104 if arg[id] ~= orig then
105 tpl = true
106 dbg("[g]:#LINE# orig: %s", str(orig))
107 dbg("[g]:#LINE# replaced: %s", str(arg[id]))
108 end
109 dbg("[g]:#LINE# called, id: %s, arg[id]: %s, args { " .. (("{%s}, "):rep(#arg):gsub(", $", "")) .. " }", id,
110 arg[id], ...)
111 dbg("[g]:#LINE# concat(arg): %s", table.concat(arg))
112 return table.concat(arg)
113 end
114 -- g }}}
115
116 -- tpl-placeholders and attributes {{{
117 text = text
118 :gsub(
119 "(=[%s]-)" .. -- only match attr.values, and not random strings between two random apostrophs
120 "(%b'')",
121 function(...) return g(2, ...) end
122 )
123 :gsub(
124 "(=[%s]-)" .. -- same for "
125 '(%b"")',
126 function(...) return g(2, ...) end
127 ) -- Escape "<"/">" inside attr.values (see issue #50)
128 :gsub(
129 "(<" .. -- Match "<",
130 (opts.tpl_skip_pattern or "[^!]") .. -- with exclusion pattern (for example, to ignore comments, which aren't template placeholders, but can legally contain "<"/">" inside.
131 ")([^>]+)" .. -- If matched, we want to escape '<'s if we meet them inside tag
132 "(>)",
133 function(...) return g(2, ...) end
134 )
135 :gsub(
136 "(" ..
137 (tpr["<"] or "__FAILED__") .. -- Here we search for "<", we escaped in previous gsub (and don't break things if we have no escaping replacement)
138 ")(" ..
139 (opts.tpl_marker_pattern or "[^%w%s]") .. -- Capture templating symbol
140 ")([%g%s]-)" .. -- match placeholder's content
141 "(%2)(>)" .. -- placeholder's tail
142 "([^>]*>)", -- remainings
143 function(...) return g(5, ...) end
144 )
145 -- }}}
146 end -- }}}
147
148 local index = 0
149 local root = ElementNode:new(index, str(text))
150 local node, descend, tpos, opentags = root, true, 1, {}
151
152 while true do -- MainLoop {{{
153 if index == limit then -- {{{
154 err(
155 "Main loop reached loop limit (%d). Consider either increasing it or checking HTML-code for syntax errors",
156 limit)
157 break
158 end -- }}}
159 -- openstart/tpos Definitions {{{
160 local openstart, name
161 openstart, tpos, name = root._text:find(
162 "<" .. -- an uncaptured starting "<"
163 "([%w-]+)" .. -- name = the first word, directly following the "<"
164 "[^>]*>", -- include, but not capture everything up to the next ">"
165 tpos)
166 dbg("[MainLoop]:#LINE# openstart=%s || tpos=%s || name=%s", str(openstart), str(tpos), str(name))
167 -- }}})
168 if not name then break end
169 -- Some more vars {{{
170 index = index + 1
171 local tag = ElementNode:new(index, str(name), (node or {}), descend, openstart, tpos)
172 node = tag
173 local tagloop
174 local tagst, apos = tag:gettext(), 1
175 -- }}}
176 while true do -- TagLoop {{{
177 dbg("[TagLoop]:#LINE# tag.name=%s, tagloop=%s", str(tag.name), str(tagloop))
178 if tagloop == limit then -- {{{
179 err(
180 "Tag parsing loop reached loop limit (%d). Consider either increasing it or checking HTML-code for syntax errors",
181 limit)
182 break
183 end -- }}}
184 -- Attrs {{{
185 local start, k, eq, quote, v, zsp
186 start, apos, k, zsp, eq, zsp, quote = tagst:find(
187 "%s+" .. -- some uncaptured space
188 "([^%s=/>]+)" .. -- k = an unspaced string up to an optional "=" or the "/" or ">"
189 "([%s]-)" .. -- zero or more spaces
190 "(=?)" .. -- eq = the optional; "=", else ""
191 "([%s]-)" .. -- zero or more spaces
192 [=[(['"]?)]=], -- quote = an optional "'" or '"' following the "=", or ""
193 apos)
194 dbg("[TagLoop]:#LINE# start=%s || apos=%s || k=%s || zsp='%s' || eq='%s', quote=[%s]", str(start), str(apos),
195 str(k), str(zsp), str(eq), str(quote))
196 -- }}}
197 if not k or k == "/>" or k == ">" then break end
198 -- Pattern {{{
199 if eq == "=" then
200 local pattern = "=([^%s>]*)"
201 if quote ~= "" then
202 pattern = quote .. "([^" .. quote .. "]*)" .. quote
203 end
204 start, apos, v = tagst:find(pattern, apos)
205 dbg("[TagLoop]:#LINE# start=%s || apos=%s || v=%s || pattern=%s", str(start), str(apos), str(v),
206 str(pattern))
207 end
208 -- }}}
209 v = v or ""
210 if tpl then -- {{{
211 for rk, rv in pairs(tpr) do
212 v = v:gsub(rv, rk)
213 dbg("[TagLoop]:#LINE# rv=%s || rk=%s", str(rv), str(rk))
214 end
215 end -- }}}
216
217 dbg("[TagLoop]:#LINE# k=%s || v=%s", str(k), str(v))
218 tag:addattribute(k, v)
219 tagloop = (tagloop or 0) + 1
220 end
221 -- }}}
222 if voidelements[tag.name:lower()] then -- {{{
223 descend = false
224 tag:close()
225 else
226 descend = true
227 opentags[tag.name] = opentags[tag.name] or {}
228 table.insert(opentags[tag.name], tag)
229 end
230 -- }}}
231 local closeend = tpos
232 local closingloop
233 while true do -- TagCloseLoop {{{
234 -- Can't remember why did I add that, so comment it for now (and not remove), in case it will be needed again
235 -- (although, it causes #59 and #60, so it will anyway be needed to rework)
236 -- if voidelements[tag.name:lower()] then break end -- already closed
237 if closingloop == limit then
238 err(
239 "Tag closing loop reached loop limit (%d). Consider either increasing it or checking HTML-code for syntax errors",
240 limit)
241 break
242 end
243
244 local closestart, closing, closename
245 closestart, closeend, closing, closename = root._text:find("[^<]*<(/?)([%w-]+)", closeend)
246 dbg("[TagCloseLoop]:#LINE# closestart=%s || closeend=%s || closing=%s || closename=%s", str(closestart),
247 str(closeend), str(closing), str(closename))
248
249 if not closing or closing == "" then break end
250
251 closename = closename:lower()
252 tag = table.remove(opentags[closename] or {}) or
253 tag -- kludges for the cases of closing void or non-opened tags
254 closestart = root._text:find("<", closestart)
255 dbg("[TagCloseLoop]:#LINE# closestart=%s", str(closestart))
256 tag:close(closestart, closeend + 1)
257 node = tag.parent
258 descend = true
259 closingloop = (closingloop or 0) + 1
260 end -- }}}
261 end -- }}}
262 if tpl then -- {{{
263 dbg("tpl")
264 for k, v in pairs(tpr) do
265 root._text = root._text:gsub(v, k)
266 end
267 end -- }}}
268 return root
269end -- }}}
270HtmlParser.parse = parse
271