Bug 1518618 - Add custom classes to the selectors for matches, attributes and pseudoc...
[gecko.git] / toolkit / modules / BrowserUtils.jsm
blob2042702a6f2ca2db24c79bd921b264f2deec93f1
1 /* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */
2 /* This Source Code Form is subject to the terms of the Mozilla Public
3  * License, v. 2.0. If a copy of the MPL was not distributed with this
4  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 "use strict";
8 var EXPORTED_SYMBOLS = [ "BrowserUtils" ];
10 ChromeUtils.import("resource://gre/modules/Services.jsm");
11 ChromeUtils.defineModuleGetter(this, "PlacesUtils",
12   "resource://gre/modules/PlacesUtils.jsm");
14 var BrowserUtils = {
16   /**
17    * Prints arguments separated by a space and appends a new line.
18    */
19   dumpLn(...args) {
20     for (let a of args)
21       dump(a + " ");
22     dump("\n");
23   },
25   /**
26    * restartApplication: Restarts the application, keeping it in
27    * safe mode if it is already in safe mode.
28    */
29   restartApplication() {
30     let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"]
31                        .createInstance(Ci.nsISupportsPRBool);
32     Services.obs.notifyObservers(cancelQuit, "quit-application-requested", "restart");
33     if (cancelQuit.data) { // The quit request has been canceled.
34       return false;
35     }
36     // if already in safe mode restart in safe mode
37     if (Services.appinfo.inSafeMode) {
38       Services.startup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart);
39       return undefined;
40     }
41     Services.startup.quit(Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart);
42     return undefined;
43   },
45   /**
46    * urlSecurityCheck: JavaScript wrapper for checkLoadURIWithPrincipal
47    * and checkLoadURIStrWithPrincipal.
48    * If |aPrincipal| is not allowed to link to |aURL|, this function throws with
49    * an error message.
50    *
51    * @param aURL
52    *        The URL a page has linked to. This could be passed either as a string
53    *        or as a nsIURI object.
54    * @param aPrincipal
55    *        The principal of the document from which aURL came.
56    * @param aFlags
57    *        Flags to be passed to checkLoadURIStr. If undefined,
58    *        nsIScriptSecurityManager.STANDARD will be passed.
59    */
60   urlSecurityCheck(aURL, aPrincipal, aFlags) {
61     var secMan = Services.scriptSecurityManager;
62     if (aFlags === undefined) {
63       aFlags = secMan.STANDARD;
64     }
66     try {
67       if (aURL instanceof Ci.nsIURI)
68         secMan.checkLoadURIWithPrincipal(aPrincipal, aURL, aFlags);
69       else
70         secMan.checkLoadURIStrWithPrincipal(aPrincipal, aURL, aFlags);
71     } catch (e) {
72       let principalStr = "";
73       try {
74         principalStr = " from " + aPrincipal.URI.spec;
75       } catch (e2) { }
77       throw "Load of " + aURL + principalStr + " denied.";
78     }
79   },
81   /**
82    * Return or create a principal with the codebase of one, and the originAttributes
83    * of an existing principal (e.g. on a docshell, where the originAttributes ought
84    * not to change, that is, we should keep the userContextId, privateBrowsingId,
85    * etc. the same when changing the principal).
86    *
87    * @param principal
88    *        The principal whose codebase/null/system-ness we want.
89    * @param existingPrincipal
90    *        The principal whose originAttributes we want, usually the current
91    *        principal of a docshell.
92    * @return an nsIPrincipal that matches the codebase/null/system-ness of the first
93    *         param, and the originAttributes of the second.
94    */
95   principalWithMatchingOA(principal, existingPrincipal) {
96     // Don't care about system principals:
97     if (principal.isSystemPrincipal) {
98       return principal;
99     }
101     // If the originAttributes already match, just return the principal as-is.
102     if (existingPrincipal.originSuffix == principal.originSuffix) {
103       return principal;
104     }
106     let secMan = Services.scriptSecurityManager;
107     if (principal.isCodebasePrincipal) {
108       return secMan.createCodebasePrincipal(principal.URI, existingPrincipal.originAttributes);
109     }
111     if (principal.isNullPrincipal) {
112       return secMan.createNullPrincipal(existingPrincipal.originAttributes);
113     }
114     throw new Error("Can't change the originAttributes of an expanded principal!");
115   },
117   /**
118    * Constructs a new URI, using nsIIOService.
119    * @param aURL The URI spec.
120    * @param aOriginCharset The charset of the URI.
121    * @param aBaseURI Base URI to resolve aURL, or null.
122    * @return an nsIURI object based on aURL.
123    *
124    * @deprecated Use Services.io.newURI directly instead.
125    */
126   makeURI(aURL, aOriginCharset, aBaseURI) {
127     return Services.io.newURI(aURL, aOriginCharset, aBaseURI);
128   },
130   /**
131    * @deprecated Use Services.io.newFileURI directly instead.
132    */
133   makeFileURI(aFile) {
134     return Services.io.newFileURI(aFile);
135   },
137   makeURIFromCPOW(aCPOWURI) {
138     return Services.io.newURI(aCPOWURI.spec);
139   },
141   /**
142    * For a given DOM element, returns its position in "screen"
143    * coordinates. In a content process, the coordinates returned will
144    * be relative to the left/top of the tab. In the chrome process,
145    * the coordinates are relative to the user's screen.
146    */
147   getElementBoundingScreenRect(aElement) {
148     return this.getElementBoundingRect(aElement, true);
149   },
151   /**
152    * For a given DOM element, returns its position as an offset from the topmost
153    * window. In a content process, the coordinates returned will be relative to
154    * the left/top of the topmost content area. If aInScreenCoords is true,
155    * screen coordinates will be returned instead.
156    */
157   getElementBoundingRect(aElement, aInScreenCoords) {
158     let rect = aElement.getBoundingClientRect();
159     let win = aElement.ownerGlobal;
161     let x = rect.left, y = rect.top;
163     // We need to compensate for any iframes that might shift things
164     // over. We also need to compensate for zooming.
165     let parentFrame = win.frameElement;
166     while (parentFrame) {
167       win = parentFrame.ownerGlobal;
168       let cstyle = win.getComputedStyle(parentFrame);
170       let framerect = parentFrame.getBoundingClientRect();
171       x += framerect.left + parseFloat(cstyle.borderLeftWidth) + parseFloat(cstyle.paddingLeft);
172       y += framerect.top + parseFloat(cstyle.borderTopWidth) + parseFloat(cstyle.paddingTop);
174       parentFrame = win.frameElement;
175     }
177     if (aInScreenCoords) {
178       x += win.mozInnerScreenX;
179       y += win.mozInnerScreenY;
180     }
182     let fullZoom = win.windowUtils.fullZoom;
183     rect = {
184       left: x * fullZoom,
185       top: y * fullZoom,
186       width: rect.width * fullZoom,
187       height: rect.height * fullZoom,
188     };
190     return rect;
191   },
193   onBeforeLinkTraversal(originalTarget, linkURI, linkNode, isAppTab) {
194     // Don't modify non-default targets or targets that aren't in top-level app
195     // tab docshells (isAppTab will be false for app tab subframes).
196     if (originalTarget != "" || !isAppTab)
197       return originalTarget;
199     // External links from within app tabs should always open in new tabs
200     // instead of replacing the app tab's page (Bug 575561)
201     let linkHost;
202     let docHost;
203     try {
204       linkHost = linkURI.host;
205       docHost = linkNode.ownerDocument.documentURIObject.host;
206     } catch (e) {
207       // nsIURI.host can throw for non-nsStandardURL nsIURIs.
208       // If we fail to get either host, just return originalTarget.
209       return originalTarget;
210     }
212     if (docHost == linkHost)
213       return originalTarget;
215     // Special case: ignore "www" prefix if it is part of host string
216     let [longHost, shortHost] =
217       linkHost.length > docHost.length ? [linkHost, docHost] : [docHost, linkHost];
218     if (longHost == "www." + shortHost)
219       return originalTarget;
221     return "_blank";
222   },
224   /**
225    * Map the plugin's name to a filtered version more suitable for UI.
226    *
227    * @param aName The full-length name string of the plugin.
228    * @return the simplified name string.
229    */
230   makeNicePluginName(aName) {
231     if (aName == "Shockwave Flash")
232       return "Adobe Flash";
233     // Regex checks if aName begins with "Java" + non-letter char
234     if (/^Java\W/.exec(aName))
235       return "Java";
237     // Clean up the plugin name by stripping off parenthetical clauses,
238     // trailing version numbers or "plugin".
239     // EG, "Foo Bar (Linux) Plugin 1.23_02" --> "Foo Bar"
240     // Do this by first stripping the numbers, etc. off the end, and then
241     // removing "Plugin" (and then trimming to get rid of any whitespace).
242     // (Otherwise, something like "Java(TM) Plug-in 1.7.0_07" gets mangled)
243     let newName = aName.replace(/\(.*?\)/g, "").
244                         replace(/[\s\d\.\-\_\(\)]+$/, "").
245                         replace(/\bplug-?in\b/i, "").trim();
246     return newName;
247   },
249   /**
250    * Return true if linkNode has a rel="noreferrer" attribute.
251    *
252    * @param linkNode The <a> element, or null.
253    * @return a boolean indicating if linkNode has a rel="noreferrer" attribute.
254    */
255   linkHasNoReferrer(linkNode) {
256     // A null linkNode typically means that we're checking a link that wasn't
257     // provided via an <a> link, like a text-selected URL.  Don't leak
258     // referrer information in this case.
259     if (!linkNode)
260       return true;
262     let rel = linkNode.getAttribute("rel");
263     if (!rel)
264       return false;
266     // The HTML spec says that rel should be split on spaces before looking
267     // for particular rel values.
268     let values = rel.split(/[ \t\r\n\f]/);
269     return values.includes("noreferrer");
270   },
272   /**
273    * Returns true if |mimeType| is text-based, or false otherwise.
274    *
275    * @param mimeType
276    *        The MIME type to check.
277    */
278   mimeTypeIsTextBased(mimeType) {
279     return mimeType.startsWith("text/") ||
280            mimeType.endsWith("+xml") ||
281            mimeType == "application/x-javascript" ||
282            mimeType == "application/javascript" ||
283            mimeType == "application/json" ||
284            mimeType == "application/xml" ||
285            mimeType == "mozilla.application/cached-xul";
286   },
288   /**
289    * Return true if we should FAYT for this node + window (could be CPOW):
290    *
291    * @param elt
292    *        The element that is focused
293    */
294   shouldFastFind(elt) {
295     if (elt) {
296       let win = elt.ownerGlobal;
297       if (elt instanceof win.HTMLInputElement && elt.mozIsTextField(false))
298         return false;
300       if (elt.isContentEditable || win.document.designMode == "on")
301         return false;
303       if (elt instanceof win.HTMLTextAreaElement ||
304           elt instanceof win.HTMLSelectElement ||
305           elt instanceof win.HTMLObjectElement ||
306           elt instanceof win.HTMLEmbedElement)
307         return false;
308     }
310     return true;
311   },
313   /**
314    * Returns true if we can show a find bar, including FAYT, for the specified
315    * document location. The location must not be in a blacklist of specific
316    * "about:" pages for which find is disabled.
317    *
318    * This can be called from the parent process or from content processes.
319    */
320   canFindInPage(location) {
321     return !location.startsWith("about:addons") &&
322            !location.startsWith("about:config") &&
323            !location.startsWith("about:preferences");
324   },
326   _visibleToolbarsMap: new WeakMap(),
328   /**
329    * Return true if any or a specific toolbar that interacts with the content
330    * document is visible.
331    *
332    * @param  {nsIDocShell} docShell The docShell instance that a toolbar should
333    *                                be interacting with
334    * @param  {String}      which    Identifier of a specific toolbar
335    * @return {Boolean}
336    */
337   isToolbarVisible(docShell, which) {
338     let window = this.getRootWindow(docShell);
339     if (!this._visibleToolbarsMap.has(window))
340       return false;
341     let toolbars = this._visibleToolbarsMap.get(window);
342     return !!toolbars && toolbars.has(which);
343   },
345   /**
346    * Sets the --toolbarbutton-button-height CSS property on the closest
347    * toolbar to the provided element. Useful if you need to vertically
348    * center a position:absolute element within a toolbar that uses
349    * -moz-pack-align:stretch, and thus a height which is dependant on
350    * the font-size.
351    *
352    * @param element An element within the toolbar whose height is desired.
353    */
354   async setToolbarButtonHeightProperty(element) {
355     let window = element.ownerGlobal;
356     let dwu = window.windowUtils;
357     let toolbarItem = element;
358     let urlBarContainer = element.closest("#urlbar-container");
359     if (urlBarContainer) {
360       // The stop-reload-button, which is contained in #urlbar-container,
361       // needs to use #urlbar-container to calculate the bounds.
362       toolbarItem = urlBarContainer;
363     }
364     if (!toolbarItem) {
365       return;
366     }
367     let bounds = dwu.getBoundsWithoutFlushing(toolbarItem);
368     if (!bounds.height) {
369       await window.promiseDocumentFlushed(() => {
370         bounds = dwu.getBoundsWithoutFlushing(toolbarItem);
371       });
372     }
373     if (bounds.height) {
374       toolbarItem.style.setProperty("--toolbarbutton-height", bounds.height + "px");
375     }
376   },
378   /**
379    * Track whether a toolbar is visible for a given a docShell.
380    *
381    * @param  {nsIDocShell} docShell  The docShell instance that a toolbar should
382    *                                 be interacting with
383    * @param  {String}      which     Identifier of a specific toolbar
384    * @param  {Boolean}     [visible] Whether the toolbar is visible. Optional,
385    *                                 defaults to `true`.
386    */
387   trackToolbarVisibility(docShell, which, visible = true) {
388     // We have to get the root window object, because XPConnect WrappedNatives
389     // can't be used as WeakMap keys.
390     let window = this.getRootWindow(docShell);
391     let toolbars = this._visibleToolbarsMap.get(window);
392     if (!toolbars) {
393       toolbars = new Set();
394       this._visibleToolbarsMap.set(window, toolbars);
395     }
396     if (!visible)
397       toolbars.delete(which);
398     else
399       toolbars.add(which);
400   },
402   /**
403    * Retrieve the root window object (i.e. the top-most content global) for a
404    * specific docShell object.
405    *
406    * @param  {nsIDocShell} docShell
407    * @return {nsIDOMWindow}
408    */
409   getRootWindow(docShell) {
410     return docShell.sameTypeRootTreeItem.domWindow;
411   },
413   /**
414    * Trim the selection text to a reasonable size and sanitize it to make it
415    * safe for search query input.
416    *
417    * @param aSelection
418    *        The selection text to trim.
419    * @param aMaxLen
420    *        The maximum string length, defaults to a reasonable size if undefined.
421    * @return The trimmed selection text.
422    */
423   trimSelection(aSelection, aMaxLen) {
424     // Selections of more than 150 characters aren't useful.
425     const maxLen = Math.min(aMaxLen || 150, aSelection.length);
427     if (aSelection.length > maxLen) {
428       // only use the first maxLen important chars. see bug 221361
429       let pattern = new RegExp("^(?:\\s*.){0," + maxLen + "}");
430       pattern.test(aSelection);
431       aSelection = RegExp.lastMatch;
432     }
434     aSelection = aSelection.trim().replace(/\s+/g, " ");
436     if (aSelection.length > maxLen) {
437       aSelection = aSelection.substr(0, maxLen);
438     }
440     return aSelection;
441   },
443   /**
444    * Retrieve the text selection details for the given window.
445    *
446    * @param  aTopWindow
447    *         The top window of the element containing the selection.
448    * @param  aCharLen
449    *         The maximum string length for the selection text.
450    * @return The selection details containing the full and trimmed selection text
451    *         and link details for link selections.
452    */
453   getSelectionDetails(aTopWindow, aCharLen) {
454     let focusedWindow = {};
455     let focusedElement = Services.focus.getFocusedElementForWindow(aTopWindow, true, focusedWindow);
456     focusedWindow = focusedWindow.value;
458     let selection = focusedWindow.getSelection();
459     let selectionStr = selection.toString();
460     let fullText;
462     let url;
463     let linkText;
465     // try getting a selected text in text input.
466     if (!selectionStr && focusedElement) {
467       // Don't get the selection for password fields. See bug 565717.
468       if (ChromeUtils.getClassName(focusedElement) === "HTMLTextAreaElement" ||
469           (ChromeUtils.getClassName(focusedElement) === "HTMLInputElement" &&
470            focusedElement.mozIsTextField(true))) {
471         selection = focusedElement.editor.selection;
472         selectionStr = selection.toString();
473       }
474     }
476     let collapsed = selection.isCollapsed;
478     if (selectionStr) {
479       // Have some text, let's figure out if it looks like a URL that isn't
480       // actually a link.
481       linkText = selectionStr.trim();
482       if (/^(?:https?|ftp):/i.test(linkText)) {
483         try {
484           url = this.makeURI(linkText);
485         } catch (ex) {}
486       } else if (/^(?:[a-z\d-]+\.)+[a-z]+$/i.test(linkText)) {
487         // Check if this could be a valid url, just missing the protocol.
488         // Now let's see if this is an intentional link selection. Our guess is
489         // based on whether the selection begins/ends with whitespace or is
490         // preceded/followed by a non-word character.
492         // selection.toString() trims trailing whitespace, so we look for
493         // that explicitly in the first and last ranges.
494         let beginRange = selection.getRangeAt(0);
495         let delimitedAtStart = /^\s/.test(beginRange);
496         if (!delimitedAtStart) {
497           let container = beginRange.startContainer;
498           let offset = beginRange.startOffset;
499           if (container.nodeType == container.TEXT_NODE && offset > 0)
500             delimitedAtStart = /\W/.test(container.textContent[offset - 1]);
501           else
502             delimitedAtStart = true;
503         }
505         let delimitedAtEnd = false;
506         if (delimitedAtStart) {
507           let endRange = selection.getRangeAt(selection.rangeCount - 1);
508           delimitedAtEnd = /\s$/.test(endRange);
509           if (!delimitedAtEnd) {
510             let container = endRange.endContainer;
511             let offset = endRange.endOffset;
512             if (container.nodeType == container.TEXT_NODE &&
513                 offset < container.textContent.length)
514               delimitedAtEnd = /\W/.test(container.textContent[offset]);
515             else
516               delimitedAtEnd = true;
517           }
518         }
520         if (delimitedAtStart && delimitedAtEnd) {
521           try {
522             url = Services.uriFixup.createFixupURI(linkText, Services.uriFixup.FIXUP_FLAG_NONE);
523           } catch (ex) {}
524         }
525       }
526     }
528     if (selectionStr) {
529       // Pass up to 16K through unmolested.  If an add-on needs more, they will
530       // have to use a content script.
531       fullText = selectionStr.substr(0, 16384);
532       selectionStr = this.trimSelection(selectionStr, aCharLen);
533     }
535     if (url && !url.host) {
536       url = null;
537     }
539     return { text: selectionStr, docSelectionIsCollapsed: collapsed, fullText,
540              linkURL: url ? url.spec : null, linkText: url ? linkText : "" };
541   },
543   // Iterates through every docshell in the window and calls PermitUnload.
544   canCloseWindow(window) {
545     let docShell = window.docShell;
546     for (let i = 0; i < docShell.childCount; ++i) {
547       let childShell = docShell.getChildAt(i).QueryInterface(Ci.nsIDocShell);
548       let contentViewer = childShell.contentViewer;
549       if (contentViewer && !contentViewer.permitUnload()) {
550         return false;
551       }
552     }
554     return true;
555   },
557   /**
558    * Replaces %s or %S in the provided url or postData with the given parameter,
559    * acccording to the best charset for the given url.
560    *
561    * @return [url, postData]
562    * @throws if nor url nor postData accept a param, but a param was provided.
563    */
564   async parseUrlAndPostData(url, postData, param) {
565     let hasGETParam = /%s/i.test(url);
566     let decodedPostData = postData ? unescape(postData) : "";
567     let hasPOSTParam = /%s/i.test(decodedPostData);
569     if (!hasGETParam && !hasPOSTParam) {
570       if (param) {
571         // If nor the url, nor postData contain parameters, but a parameter was
572         // provided, return the original input.
573         throw new Error("A param was provided but there's nothing to bind it to");
574       }
575       return [url, postData];
576     }
578     let charset = "";
579     const re = /^(.*)\&mozcharset=([a-zA-Z][_\-a-zA-Z0-9]+)\s*$/;
580     let matches = url.match(re);
581     if (matches) {
582       [, url, charset] = matches;
583     } else {
584       // Try to fetch a charset from History.
585       try {
586         // Will return an empty string if character-set is not found.
587         let pageInfo = await PlacesUtils.history.fetch(url, {includeAnnotations: true});
588         if (pageInfo && pageInfo.annotations.has(PlacesUtils.CHARSET_ANNO)) {
589           charset = pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO);
590         }
591       } catch (ex) {
592         // makeURI() throws if url is invalid.
593         Cu.reportError(ex);
594       }
595     }
597     // encodeURIComponent produces UTF-8, and cannot be used for other charsets.
598     // escape() works in those cases, but it doesn't uri-encode +, @, and /.
599     // Therefore we need to manually replace these ASCII characters by their
600     // encodeURIComponent result, to match the behavior of nsEscape() with
601     // url_XPAlphas.
602     let encodedParam = "";
603     if (charset && charset != "UTF-8") {
604       try {
605         let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
606                           .createInstance(Ci.nsIScriptableUnicodeConverter);
607         converter.charset = charset;
608         encodedParam = converter.ConvertFromUnicode(param) + converter.Finish();
609       } catch (ex) {
610         encodedParam = param;
611       }
612       encodedParam = escape(encodedParam).replace(/[+@\/]+/g, encodeURIComponent);
613     } else {
614       // Default charset is UTF-8
615       encodedParam = encodeURIComponent(param);
616     }
618     url = url.replace(/%s/g, encodedParam).replace(/%S/g, param);
619     if (hasPOSTParam) {
620       postData = decodedPostData.replace(/%s/g, encodedParam)
621                                 .replace(/%S/g, param);
622     }
623     return [url, postData];
624   },
626   /**
627    * Generate a document fragment for a localized string that has DOM
628    * node replacements. This avoids using getFormattedString followed
629    * by assigning to innerHTML. Fluent can probably replace this when
630    * it is in use everywhere.
631    *
632    * @param {Document} doc
633    * @param {String}   msg
634    *                   The string to put replacements in. Fetch from
635    *                   a stringbundle using getString or GetStringFromName,
636    *                   or even an inserted dtd string.
637    * @param {Node|String} nodesOrStrings
638    *                   The replacement items. Can be a mix of Nodes
639    *                   and Strings. However, for correct behaviour, the
640    *                   number of items provided needs to exactly match
641    *                   the number of replacement strings in the l10n string.
642    * @returns {DocumentFragment}
643    *                   A document fragment. In the trivial case (no
644    *                   replacements), this will simply be a fragment with 1
645    *                   child, a text node containing the localized string.
646    */
647   getLocalizedFragment(doc, msg, ...nodesOrStrings) {
648     // Ensure replacement points are indexed:
649     for (let i = 1; i <= nodesOrStrings.length; i++) {
650       if (!msg.includes("%" + i + "$S")) {
651         msg = msg.replace(/%S/, "%" + i + "$S");
652       }
653     }
654     let numberOfInsertionPoints = msg.match(/%\d+\$S/g).length;
655     if (numberOfInsertionPoints != nodesOrStrings.length) {
656       Cu.reportError(`Message has ${numberOfInsertionPoints} insertion points, ` +
657                      `but got ${nodesOrStrings.length} replacement parameters!`);
658     }
660     let fragment = doc.createDocumentFragment();
661     let parts = [msg];
662     let insertionPoint = 1;
663     for (let replacement of nodesOrStrings) {
664       let insertionString = "%" + (insertionPoint++) + "$S";
665       let partIndex = parts.findIndex(part => typeof part == "string" && part.includes(insertionString));
666       if (partIndex == -1) {
667         fragment.appendChild(doc.createTextNode(msg));
668         return fragment;
669       }
671       if (typeof replacement == "string") {
672         parts[partIndex] = parts[partIndex].replace(insertionString, replacement);
673       } else {
674         let [firstBit, lastBit] = parts[partIndex].split(insertionString);
675         parts.splice(partIndex, 1, firstBit, replacement, lastBit);
676       }
677     }
679     // Put everything in a document fragment:
680     for (let part of parts) {
681       if (typeof part == "string") {
682         if (part) {
683           fragment.appendChild(doc.createTextNode(part));
684         }
685       } else {
686         fragment.appendChild(part);
687       }
688     }
689     return fragment;
690   },