Skip to content

Instantly share code, notes, and snippets.

@sstur
Last active October 8, 2023 04:17
Show Gist options
  • Star 93 You must be signed in to star a gist
  • Fork 35 You must be signed in to fork a gist
  • Save sstur/7379870 to your computer and use it in GitHub Desktop.
Save sstur/7379870 to your computer and use it in GitHub Desktop.
Stringify DOM nodes using JSON (and revive again)
function toJSON(node) {
let propFix = { for: 'htmlFor', class: 'className' };
let specialGetters = {
style: (node) => node.style.cssText,
};
let attrDefaultValues = { style: '' };
let obj = {
nodeType: node.nodeType,
};
if (node.tagName) {
obj.tagName = node.tagName.toLowerCase();
} else if (node.nodeName) {
obj.nodeName = node.nodeName;
}
if (node.nodeValue) {
obj.nodeValue = node.nodeValue;
}
let attrs = node.attributes;
if (attrs) {
let defaultValues = new Map();
for (let i = 0; i < attrs.length; i++) {
let name = attrs[i].nodeName;
defaultValues.set(name, attrDefaultValues[name]);
}
// Add some special cases that might not be included by enumerating
// attributes above. Note: this list is probably not exhaustive.
switch (obj.tagName) {
case 'input': {
if (node.type === 'checkbox' || node.type === 'radio') {
defaultValues.set('checked', false);
} else if (node.type !== 'file') {
// Don't store the value for a file input.
defaultValues.set('value', '');
}
break;
}
case 'option': {
defaultValues.set('selected', false);
break;
}
case 'textarea': {
defaultValues.set('value', '');
break;
}
}
let arr = [];
for (let [name, defaultValue] of defaultValues) {
let propName = propFix[name] || name;
let specialGetter = specialGetters[propName];
let value = specialGetter ? specialGetter(node) : node[propName];
if (value !== defaultValue) {
arr.push([name, value]);
}
}
if (arr.length) {
obj.attributes = arr;
}
}
let childNodes = node.childNodes;
// Don't process children for a textarea since we used `value` above.
if (obj.tagName !== 'textarea' && childNodes && childNodes.length) {
let arr = (obj.childNodes = []);
for (let i = 0; i < childNodes.length; i++) {
arr[i] = toJSON(childNodes[i]);
}
}
return obj;
}
function toDOM(input) {
let obj = typeof input === 'string' ? JSON.parse(input) : input;
let propFix = { for: 'htmlFor', class: 'className' };
let node;
let nodeType = obj.nodeType;
switch (nodeType) {
// ELEMENT_NODE
case 1: {
node = document.createElement(obj.tagName);
if (obj.attributes) {
for (let [attrName, value] of obj.attributes) {
let propName = propFix[attrName] || attrName;
// Note: this will throw if setting the value of an input[type=file]
node[propName] = value;
}
}
break;
}
// TEXT_NODE
case 3: {
return document.createTextNode(obj.nodeValue);
}
// COMMENT_NODE
case 8: {
return document.createComment(obj.nodeValue);
}
// DOCUMENT_FRAGMENT_NODE
case 11: {
node = document.createDocumentFragment();
break;
}
default: {
// Default to an empty fragment node.
return document.createDocumentFragment();
}
}
if (obj.childNodes && obj.childNodes.length) {
for (let childNode of obj.childNodes) {
node.appendChild(toDOM(childNode));
}
}
return node;
}
@jfromell
Copy link

This saved my day, thanks a lot mate!

@tsouk
Copy link

tsouk commented Feb 29, 2016

Mine too! I am reading another site's window with jsdom and sending it to the client as an object passed through toJSON.
Nice one.

@ukari
Copy link

ukari commented Jul 21, 2016

amazing!

@luminaxster
Copy link

thanks! really cool!

@ko22009
Copy link

ko22009 commented Mar 28, 2017

Thanks!!!

Example, sorting https://jsfiddle.net/chefk6uu/
increase, decrease and revert.
But, I found simple solution: https://jsfiddle.net/m6365sc6/

@AutoSponge
Copy link

