UNPKG

12.6 kBJavaScriptView Raw
1const selfClosing = ['input', 'link', 'meta', 'hr', 'br', 'source', 'img'];
2
3function without(arr, element, attr) {
4 let idx;
5 arr.forEach((node, index) => {
6 if ((attr && node[attr] === element) || node === element) {
7 idx = index;
8 }
9 });
10 arr.splice(idx, 1);
11 return arr;
12}
13
14function isCustomSelfClosing(tagName) {
15 let tagList = module.exports.options.customSelfClosingTags;
16 if (!tagList) return false;
17 if (Array.isArray(tagList)) {
18 return tagList.indexOf(tagName) >= 0;
19 }
20 if (tagList instanceof RegExp) {
21 return tagList.test(tagName);
22 }
23 if (typeof tagList === 'string') {
24 return (new RegExp(tagList)).test(tagName);
25 }
26 throw new Error('Unknown custom self closing tag list format. Please use an array with tag names, a regular expression or a string');
27}
28
29function parseAttributes(node, attributes) {
30 attributes = (attributes || '').trim();
31 if (attributes.length <= 0) {
32 return;
33 }
34 let match = [];
35 let position = 0;
36 let charCode = attributes.charCodeAt(position);
37 while (charCode >= 65 && charCode <= 90 || // upper-cased characters
38 charCode >= 97 && charCode <= 122 || // lower-cased characters
39 charCode >= 48 && charCode <= 57 || // numbers
40 charCode === 58 || charCode === 45 || // colons and dashes
41 charCode === 95) { // underscores
42 match[1] = (match[1] || '') + attributes.charAt(position);
43 charCode = attributes.charCodeAt(++position);
44 }
45 attributes = attributes.substr(position).trim();
46 if (attributes[0] !== '=') {
47 if (attributes[0] === '<') {
48 throw new Error('Unexpected markup while parsing attributes for ' + node.tagName + ' at ' + attributes);
49 }
50 node.setAttribute(match[1], match[1]);
51 parseAttributes(node, attributes);
52 } else {
53 attributes = attributes.substr(1).trim();
54 if (attributes[0] === '"' || attributes[0] === "'") {
55 // search for another "
56 position = 1;
57 while (attributes[position] !== attributes[0]) {
58 match[2] = (match[2] || '') + attributes[position];
59 position += 1;
60 }
61 attributes = attributes.substr(position + 1);
62 } else {
63 match[2] = attributes.split(' ')[0];
64 attributes = attributes.split(' ').slice(1).join(' ');
65 }
66 node.setAttribute(match[1], match[2]);
67 if (match[1] === 'class') {
68 node.classList.add(match[2]);
69 } else if (match[1] === 'title' || match[1] === 'id' || match[1] === 'name') {
70 node[match[1]] = match[2];
71 }
72 return parseAttributes(node, attributes);
73 }
74}
75
76function getNextTag(html, position = -1) {
77 let match = null;
78 if (position < 0) {
79 position = html.indexOf('<');
80 }
81 // we are at a < now or at the end of the string
82 if (position >= 0 && position < html.length) {
83 match = [];
84 match.index = position;
85 position += 1;
86 if (html[position] === '/') {
87 match[1] = '/';
88 position += 1;
89 }
90 let charCode = html.charCodeAt(position);
91 // read all tag name characters
92 while (charCode >= 65 && charCode <= 90 || // upper-cased characters
93 charCode >= 97 && charCode <= 122 || // lower-cased characters
94 charCode >= 48 && charCode <= 57 || // numbers
95 charCode === 58 || charCode === 45 || // colons and dashes
96 charCode === 95) { // underscores
97 match[2] = (match[2] || '') + html.charAt(position);
98 charCode = html.charCodeAt(++position);
99 }
100 if (!match[2]) {
101 return getNextTag(html, html.indexOf('<', position));
102 }
103 let startAttrs = position;
104 let isInAttributeValue = false;
105 while (position < html.length && (html[position] !== '>' || isInAttributeValue)) {
106 if (html[position] === '"') isInAttributeValue = !isInAttributeValue;
107 position++;
108 }
109 if (position < html.length) {
110 let endAttrs = position;
111 if (html[position - 1] === '/') {
112 match[4] = '/';
113 endAttrs = position - 1;
114 }
115 if (endAttrs - startAttrs > 1) {
116 // we have something
117 match[3] = html.substring(startAttrs, endAttrs);
118 }
119 }
120 match[0] = html.substring(match.index, position + 1);
121 }
122 return match;
123}
124
125let level = [];
126function parse(document, html, parentNode) {
127 let match;
128
129 while (match = getNextTag(html)) {
130 if (match[1]) {
131 // closing tag
132 if (level.length === 0) throw new Error('Unexpected closing tag ' + match[2]);
133 let closed = level.pop();
134 if (closed !== match[2]) throw new Error('Unexpected closing tag ' + match[2] + '; expected ' + closed);
135 let content = html.substring(0, match.index);
136 if (content) {
137 parentNode.appendChild(document.createTextNode(content));
138 }
139 return html.substr(match.index + match[0].length);
140 } else {
141 // opening tag
142 let content = html.substring(0, match.index);
143 if (content) {
144 parentNode.appendChild(document.createTextNode(content));
145 }
146 let node = document.createElement(match[2]);
147 parseAttributes(node, match[3]);
148 if (!match[4] && selfClosing.indexOf(match[2]) < 0 && !isCustomSelfClosing(match[2])) {
149 level.push(match[2]);
150 html = parse(document, html.substr(match.index + match[0].length), node);
151 } else {
152 html = html.substr(match.index + match[0].length);
153 }
154 parentNode.appendChild(node);
155 }
156 }
157 if (level.length > 0) {
158 throw new Error('Unclosed tag' + (level.length > 1 ? 's ' : ' ') + level.join(', '));
159 }
160 if (html.length > 0) {
161 parentNode.appendChild(document.createTextNode(html));
162 }
163 return html;
164}
165
166// helpers
167const regExp = function(name) {
168 return new RegExp('(^| )'+ name +'( |$)');
169};
170const forEach = function(list, fn, scope) {
171 for (let i = 0; i < list.length; i++) {
172 fn.call(scope, list[i]);
173 }
174};
175
176// class list object with basic methods
177function ClassList(element) {
178 this.element = element;
179}
180
181ClassList.prototype = {
182 add: function() {
183 forEach(arguments, function(name) {
184 if (!this.contains(name)) {
185 this.element.className += this.element.className.length > 0 ? ' ' + name : name;
186 }
187 }, this);
188 },
189 remove: function() {
190 forEach(arguments, function(name) {
191 this.element.className =
192 this.element.className.replace(regExp(name), '');
193 }, this);
194 },
195 toggle: function(name) {
196 return this.contains(name)
197 ? (this.remove(name), false) : (this.add(name), true);
198 },
199 contains: function(name) {
200 return regExp(name).test(this.element.className);
201 },
202 // bonus..
203 replace: function(oldName, newName) {
204 this.remove(oldName), this.add(newName);
205 }
206};
207
208function matchesSelector(tag, selector) {
209 let selectors = selector.split(/\s*,\s*/),
210 match;
211 for (let all in selectors) {
212 if (match = selectors[all].match(/(?:([\w*:_-]+)?\[([\w:_-]+)(?:(\$|\^|\*)?=(?:(?:'([^']*)')|(?:"([^"]*)")))?\])|(?:\.([\w_-]+))|([\w*:_-]+)/g)) {
213 let value = RegExp.$4 || RegExp.$5;
214 if (RegExp.$7 === tag.tagName || RegExp.$7 === '*') return true;
215 if (RegExp.$6 && tag.classList.contains(RegExp.$6)) return true;
216 if (RegExp.$1 && tag.tagName !== RegExp.$1) continue;
217 let attribute = tag.getAttribute(RegExp.$2);
218 if (!RegExp.$3 && !value && typeof tag.attributes[RegExp.$2] !== 'undefined') return true;
219 if (!RegExp.$3 && value && attribute === value) return true;
220 if (RegExp.$3 && RegExp.$3 === '^' && attribute.indexOf(value) === 0) return true;
221 if (RegExp.$3 && RegExp.$3 === '$' && attribute.match(new RegExp(value + '$'))) return true;
222 if (RegExp.$3 && RegExp.$3 === '*' && attribute.indexOf(value) >= 0) return true;
223 }
224 }
225 return false;
226}
227
228function findElements(start, filterFn) {
229 let result = [];
230 start.children.forEach(child => {
231 result = result.concat(filterFn(child) ? child : [], findElements(child, filterFn));
232 });
233 return result;
234}
235
236function HTMLElement(name, owner) {
237 this.nodeType = 1;
238 this.nodeName = name;
239 this.tagName = name;
240 this.className = '';
241 this.childNodes = [];
242 this.style = {};
243 this.ownerDocument = owner;
244 this.parentNode = null;
245 this.attributes = [];
246}
247
248Object.defineProperty(HTMLElement.prototype, 'children', {
249 get: function() { return this.childNodes.filter(node => node.nodeType === 1) }
250});
251Object.defineProperty(HTMLElement.prototype, 'classList', {
252 get: function() { return new ClassList(this); }
253});
254Object.defineProperty(HTMLElement.prototype, 'innerHTML', {
255 get: function() {
256 return this.childNodes.map(tag => tag.nodeType === 1 ? tag.outerHTML : tag.nodeValue).join('');
257 },
258 set: function (value) {
259 this.childNodes = [];
260 level = [];
261 parse(this.ownerDocument, value, this);
262 }
263});
264Object.defineProperty(HTMLElement.prototype, 'outerHTML', {
265 get: function() {
266 if (Object.prototype.toString.call(this.attributes) !== '[object Array]') {
267 this.attributes = Object.keys(this.attributes).map(entry => ({name: entry, value: this.attributes[entry]}));
268 this.attributes.forEach((attr, idx, arr) => {
269 this.attributes[attr.name] = attr.value;
270 });
271 }
272 let attributes = this.attributes.map(attr => `${attr.name}="${typeof attr.value === 'undefined'?'':attr.value}"`).join(' ');
273 if (selfClosing.indexOf(this.tagName) >= 0 || isCustomSelfClosing(this.tagName)) {
274 return `<${this.tagName}${attributes ? ' ' + attributes : ''}/>`;
275 } else {
276 return `<${this.tagName}${attributes ? ' ' + attributes : ''}>${this.innerHTML}</${this.tagName}>`;
277 }
278 }
279});
280HTMLElement.prototype.appendChild = function(child) {
281 this.childNodes.push(child);
282 child.parentNode = this;
283};
284HTMLElement.prototype.removeChild = function(child) {
285 let idx = this.childNodes.indexOf(child);
286 if (idx >= 0) this.childNodes.splice(idx, 1);
287};
288HTMLElement.prototype.setAttribute = function(name, value) {
289 let obj = {name, value};
290 if (this.attributes[name]) {
291 this.attributes[this.attributes.indexOf(this.attributes[name])] = obj;
292 } else {
293 this.attributes.push(obj);
294 }
295 this.attributes[name] = obj;
296 if (name === 'class') this.className = value;
297};
298HTMLElement.prototype.removeAttribute = function(name) {
299 let idx = this.attributes.indexOf(this.attributes[name]);
300 if (idx >= 0) {
301 this.attributes.splice(idx, 1);
302 }
303 delete this.attributes[name];
304};
305HTMLElement.prototype.getAttribute = function(name) { return this.attributes[name] && this.attributes[name].value || ''; };
306HTMLElement.prototype.replaceChild = function(newChild, toReplace) {
307 let idx = this.childNodes.indexOf(toReplace);
308 this.childNodes.splice(idx, 1, newChild);
309 newChild.parentNode = this;
310};
311HTMLElement.prototype.addEventListener = function() {};
312HTMLElement.prototype.removeEventListener = function() {};
313HTMLElement.prototype.getElementsByTagName = function(tagName) {
314 return findElements(this, el => ((tagName === '*' && el.tagName) || el.tagName === tagName));
315};
316HTMLElement.prototype.getElementsByClassName = function(className) {
317 return findElements(this, el => el.classList.contains(className));
318};
319HTMLElement.prototype.querySelectorAll = function(selector) {
320 return findElements(this, el => matchesSelector(el, selector));
321};
322HTMLElement.prototype.getElementById = function(id) {
323 return findElements(this, el => el.getAttribute('id') === id)[0];
324};
325
326function DOMText(content, owner) {
327 this.nodeValue = content;
328 this.nodeType = 3;
329 this.parentNode = null;
330 this.ownerDocument = owner;
331}
332
333export default function Document(html) {
334 if (!this instanceof Document) {
335 return new Document(html);
336 }
337
338 this.createElement = name => new HTMLElement(name, this);
339 this.createTextNode = content => new DOMText(content, this);
340 this.getElementById = HTMLElement.prototype.getElementById.bind(this);
341 this.getElementsByTagName = HTMLElement.prototype.getElementsByTagName.bind(this);
342 this.getElementsByClassName = HTMLElement.prototype.getElementsByClassName.bind(this);
343 this.querySelectorAll = HTMLElement.prototype.querySelectorAll.bind(this);
344 this.addEventListener = () => {};
345 this.removeEventListener = () => {};
346
347 this.documentElement = this.createElement('html');
348 this.childNodes = [this.documentElement];
349 this.children = [this.documentElement];
350 this.nodeType = 9;
351
352 if (typeof html !== 'string' || html.trim().indexOf('<!DOCTYPE') < 0) {
353 this.head = this.createElement('head');
354 this.body = this.createElement('body');
355 this.documentElement.appendChild(this.head);
356 this.documentElement.appendChild(this.body);
357 if (typeof html === 'string') {
358 level = [];
359 parse(this, html, this.body);
360 }
361 } else {
362 html.match(/<html([^>]*)>/);
363 if (RegExp.$1) {
364 parseAttributes(this.documentElement, RegExp.$1);
365 }
366 html = html.replace(/<!DOCTYPE[^>]+>[\n\s]*<html([^>]*)>/g, '').replace(/<\/html>/g, '');
367 level = [];
368 parse(this, html, this.documentElement);
369 this.head = this.getElementsByTagName('head')[0];
370 this.body = this.getElementsByTagName('body')[0];
371 }
372}
373
374module.exports = Document;
375module.exports.DOMElement = HTMLElement;
376module.exports.DOMText = DOMText;
377module.exports.options = {
378 customSelfClosingTags: null
379};
380typeof global !== 'undefined' && (global.HTMLElement = HTMLElement);