view_JsonRenderer.js

// Copyright © 2013-2017 David Caldwell <david@porkrind.org>
//
// 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.

// Usage
// -----
// The module exports one entry point, the `renderjson()` function. It takes in
// the JSON you want to render as a single argument and returns an HTML
// element.
//
// Options
// -------
// renderjson.set_icons("+", "-")
//   This Allows you to override the disclosure icons.
//
// renderjson.set_show_to_level(level)
//   Pass the number of levels to expand when rendering. The default is 0, which
//   starts with everything collapsed. As a special case, if level is the string
//   "all" then it will start with everything expanded.
//
// renderjson.set_max_string_length(length)
//   Strings will be truncated and made expandable if they are longer than
//   `length`. As a special case, if `length` is the string "none" then
//   there will be no truncation. The default is "none".
//
// renderjson.set_sort_objects(sort_bool)
//   Sort objects by key (default: false)
//
// renderjson.set_replacer(replacer_function)
//   Equivalent of JSON.stringify() `replacer` argument when it's a function
//
// renderjson.set_collapse_msg(collapse_function)
//   Accepts a function (len:number):string => {} where len is the length of the
//   object collapsed.  Function should return the message displayed when a
//   object is collapsed.  The default message is "X items"
//
// renderjson.set_property_list(property_list)
//   Equivalent of JSON.stringify() `replacer` argument when it's an array
//
// Theming
// -------
// The HTML output uses a number of classes so that you can theme it the way
// you'd like:
//     .disclosure    ("⊕", "⊖")
//     .syntax        (",", ":", "{", "}", "[", "]")
//     .string        (includes quotes)
//     .number
//     .boolean
//     .key           (object key)
//     .keyword       ("null", "undefined")
//     .object.syntax ("{", "}")
//     .array.syntax  ("[", "]")

