UNPKG

8.96 kBJavaScriptView Raw
1const DEFAULT_IGNORE_TAGS = ['script', 'style', 'svg'];
2const DEFAULT_EMPTY_ATTRS = ['class', 'id'];
3const VOID_ELEMENTS = [
4 'area',
5 'base',
6 'br',
7 'col',
8 'embed',
9 'hr',
10 'img',
11 'input',
12 'keygen',
13 'link',
14 'menuitem',
15 'meta',
16 'param',
17 'source',
18 'track',
19 'wbr',
20];
21
22/**
23 * Reverses the sense of a predicate
24 * @param {(x: any) => Boolean} p predicate
25 * @return {(x: any) => Boolean}
26 */
27const not = p => (...args) => !p(...args);
28
29/**
30 * @typedef IgnoreAttributesForTags
31 * @property {string[]} tags tags on which to ignore the given attributes
32 * @property {string[]} attributes attributes to ignore for the given tags
33 */
34
35/**
36 * @typedef DiffOptions
37 * @property {(string | IgnoreAttributesForTags)[]} [ignoreAttributes]
38 * array of attributes to ignore, when given a string that attribute will be ignored on all tags
39 * when given an object of type `IgnoreAttributesForTags`, you can specify on which tags to ignore which attributes
40 * @property {string[]} [ignoreTags] array of tags to ignore, these tags are stripped from the output
41 * @property {string[]} [ignoreChildren] array of tags whose children to ignore, the children of
42 * these tags are stripped from the output
43 * @property {string[]} [stripEmptyAttributes] array of attributes which should be removed when empty.
44 * Be careful not to add any boolean attributes here (e.g. `hidden`) unless you know what you're doing
45 */
46
47/**
48 * Restructures given HTML string, returning it in a format which can be used for comparison:
49 * - whitespace and newlines are normalized
50 * - tags and attributes are printed on individual lines
51 * - comments, style, script and svg tags are removed
52 * - additional tags and attributes can optionally be ignored
53 *
54 * See README.md for details.
55 *
56 * @example
57 * import getDiffableHTML from '@open-wc/semantic-dom-diff';
58 *
59 * const htmlA = getDiffableHTML(`... some html ...`, { ignoredAttributes: [], ignoredTags: [], ignoreChildren: [] });
60 * const htmlB = getDiffableHTML(`... some html ...`);
61 *
62 * // use regular string comparison to spot the differences
63 * expect(htmlA).to.equal(htmlB);
64 *
65 * @param {Node | string} html
66 * @param {DiffOptions} [options]
67 * @returns {string} html restructured in a diffable format
68 */
69export function getDiffableHTML(html, options = {}) {
70 const ignoreAttributes = /** @type {string[]} */ (options.ignoreAttributes
71 ? options.ignoreAttributes.filter(e => typeof e === 'string')
72 : []);
73 const ignoreAttributesForTags = /** @type {IgnoreAttributesForTags[]} */ (options.ignoreAttributes
74 ? options.ignoreAttributes.filter(e => typeof e !== 'string')
75 : []);
76 const ignoreTags = [...(options.ignoreTags || []), ...DEFAULT_IGNORE_TAGS];
77 const ignoreChildren = options.ignoreChildren || [];
78 const stripEmptyAttributes = options.stripEmptyAttributes || DEFAULT_EMPTY_ATTRS;
79 const escapeAttributes = /(&|")/g;
80 /** @param {string} match */
81 const escapeAttributesFn = match => (match === '&' ? '&' : '"');
82
83 let text = '';
84 let depth = -1;
85 /** @type {Set<Node>} */
86 const handledChildrenForNode = new Set();
87 /** @type {Set<Node>} */
88 const handledNodeStarted = new Set();
89
90 /** @returns {string} */
91 function getIndentation() {
92 return ' '.repeat(depth);
93 }
94
95 /** @param {Text} textNode */
96 function printText(textNode) {
97 const value = textNode.nodeValue.trim();
98
99 if (value !== '') {
100 text += `${getIndentation()}${value}\n`;
101 }
102 }
103
104 /** @param {Node} node */
105 function shouldProcessChildren(node) {
106 const name = node.nodeName.toLowerCase();
107 return (
108 !ignoreTags.includes(name) &&
109 !ignoreChildren.includes(name) &&
110 !handledChildrenForNode.has(node)
111 );
112 }
113
114 /**
115 * An element's classList, sorted, as string
116 * @param {Element} el Element
117 * @return {String}
118 */
119 function getClassListValueString(el) {
120 // @ts-ignore
121 return [...el.classList.values()].sort().join(' ');
122 }
123
124 function shouldStripAttribute({ name, value }) {
125 return stripEmptyAttributes.includes(name) && value.trim() === '';
126 }
127
128 /**
129 * @param {Element} el
130 * @param {Attr} attr
131 */
132 function getAttributeString(el, { name, value }) {
133 if (shouldStripAttribute({ name, value })) return '';
134 if (name === 'class') return ` class="${getClassListValueString(el)}"`;
135 return ` ${name}="${value.replace(escapeAttributes, escapeAttributesFn)}"`;
136 }
137
138 /**
139 * @param {Element} el
140 * @return {(attr: Attr) => Boolean}
141 */
142 function isIgnoredAttribute(el) {
143 return function isIgnoredElementAttibute(attr) {
144 if (ignoreAttributes.includes(attr.name) || shouldStripAttribute(attr)) {
145 return true;
146 }
147
148 return !!ignoreAttributesForTags.find(e => {
149 if (!e.tags || !e.attributes) {
150 throw new Error(
151 `An object entry to ignoreAttributes should contain a 'tags' and an 'attributes' property.`,
152 );
153 }
154 return e.tags.includes(el.nodeName.toLowerCase()) && e.attributes.includes(attr.name);
155 });
156 };
157 }
158
159 const sortAttribute = (a, b) => a.name.localeCompare(b.name);
160
161 /** @param {Element} el */
162 function getAttributesString(el) {
163 let attrStr = '';
164 const attributes = Array.from(el.attributes)
165 .filter(not(isIgnoredAttribute(el)))
166 .sort(sortAttribute);
167
168 if (attributes.length === 1) {
169 attrStr = getAttributeString(el, attributes[0]);
170 } else if (attributes.length > 1) {
171 for (let i = 0; i < attributes.length; i += 1) {
172 attrStr += `\n${getIndentation()} ${getAttributeString(el, attributes[i])}`;
173 }
174 attrStr += `\n${getIndentation()}`;
175 }
176
177 return attrStr;
178 }
179
180 /** @param {Element} el */
181 function getLocalName(el) {
182 // Use original tag if available via data-tag-name attribute (use-case for scoped elements)
183 // See packages/scoped-elements for more info
184 return el.getAttribute('data-tag-name') || el.localName;
185 }
186
187 /** @param {Element} el */
188 function printOpenElement(el) {
189 text += `${getIndentation()}<${getLocalName(el)}${getAttributesString(el)}>\n`;
190 }
191
192 /** @param {Node} node */
193 function onNodeStart(node) {
194 // don't print this node if we should ignore it
195 if (node.nodeName === 'DIFF-CONTAINER' || ignoreTags.includes(node.nodeName.toLowerCase())) {
196 return;
197 }
198
199 // don't print this node if it was already printed, this happens when
200 // crawling upwards after handling children
201 if (handledNodeStarted.has(node)) {
202 return;
203 }
204 handledNodeStarted.add(node);
205
206 if (node instanceof Text) {
207 printText(node);
208 } else if (node instanceof Element) {
209 printOpenElement(node);
210 } else {
211 throw new Error(`Unknown node type: ${node}`);
212 }
213 }
214
215 /** @param {Element} el */
216 function printCloseElement(el) {
217 if (getLocalName(el) === 'diff-container' || VOID_ELEMENTS.includes(getLocalName(el))) {
218 return;
219 }
220
221 text += `${getIndentation()}</${getLocalName(el)}>\n`;
222 }
223
224 /** @param {Node} node */
225 function onNodeEnd(node) {
226 // don't print this node if we should ignore it
227 if (ignoreTags.includes(node.nodeName.toLowerCase())) {
228 return;
229 }
230
231 if (node instanceof Element) {
232 printCloseElement(node);
233 }
234 }
235
236 let container;
237
238 if (typeof html === 'string') {
239 container = document.createElement('diff-container');
240 container.innerHTML = html;
241 depth = -1;
242 } else if (html instanceof Node) {
243 container = html;
244 depth = 0;
245 } else {
246 throw new Error(`Cannot create diffable HTML from: ${html}`);
247 }
248
249 const walker = document.createTreeWalker(
250 container,
251 NodeFilter.SHOW_TEXT + NodeFilter.SHOW_ELEMENT,
252 null,
253 false,
254 );
255
256 // walk the dom and create a diffable string representation
257 while (walker.currentNode) {
258 const current = walker.currentNode;
259 onNodeStart(current);
260
261 // crawl children if we should for this node, and if it has children
262 if (shouldProcessChildren(current) && walker.firstChild()) {
263 depth += 1;
264 } else {
265 // we are done processing this node's children, handle this node's end
266 onNodeEnd(current);
267
268 // move to next sibling
269 const sibling = walker.nextSibling();
270
271 // otherwise move back up to parent node
272 if (!sibling) {
273 depth -= 1;
274
275 const parent = walker.parentNode();
276 // if there is no parent node, we are done
277 if (!parent) {
278 break;
279 }
280
281 // we just processed the parent's children, remember so that we don't
282 // process them again later
283 handledChildrenForNode.add(parent);
284 }
285 }
286 }
287
288 return text;
289}
290
291/**
292 * @param {*} arg
293 * @return {arg is DiffOptions}
294 */
295export function isDiffOptions(arg) {
296 return (
297 arg &&
298 arg !== null &&
299 typeof arg === 'object' &&
300 ('ignoreAttributes' in arg ||
301 'ignoreTags' in arg ||
302 'ignoreChildren' in arg ||
303 'stripEmptyAttributes' in arg)
304 );
305}