JasonWoof Got questions, comments, patches, etc.? Contact Jason Woofenden
start parsing open tags
[peach-html5-editor.git] / parse-html.coffee
1 # HTML parser meant to run in a browser, in support of WYSIWYG editor
2 # Copyright 2015 Jason Woofenden
3 #
4 # This program is free software: you can redistribute it and/or modify it under
5 # the terms of the GNU Affero General Public License as published by the Free
6 # Software Foundation, either version 3 of the License, or (at your option) any
7 # later version.
8 #
9 # This program is distributed in the hope that it will be useful, but WITHOUT
10 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11 # FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
12 # details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17
18 # This file implements a parser for html snippets, meant to be used by a
19 # WYSIWYG editor. Hence it does not attempt to parse doctypes, <html>, <head>
20 # or <body> tags, nor does it produce the top level "document" node in the dom
21 # tree, nor nodes for html, head or body.
22 #
23 # Instead, the data structure produced by this parser is an array of nodes.
24 #
25 # Each node is an array. The first element in the array is an integer (one of
26 # the TYPE_* constants below) followed by the appropriate fields for that type
27 # (shown below in the comments after the TYPE_* definition.)
28
29 TYPE_TAG = 0 # name, {attributes}, [children]
30 TYPE_TEXT = 1 # "text"
31 TYPE_WHITESPACE = 2
32 TYPE_COMMENT = 3
33 # the following types are emited by the tokenizer, but shouldn't end up in the tree:
34 TYPE_OPEN_TAG = 4
35
36 lc_alpha = "abcdefghijklmnopqrstuvwxqz"
37 uc_alpha = "ABCDEFGHIJKLMNOPQRSTUVWXQZ"
38 digits = "0123456789"
39 alnum = lc_alpha + uc_alpha + digits
40 hex_chars = digits + "abcdefABCDEF"
41
42 # some SVG elements have dashes in them
43 tag_name_chars = alnum + "-"
44
45 # http://www.w3.org/TR/html5/infrastructure.html#space-character
46 space_chars = "\u0009\u000a\u000c\u000d\u0020"
47
48 # https://en.wikipedia.org/wiki/Whitespace_character#Unicode
49 whitespace_chars = "\u0009\u000a\u000b\u000c\u000d\u0020\u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000"
50
51 # These are the character references that don't need a terminating semicolon
52 # min length: 2, max: 6, none are a prefix of any other.
53 legacy_char_refs = {
54         Aacute: 'Á', aacute: 'á', Acirc: 'Â', acirc: 'â', acute: '´', AElig: 'Æ',
55         aelig: 'æ', Agrave: 'À', agrave: 'à', AMP: '&', amp: '&', Aring: 'Å',
56         aring: 'å', Atilde: 'Ã', atilde: 'ã', Auml: 'Ä', auml: 'ä', brvbar: '¦',
57         Ccedil: 'Ç', ccedil: 'ç', cedil: '¸', cent: '¢', COPY: '©', copy: '©',
58         curren: '¤', deg: '°', divide: '÷', Eacute: 'É', eacute: 'é', Ecirc: 'Ê',
59         ecirc: 'ê', Egrave: 'È', egrave: 'è', ETH: 'Ð', eth: 'ð', Euml: 'Ë',
60         euml: 'ë', frac12: '½', frac14: '¼', frac34: '¾', GT: '>', gt: '>',
61         Iacute: 'Í', iacute: 'í', Icirc: 'Î', icirc: 'î', iexcl: '¡', Igrave: 'Ì',
62         igrave: 'ì', iquest: '¿', Iuml: 'Ï', iuml: 'ï', laquo: '«', LT: '<',
63         lt: '<', macr: '¯', micro: 'µ', middot: '·', nbsp: "\u00a0", not: '¬',
64         Ntilde: 'Ñ', ntilde: 'ñ', Oacute: 'Ó', oacute: 'ó', Ocirc: 'Ô', ocirc: 'ô',
65         Ograve: 'Ò', ograve: 'ò', ordf: 'ª', ordm: 'º', Oslash: 'Ø', oslash: 'ø',
66         Otilde: 'Õ', otilde: 'õ', Ouml: 'Ö', ouml: 'ö', para: '¶', plusmn: '±',
67         pound: '£', QUOT: '"', quot: '"', raquo: '»', REG: '®', reg: '®', sect: '§',
68         shy: '­', sup1: '¹', sup2: '²', sup3: '³', szlig: 'ß', THORN: 'Þ', thorn: 'þ',
69         times: '×', Uacute: 'Ú', uacute: 'ú', Ucirc: 'Û', ucirc: 'û', Ugrave: 'Ù',
70         ugrave: 'ù', uml: '¨', Uuml: 'Ü', uuml: 'ü', Yacute: 'Ý', yacute: 'ý',
71         yen: '¥', yuml: 'ÿ'
72 }
73
74 void_elements = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']
75 raw_text_elements = ['script', 'style']
76 escapable_raw_text_elements = ['textarea', 'title']
77 # http://www.w3.org/TR/SVG/ 1.1 (Second Edition)
78 svg_elements = [
79         'a', 'altGlyph', 'altGlyphDef', 'altGlyphItem', 'animate', 'animateColor',
80         'animateMotion', 'animateTransform', 'circle', 'clipPath', 'color-profile',
81         'cursor', 'defs', 'desc', 'ellipse', 'feBlend', 'feColorMatrix',
82         'feComponentTransfer', 'feComposite', 'feConvolveMatrix',
83         'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight', 'feFlood',
84         'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR', 'feGaussianBlur', 'feImage',
85         'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'fePointLight',
86         'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence', 'filter',
87         'font', 'font-face', 'font-face-format', 'font-face-name', 'font-face-src',
88         'font-face-uri', 'foreignObject', 'g', 'glyph', 'glyphRef', 'hkern',
89         'image', 'line', 'linearGradient', 'marker', 'mask', 'metadata',
90         'missing-glyph', 'mpath', 'path', 'pattern', 'polygon', 'polyline',
91         'radialGradient', 'rect', 'script', 'set', 'stop', 'style', 'svg',
92         'switch', 'symbol', 'text', 'textPath', 'title', 'tref', 'tspan', 'use',
93         'view', 'vkern'
94 ]
95
96 # http://www.w3.org/TR/MathML/ Version 3.0 2nd Edition
97 mathml_elements = [
98         'abs', 'and', 'annotation', 'annotation-xml', 'apply', 'approx', 'arccos',
99         'arccosh', 'arccot', 'arccoth', 'arccsc', 'arccsch', 'arcsec', 'arcsech',
100         'arcsin', 'arcsinh', 'arctan', 'arctanh', 'arg', 'bind', 'bvar', 'card',
101         'cartesianproduct', 'cbytes', 'ceiling', 'cerror', 'ci', 'cn', 'codomain',
102         'complexes', 'compose', 'condition', 'conjugate', 'cos', 'cosh', 'cot',
103         'coth', 'cs', 'csc', 'csch', 'csymbol', 'curl', 'declare', 'degree',
104         'determinant', 'diff', 'divergence', 'divide', 'domain',
105         'domainofapplication', 'emptyset', 'eq', 'equivalent', 'eulergamma',
106         'exists', 'exp', 'exponentiale', 'factorial', 'factorof', 'false', 'floor',
107         'fn', 'forall', 'gcd', 'geq', 'grad', 'gt', 'ident', 'image', 'imaginary',
108         'imaginaryi', 'implies', 'in', 'infinity', 'int', 'integers', 'intersect',
109         'interval', 'inverse', 'lambda', 'laplacian', 'lcm', 'leq', 'limit',
110         'list', 'ln', 'log', 'logbase', 'lowlimit', 'lt', 'maction', 'maligngroup',
111         'malignmark', 'math', 'matrix', 'matrixrow', 'max', 'mean', 'median',
112         'menclose', 'merror', 'mfenced', 'mfrac', 'mglyph', 'mi', 'mi', 'min',
113         'minus', 'mlabeledtr', 'mlongdiv', 'mmultiscripts', 'mn', 'mo', 'mode',
114         'moment', 'momentabout', 'mover', 'mpadded', 'mphantom', 'mprescripts',
115         'mroot', 'mrow', 'ms', 'mscarries', 'mscarry', 'msgroup', 'msline',
116         'mspace', 'msqrt', 'msrow', 'mstack', 'mstyle', 'msub', 'msubsup', 'msup',
117         'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover', 'naturalnumbers',
118         'neq', 'none', 'not', 'notanumber', 'notin', 'notprsubset', 'notsubset',
119         'or', 'otherwise', 'outerproduct', 'partialdiff', 'pi', 'piece',
120         'piecewise', 'plus', 'power', 'primes', 'product', 'prsubset', 'quotient',
121         'rationals', 'real', 'reals', 'reln', 'rem', 'root', 'scalarproduct',
122         'sdev', 'sec', 'sech', 'selector', 'semantics', 'sep', 'set', 'setdiff',
123         'share', 'sin', 'sinh', 'span', 'subset', 'sum', 'tan', 'tanh', 'tendsto',
124         'times', 'transpose', 'true', 'union', 'uplimit', 'variance', 'vector',
125         'vectorproduct', 'xor'
126 ]
127 # foreign_elements = [svg_elements..., mathml_elements...]
128 #normal_elements = All other allowed HTML elements are normal elements.
129
130
131 # decode_named_char_ref()
132 #
133 # The list of named character references is _huge_ so ask the browser to decode
134 # for us instead of wasting bandwidth/space on including the table here.
135 #
136 # Pass without the "&" but with the ";" examples:
137 #    for "&amp" pass "amp;"
138 #    for "&#x2032" pass "x2032;"
139 g_dncr = {
140         cache: {}
141         textarea: document.createElement('textarea')
142 }
143 # TODO test this in IE8
144 decode_named_char_ref = (txt) ->
145         txt = "&#{txt}"
146         decoded = g_dncr.cache[txt]
147         return decoded if decoded?
148         g_dncr.textarea.innerHTML = txt
149         decoded = g_dncr.textarea.value
150         return null if decoded is txt
151         return g_dncr.cache[txt] = decoded
152
153 parse_html = (txt) ->
154         cur = 0 # index of next char in txt to be parsed
155         # declare tree and tokenizer variables so they're in scope below
156         tree = null
157         tree_append_point = null
158         tree_state = null
159         tok_state = null
160         tok_cur = null # partially parsed tag
161
162
163         # the functions below implement the tokenizer stats described here:
164         # http://www.w3.org/TR/html5/syntax.html#tokenization
165
166         # http://www.w3.org/TR/html5/syntax.html#data-state
167         tok_state_data = ->
168                 switch c = txt.charAt(cur++)
169                         when '&'
170                                 tok_state = tok_state_character_reference_in_data
171                         when '<'
172                                 tok_state = tok_state_tag_open
173                         when "\u0000"
174                                 # Parse error
175                                 return [TYPE_TEXT, c]
176                         else
177                                 return [TYPE_TEXT, c]
178                 return null
179
180         # http://www.w3.org/TR/html5/syntax.html#tag-open-state
181         tok_state_tag_open = ->
182                 switch c = txt.charAt(cur++)
183                         when '!'
184                                 tok_state = tok_state_markup_declaration_open
185                         when '/'
186                                 tok_state = tok_state_end_tag_open
187                         when '?'
188                                 # Parse error
189                                 tok_state = tok_state_bogus_comment
190                         else
191                                 if lc_alpha.indexOf(c) > -1
192                                         tok_cur = [TYPE_OPEN_TAG, c, {}, []]
193                                         tok_state = tok_state_tag_name
194                                 else if uc_alpha.indexOf(c) > -1
195                                         tok_cur = [TYPE_OPEN_TAG, c.toLowerCase(), {}, []]
196                                         tok_state = tok_state_tag_name
197                                 else
198                                         # Parse error
199                                         tok_state = tok_state_data
200                                         cur -= 1 # we didn't parse/handle the char after <
201                                         return [TYPE_TEXT, '<']
202                 return null
203
204         # http://www.w3.org/TR/html5/syntax.html#tag-name-state
205         tok_state_tag_name = ->
206                 switch c = txt.charAt(cur++)
207                         when "\t", "\n", ' '
208                                 tok_state = tok_state_before_attribute_name
209                         when '/'
210                                 tok_state = tok_state_self_closing_start_tag
211                         when '>'
212                                 tok_state = tok_state_data
213                                 tmp = tok_cur
214                                 tok_cur = null
215                                 return tmp
216                         when "\u0000"
217                                 # Parse error
218                                 tok_cur[1] += "\ufffd"
219                         else
220                                 if uc_alpha.indexOf(c) > -1
221                                         tok_cur[1] += c.toLowerCase()
222                                 else
223                                         tok_cur[1] += c
224                 return null
225
226         # http://www.w3.org/TR/html5/syntax.html#character-reference-in-data-state
227         # & just got consumed
228         tok_state_character_reference_in_data = ->
229                 tok_state = tok_state_data
230                 if cur >= txt.length
231                         return [TYPE_TEXT, '&']
232                 switch c = txt.charAt(cur)
233                         when ';'
234                                 return [TYPE_TEXT, '&']
235                         when '#'
236                                 if cur + 1 >= txt.length
237                                         return [TYPE_TEXT, '&']
238                                 if txt.charAt(cur + 1).toLowerCase() is 'x'
239                                         prefix = '#x'
240                                         charset = hex_chars
241                                         start = cur + 2
242                                 else
243                                         charset = digits
244                                         start = cur + 1
245                                         prefix = '#'
246                                 i = 0
247                                 while start + i < txt.length and charset.indexOf(txt.charAt(start + i)) > -1
248                                         i += 1
249                                 if i is 0
250                                         return [TYPE_TEXT, '&']
251                                 if txt.charAt(start + i) is ';'
252                                         i += 1
253                                 decoded = decode_named_char_ref(prefix + txt.substr(start, i).toLowerCase())
254                                 if decoded?
255                                         cur = start + i
256                                         return [TYPE_TEXT, decoded]
257                                 return [TYPE_TEXT, '&']
258                         else
259                                 for i in [0...31]
260                                         if alnum.indexOf(txt.charAt(cur + i)) is -1
261                                                 break
262                                 if i is 0
263                                         return [TYPE_TEXT, '&']
264                                 if txt.charAt(cur + i) is ';'
265                                         i += 1 # include ';' terminator in value
266                                         decoded = decode_named_char_ref txt.substr(cur, i)
267                                         if decoded?
268                                                 cur += i
269                                                 return [TYPE_TEXT, decoded]
270                                         return [TYPE_TEXT, '&']
271                                 else
272                                         # no ';' terminator (only legacy char refs)
273                                         if i < 2 or i > 6
274                                                 return [TYPE_TEXT, '&']
275                                         # FIXME: if we're inside an attribute:
276                                         # 1.    don't parse refs that are followed by =
277                                         # 2.    don't parse refs that are followed by alnum
278                                         max = i
279                                         for i in [2..max] # no prefix matches, so ok to check shortest first
280                                                 c = legacy_char_refs[txt.substr(cur, i)]
281                                                 if c?
282                                                         cur += i # consume entity chars
283                                                         return [TYPE_TEXT, c]
284                 return null
285
286         # the functions below impliment the Tree Contstruction algorithm here:
287         # http://www.w3.org/TR/html5/syntax.html#tree-construction
288         # FIXME this is just a bit of a hack that makes sense... read spec and do it that way
289         tree_append = (t) ->
290                 if t[0] is TYPE_TEXT and tree_append_point.length > 0 and tree_append_point[tree_append_point.length - 1][0] is TYPE_TEXT
291                         tree_append_point[tree_append_point.length - 1][1] += t[1]
292                 else
293                         tree_append_point.push t
294                         if t[0] is TYPE_OPEN_TAG
295                                 t[0] = TYPE_TAG
296                                 tree_append_point = t[3]
297
298         # tree constructor initialization
299         tree = [] # see comments on TYPE_TAG/etc for the structure of this data
300         tree_append_point = tree
301         tree_state = tree_append
302
303         # tokenizer initialization
304         tok_state = tok_state_data
305
306         # proccess input
307         while cur < txt.length
308                 t = tok_state()
309                 if t?
310                         tree_state t
311
312         return tree
313
314 # everything below is tests on the above
315 test_equals = (description, fn, args..., expected_output) ->
316         output = fn.apply this, args
317         if output is expected_output
318                 console.log "passed: #{description}."
319         else
320                 console.log "FAILED: #{description}. Expected: #{expected_output}, actual: #{output}"
321 html_to_json = (html) ->
322         return JSON.stringify parse_html html
323 test_equals "empty", html_to_json, "", '[]'
324 test_equals "just text", html_to_json, "abc", '[[1,"abc"]]'
325 test_equals "named entity", html_to_json, "a&amp;1234", '[[1,"a&1234"]]'
326 test_equals "broken named character references", html_to_json, "1&amp2&&amp;3&aabbcc;", '[[1,"1&2&&3&aabbcc;"]]'
327 test_equals "numbered entity overrides", html_to_json, "1&#X80&#x80; &#x83", '[[1,"1€€ ƒ"]]'
328 test_equals "open_tag", html_to_json, "foo<span>bar", '[[1,"foo"],[0,"span",{},[[1,"bar"]]]]'