const renderjson = (function () {
  const themetext = function (/* [class, text]+ */) {
    const spans = [];
    while (arguments.length)
      spans.push(
        append(
          span(Array.prototype.shift.call(arguments)),
          text(Array.prototype.shift.call(arguments))
        )
      );
    return spans;
  };
  const append = function (/* el, ... */) {
    const el = Array.prototype.shift.call(arguments);
    for (let a = 0; a < arguments.length; a++)
      if (arguments[a].constructor == Array)
        append.apply(this, [el].concat(arguments[a]));
      else el.appendChild(arguments[a]);
    return el;
  };
  const prepend = function (el, child) {
    el.insertBefore(child, el.firstChild);
    return el;
  };
  const isempty = function (obj, pl) {
    const keys = pl || Object.keys(obj);
    for (const i in keys)
      if (Object.hasOwnProperty.call(obj, keys[i])) return false;
    return true;
  };
  const text = function (txt) {
    return document.createTextNode(txt);
  };

  const span = function (classname) {
    const s = document.createElement('span');
    if (classname) s.className = classname;
    return s;
  };
  const A = function A(txt, classname, callback) {
    const a = document.createElement('a');
    if (classname) a.className = classname;
    a.appendChild(text(txt));
    a.href = '#';
    a.onclick = function (e) {
      callback();
      if (e) e.stopPropagation();
      return false;
    };
    return a;
  };
  /**
   * @typedef {object} OptionsRenderJSON
   * @property {string} hide todo
   * @property {string} show todo
   * @property {number} max_string_length  The maximum length of a string to be displayed.
   * @property {Function} collapse_msg A function that takes in a number and returns a string.
   * @property {Function} replacer A function that takes in a key and a value and returns a value.
   * @property {Array} property_list An array of properties to be displayed.
   * @property {number} show_to_level The number of levels to expand when rendering. The default is 0, which
   starts with everything collapsed. As a special case, if level is the string
   "all" then it will start with everything expanded.
   */
  /**
   * It takes a JSON object and returns a DOM element that renders the JSON object
   *
   * @param {object} json - The JSON object to be rendered
   * @param {string} indent - The indentation string to use.
   * @param {string} dont_indent - If true, don't indent the current line.
   * @param {number} show_level - The number of levels to show.
   * @param {OptionsRenderJSON} options - Options of render
   * @returns {string} A function that takes in a json object and returns a string.
   */
  function _renderjson(json, indent, dont_indent, show_level, options) {
    const my_indent = dont_indent ? '' : indent;

    const disclosure = function (open, placeholder, close, type, builder) {
      let content;
      const empty = span(type);
      const show = function () {
        if (!content)
          append(
            empty.parentNode,
            (content = prepend(
              builder(),
              A(options.hide, 'disclosure', function () {
                content.style.display = 'none';
                empty.style.display = 'inline';
              })
            ))
          );
        content.style.display = 'inline';
        empty.style.display = 'none';
      };
      append(
        empty,
        A(options.show, 'disclosure', show),
        themetext(type + ' syntax', open),
        A(placeholder, null, show),
        themetext(type + ' syntax', close)
      );

      const el = append(span(), text(my_indent.slice(0, -1)), empty);
      if (show_level > 0 && type != 'string') show();
      return el;
    };

    if (json === null) return themetext(null, my_indent, 'keyword', 'null');
    if (json === void 0)
      return themetext(null, my_indent, 'keyword', 'undefined');

    if (typeof json == 'string' && json.length > options.max_string_length)
      return disclosure(
        '"',
        json.substr(0, options.max_string_length) + ' ...',
        '"',
        'string',
        function () {
          return append(
            span('string'),
            themetext(null, my_indent, 'string', JSON.stringify(json))
          );
        }
      );

    if (
      typeof json != 'object' ||
      [Number, String, Boolean, Date].indexOf(json.constructor) >= 0
    )
      // Strings, numbers and bools
      return themetext(null, my_indent, typeof json, JSON.stringify(json));

    if (json.constructor == Array) {
      if (json.length == 0)
        return themetext(null, my_indent, 'array syntax', '[]');

      return disclosure(
        '[',
        options.collapse_msg(json.length),
        ']',
        'array',
        function () {
          const as = append(
            span('array'),
            themetext('array syntax', '[', null, '\n')
          );
          for (let i = 0; i < json.length; i++)
            append(
              as,
              _renderjson(
                options.replacer.call(json, i, json[i]),
                indent + '    ',
                false,
                show_level - 1,
                options
              ),
              i != json.length - 1 ? themetext('syntax', ',') : [],
              text('\n')
            );
          append(as, themetext(null, indent, 'array syntax', ']'));
          return as;
        }
      );
    }

    // Object
    if (isempty(json, options.property_list))
      return themetext(null, my_indent, 'object syntax', '{}');

    return disclosure(
      '{',
      options.collapse_msg(Object.keys(json).length),
      '}',
      'object',
      function () {
        const os = append(
          span('object'),
          themetext('object syntax', '{', null, '\n')
        );
        let last;
        for (const k in json) last = k;
        let keys = options.property_list || Object.keys(json);
        if (options.sort_objects) keys = keys.sort();
        for (const i in keys) {
          const k = keys[i];
          if (!(k in json)) continue;
          append(
            os,
            themetext(
              null,
              indent + '    ',
              'key',
              '"' + k + '"',
              'object syntax',
              ': '
            ),
            _renderjson(
              options.replacer.call(json, k, json[k]),
              indent + '    ',
              true,
              show_level - 1,
              options
            ),
            k != last ? themetext('syntax', ',') : [],
            text('\n')
          );
        }
        append(os, themetext(null, indent, 'object syntax', '}'));
        return os;
      }
    );
  }

  const renderjson = function renderjson(json) {
    /** @type {OptionsRenderJSON} */
    const options = new Object(renderjson.options); // eslint-disable-line no-new-object
    options.replacer =
      typeof options.replacer == 'function'
        ? options.replacer
        : function (k, v) {
            return v;
          };
    const pre = append(
      document.createElement('pre'),
      _renderjson(json, '', false, options.show_to_level, options)
    );
    pre.className = 'renderjson';
    return pre;
  };
  renderjson.set_icons = function (show, hide) {
    renderjson.options.show = show;
    renderjson.options.hide = hide;
    return renderjson;
  };

  renderjson.set_show_to_level = function (level) {
    renderjson.options.show_to_level =
      typeof level == 'string' && level.toLowerCase() === 'all'
        ? Number.MAX_VALUE
        : level;
    return renderjson;
  };
  renderjson.set_max_string_length = function (length) {
    renderjson.options.max_string_length =
      typeof length == 'string' && length.toLowerCase() === 'none'
        ? Number.MAX_VALUE
        : length;
    return renderjson;
  };
  renderjson.set_sort_objects = function (sort_bool) {
    renderjson.options.sort_objects = sort_bool;
    return renderjson;
  };
  renderjson.set_replacer = function (replacer) {
    renderjson.options.replacer = replacer;
    return renderjson;
  };
  renderjson.set_collapse_msg = function (collapse_msg) {
    renderjson.options.collapse_msg = collapse_msg;
    return renderjson;
  };
  renderjson.set_property_list = function (prop_list) {
    renderjson.options.property_list = prop_list;
    return renderjson;
  };
  // Backwards compatiblity. Use set_show_to_level() for new code.
  renderjson.set_show_by_default = function (show) {
    renderjson.options.show_to_level = show ? Number.MAX_VALUE : 0;
    return renderjson;
  };
  renderjson.options = {};
  renderjson.set_icons('⊕', '⊖');
  renderjson.set_show_by_default(false);
  renderjson.set_sort_objects(false);
  renderjson.set_max_string_length('none');
  renderjson.set_replacer(void 0);
  renderjson.set_property_list(void 0);
  renderjson.set_collapse_msg(function (len) {
    return len + ' item' + (len == 1 ? '' : 's');
  });
  return renderjson;
})();

export class JsonRenderer {
  constructor() {
    this.renderjson = renderjson;
  }
}