You need a few more steps to map shadow dom (open) and iframes (non-sandboxed) for serialization but the rehydration gets trickier since those can have state, templates, etc.

@YaroslavGrushko
Copy link

YaroslavGrushko commented Sep 6, 2018

Thanks 👍

@thaycacac
Copy link

So helpful !!!

@rycka1983
Copy link

Thank you a LOT.

@amberdiehl
Copy link

Thank you for sharing this--super neat!

@amberdiehl
Copy link

@sstur I seem to be having trouble with it saving and restoring radio values. The DOM elements are being saved but when restored, the item that was selected no longer has the checked attribute.

@sstur
Copy link
Author

sstur commented Dec 21, 2019

Thanks for the feedback!
Actually, this code is so old, over 6 years now, and hasn't been updated or tested in a a long time, that it doesn't surprise me if there are some edge cases that aren't handled properly. But I'll see if I can repro the issue you mentioned and maybe there's a simple fix.

@amberdiehl
Copy link

@sstur Thanks, so much, for getting back to me!

Actually, I figured out a work around--maybe not even really a work around based on how radios work? When the radio option is clicked, its checked property is set. That is fine if the form is submitted without going through toJSON and then toDOM. After looking at your code a bit I added a small function (to mine) that adds/removes the attribute "checked" as appropriate and that did the trick!

Again, thank you, for sharing your code. The project I'm working on is enabling an app to be offline and the functions you wrote are a godsend as I really wanted to serialize the form and deserialize the form, not the form values.

@sstur
Copy link
Author

sstur commented Dec 22, 2019

I've updated it to better handle form elements and runtime state (such as checkboxes and radio items) and it's now using more modern JS syntax. Hope this helps!

@amberdiehl
Copy link

@sstur, wow! Sweet! Thanks, Dude. I'll give the new version a go!

@tomgallagher
Copy link

tomgallagher commented Mar 28, 2020

This has been really useful to me over the last few months. I've had to change some things, maybe in doing so I've made errors. But I had to pay attention to the size of the JSON file. Has anyone else noticed that all the empty fields on the inline style attribute of the DOM node are saved to json? I changed it to this and it works better.

if (attrs) {
    let attrNames = new Map();
    for (let i = 0; i < attrs.length; i++) {
        attrNames.set(attrs[i].nodeName, undefined);
    }
    let arr = [];
    for (let [name, defaultValue] of attrNames) {
        let value;
        if (name === 'style') {
            //if we query the node with the attribute getter [], we get a CSS style declaration, not a string. 
            //it is possible to get the cssText string though
            value = node.style.cssText;
            //then we can just push that to the array
            arr.push(['style', value]);
        } else {
            value = node[name];
            if (value !== defaultValue) { arr.push([name, value]); }
       }
   }
   if (arr.length) { obj.attributes = arr; }
}

@sstur
Copy link
Author

sstur commented Mar 28, 2020

This is a great point @tomgallagher! I'll update the code above to better handle style properties.

Edit: Done.

@hcharley
Copy link

hcharley commented Dec 14, 2020

For those using Typescript, or want the consts that can be lets be converted:

export function toJson(node: Node & Record<string, unknown>) {
  const propFix = { for: 'htmlFor', class: 'className' };
  const specialGetters = {
    style: (node: HTMLElement) => node.style.cssText,
  };
  const attrDefaultValues = { style: '' };
  const obj: any = {
    nodeType: node.nodeType,
  };
  if (node.tagName) {
    obj.tagName = node.tagName.toLowerCase();
  } else if (node.nodeName) {
    obj.nodeName = node.nodeName;
  }
  if (node.nodeValue) {
    obj.nodeValue = node.nodeValue;
  }

  const attrs = node.attributes;
  if (attrs) {
    const defaultValues = new Map();
    for (let i = 0; i < attrs.length; i++) {
      const name = attrs[i].nodeName;
      defaultValues.set(name, attrDefaultValues[name]);
    }
    // Add some special cases that might not be included by enumerating
    // attributes above. Note: this list is probably not exhaustive.
    switch (obj.tagName) {
      case 'input': {
        if (node.type === 'checkbox' || node.type === 'radio') {
          defaultValues.set('checked', false);
        } else if (node.type !== 'file') {
          // Don't store the value for a file input.
          defaultValues.set('value', '');
        }
        break;
      }
      case 'option': {
        defaultValues.set('selected', false);
        break;
      }
      case 'textarea': {
        defaultValues.set('value', '');
        break;
      }
    }
    const arr = [];
    for (const [name, defaultValue] of defaultValues) {
      const propName = propFix[name] || name;
      const specialGetter = specialGetters[propName];
      const value = specialGetter ? specialGetter(node) : node[propName];
      if (value !== defaultValue) {
        arr.push([name, value]);
      }
    }
    if (arr.length) {
      obj.attributes = arr;
    }
  }
  const childNodes = node.childNodes;
  // Don't process children for a textarea since we used `value` above.
  if (obj.tagName !== 'textarea' && childNodes && childNodes.length) {
    const arr = (obj.childNodes = []);
    for (let i = 0; i < childNodes.length; i++) {
      arr[i] = toJson(childNodes[i] as any);
    }
  }
  return obj;
}

