Replacing HTML Imports

| permalink | javascript, html

Ah, HTML Import, a lovely feature that was led out the backdoor and shot in cold blood by web app developers.

This was the client side answer to SSI and PHP includes, replacing frames and framesets in an accessible way. In addition, it works offline in the local file system, making it perfect for building HTML documents meant to be viewed locally without setting up a server.

Sadly, because it wasn't a perfect fit for importing JavaScript modules (global namespace pollution and other cons) it was killed.

It became yet another victim of the app centric web.

Bypassing CORS

Due to my goal of using it offline, the only way to sneak external HTML past CORS restriction is to inject them through <script> tags like JSONP.

As for emitting the HTML to the page, you can use document.write or DOM manipulation.

Both methods have pros and cons.

document.write

document.write, while frowned upon and considered archaic, is naturally synchronous and render-blocking while having major limitations:

  1. Cannot be used after the page is loaded, or it will erase existing page content.
  2. Not allowed to be called from a dynamically inserted <script> tag.
  3. Chrome will refuse to execute inserted <script> tags under certain conditions.

DOM manipulation

DOM manipulation is the recommended method, however there are two problems that needs to be solved:

  1. External scripts are async by default but can be turned off via script.async = false.
  2. Non-parser created external stylesheets are non-render-blocking, this is true for both Chrome and Safari, with Firefox being the exception as it doesn't seem to follow the spec for some reason.

Non-render-blocking means that loading linked stylesheet via DOM Manipulation causes FOUC (flash of unstyled content).

How does document.write side step this? It utilize the parser to create elements, so it's no longer considered dynamically inserted.

There IS a solution outlined in the spec - the blocking attribute for <link>.

However, as of today it is only implemented by Chromium based browsers.

So with everything considered, I've crafted the solution below that tries to be as well-rounded as possible.

function _include(html){

  // DOM insertion allow <script> to trigger
  html = document.createRange().createContextualFragment(html);

  // process injected scripts
  html.querySelectorAll('script').forEach((e)=>{
    // mark as injected
    e.setAttribute('data-injected', '');
    // disable async if not explicitly set for external scripts
    if (e.hasAttribute('src') && !e.hasAttribute('async')) e.async = false;
  })

  // target linked stylesheets
  let links = html.querySelectorAll('link[rel=stylesheet]');

  // prevent FOUC before it happens
  if (document.readyState === 'loading') {
    // use document.write for non-injected scripts
    if (!document.currentScript.hasAttribute('data-injected')) {
      links.forEach((e)=>{
        e.replaceWith(writeStyle(e));
      })
    } else {
    // Chromium-only fallback
      links.forEach((e)=>{
        e.setAttribute('blocking','render');
      });
    }
  }

  function writeStyle(e){
    return document.createRange().createContextualFragment(`
      <script>
        document.write(\`${e.outerHTML}\`);
        document.currentScript.remove()
      </script>
    `)
  }

  document.currentScript.replaceWith(html);

}

The only weakness, if you can consider it that, is that in Safari you can only mitigate against FOUC if the linked stylesheet is one import away from the host page, due to document.write limitation.

How to use

As <script> tags are only allowed inside <head> and <body> tags, you still need both in your host page:

<!DOCTYPE html>
<html>
<head>
  <script src=js/include.js></script>
  <script src=includes/head.js></script>
</head>
<body>
  <script src=includes/body.js></script>
</body>
</html>

An example import page could look like so:

// head.js
_include(`
  <title># Replace with H1 text #</title>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/svg+xml" href="img/favicon.svg">
  <link rel="stylesheet" media="screen" href="css/style.css">
  <script>
    document.addEventListener('DOMContentLoaded', ()=>{
      document.title = document.querySelector('h1').innerText;
    })
  </script>
`)

Now, this solution is specially crafted to side step CORS and deal with FOUC with linked stylesheets.

I mainly see this as a way to make local file-based HTML documents easier to author and consumed without having to rely on localhost for CORS, as well as simplifying script loading and dependency management.

Hopefully my solution helped you in some meaningful way, see you next article.