export function toDom(input: Node) {
  const obj = typeof input === 'string' ? JSON.parse(input) : input;
  const propFix = { for: 'htmlFor', class: 'className' };
  let node: Node;
  const nodeType = obj.nodeType;
  switch (nodeType) {
    // ELEMENT_NODE
    case 1: {
      node = document.createElement(obj.tagName);
      if (obj.attributes) {
        for (const [attrName, value] of obj.attributes) {
          const propName = propFix[attrName] || attrName;
          // Note: this will throw if setting the value of an input[type=file]
          node[propName] = value;
        }
      }
      break;
    }
    // TEXT_NODE
    case 3: {
      return document.createTextNode(obj.nodeValue);
    }
    // COMMENT_NODE
    case 8: {
      return document.createComment(obj.nodeValue);
    }
    // DOCUMENT_FRAGMENT_NODE
    case 11: {
      node = document.createDocumentFragment();
      break;
    }
    default: {
      // Default to an empty fragment node.
      return document.createDocumentFragment();
    }
  }
  if (obj.childNodes && obj.childNodes.length) {
    for (const childNode of obj.childNodes) {
      node.appendChild(toDom(childNode));
    }
  }
  return node;
}

@nmrugg
Copy link

nmrugg commented Dec 28, 2020

@sstur could you add a license?

@sstur
Copy link
Author

sstur commented Dec 30, 2020

Licenced as ISC. License follows:


Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.

THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

Source: http://opensource.org/licenses/ISC

@lsdm-lab
Copy link

lsdm-lab commented Apr 24, 2021

HI, I want to convert Html to Json using TOJSON() function but it doesn't capture special attributes like data-glide_el.

 <div class="glide" id="glide_1" >
      <div class="glide__track" data-glide-el="track">
            <div class="hero__center">
              <div class="hero__left">
                <span class="">New Inspiration 2020</span>
                </div>
              <div class="hero__right">
                <div class="hero__img-container">
                  <img class="banner_01" src="./images/banner_01.jpg" alt="banner2" />
                </div>
              </div>
            </div>
          </div>
 </div>

In the same manner, I am don't know how to generate the same HTML from JSON already created in the above step.
where can I provide my root node in toDOM() function so that it can add the generated code to it?

@sstur
Copy link
Author

sstur commented Apr 24, 2021

it doesn't capture special attributes like data-glide-el

True. The reason is that data attributes don't change the way the static HTML displays, so it doesn't make sense to capture them for the original goals of this script.

In most cases, the purpose of data attributes is for JavaScript to do something with the data, but toJSON() also doesn't capture JavaScript or JS-related attributes (like onclick).

So I'd say data attributes are out of scope for this, but I'd be open to hear if there's a good use for adding support.

don't know how to generate the same HTML from JSON

If you need HTML output, I'd suggest you first create DOM and then convert that to HTML using innerHTML or something similar.

where can I provide my root node in toDOM()

toDOM() doesn't require a root node, instead it returns a node. You can attach that node to your document using something like document.body.appendChild(node).

@prakhartiwari0
Copy link

Can we support SVG too?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment