Repository: andrejewski/himalaya
Pull request #142: Add 'preferDoubleQuoteAttributes' option to stringify
Author: login: GriffinCodes
Created: 2025-04-02T14:41:06Z | Merged: 2025-04-04T07:05:51Z

- [x] Concat `parseDefaults` and `options` to allow only passing in overridden values
- [x] Added test coverage (3 tests)
- [x] Added optional stringify pane to docs/index.html with checkbox for quote behavior

Let me know if there's anything I can improve 🙂 

Closes #94

Linked issue #94: Render attribute tags with double quotes instead of single quotes as default
I'm writing a custom plugin for a markdown parser. I'm using Himalaya to make an AST, append some tags and return the text again to the parser. The problem is that the parser (showdownjs) won't render the html correctly because it want's to receive `<div class="my-class">` but the stringify function returns `<div class='my-class'>`

It could be intentional, but I think it would make sense to render attribute tags with double quotes instead of single quotes as default. Or maybe make it available in options at least?

https://github.com/andrejewski/himalaya/blob/f0b870011b84da362c863dc914157f30d4a603ac/src/stringify.js#L10
Linked issue comments: user: login: andrejewski
created_at: 2018-06-25T16:25:41Z
body: Thanks for the issue, @leifriksheim. HTML allows both single and double quotes so I would open an issue with showdown (though given its longevity I would expect an issue to already exist).

In the meantime, I will consider adding a new option. Right now I see 4 strategies:

- Single default, then double
- Double default, then single
- Single and encode entities
- Double and encode entities

I am not seeing a straight forward solution to the encoding bit. I would like to make us more resilient in the case that both single and double quotes are used in the attribute value if it does not complicate things too much. If it turns out to be hard, it may be worth making a `himalaya-*` package to help in these cases.

In the mean-meantime feel free to copy/paste the stringify function into a util and bend it to your will.
author_association: OWNER
user: login: leifriksheim
created_at: 2018-06-25T21:08:20Z
body: Thank you for a quick answer! I'll copy/paste for now. Love the package btw, exactly what I was looking for! 
author_association: NONE
user: login: andrejewski
created_at: 2018-06-30T18:42:49Z
body: After giving this some thought I think the best approach is to make a new package like `himalaya-stringify` which exports a `stringify` that takes more options. In particular I think to solve this problem a `serializeAttributeValue: (string | null, attributeName: string) => string | null` option.

The reason a brand new package is preferable:
- Room for experimentation in its own realm
- Not bloating Himalaya with options needed by only a fraction of people
- Freedom to get into the weeds and maybe add some dependencies. Himalaya has no non-dev dependencies on purpose because they are a huge management pain
- Freedom not to be blocked by me
- We can always integrate the best parts of the new package into a minor or major release once things are fleshed out

@leifriksheim If you want to take over that endeavor I'm all for it. We can talk about potential APIs to expose and what not for the new package if you want. If that's too much work, someone else may pick it up or I'll keep it in the back of my mind for a future release as a potential enhancement.
author_association: OWNER

Diff:
diff --git a/docs/dist/himalaya.js b/docs/dist/himalaya.js
index 5484d76..909b98f 100644
--- a/docs/dist/himalaya.js
+++ b/docs/dist/himalaya.js
@@ -119,7 +119,8 @@ var parseDefaults = {
   closingTags: _tags.closingTags,
   childlessTags: _tags.childlessTags,
   closingTagAncestorBreakers: _tags.closingTagAncestorBreakers,
-  includePositions: false
+  includePositions: false,
+  preferDoubleQuoteAttributes: false
 };
 exports.parseDefaults = parseDefaults;
 function parse(str) {
@@ -643,15 +644,21 @@ exports["default"] = void 0;
 exports.formatAttributes = formatAttributes;
 exports.toHTML = toHTML;
 var _compat = require("./compat");
-function formatAttributes(attributes) {
+function formatAttributes(attributes, preferDoubleQuoteAttributes) {
   return attributes.reduce(function (attrs, attribute) {
     var key = attribute.key,
       value = attribute.value;
     if (value === null) {
       return "".concat(attrs, " ").concat(key);
     }
-    var quoteEscape = value.indexOf("'") !== -1;
-    var quote = quoteEscape ? '"' : "'";
+    var quote;
+    if (preferDoubleQuoteAttributes) {
+      var quoteEscape = value.indexOf('"') !== -1;
+      quote = quoteEscape ? "'" : '"';
+    } else {
+      var _quoteEscape = value.indexOf("'") !== -1;
+      quote = _quoteEscape ? '"' : "'";
+    }
     return "".concat(attrs, " ").concat(key, "=").concat(quote).concat(value).concat(quote);
   }, '');
 }
@@ -667,7 +674,7 @@ function toHTML(tree, options) {
       attributes = node.attributes,
       children = node.children;
     var isSelfClosing = (0, _compat.arrayIncludes)(options.voidTags, tagName.toLowerCase());
-    return isSelfClosing ? "<".concat(tagName).concat(formatAttributes(attributes), ">") : "<".concat(tagName).concat(formatAttributes(attributes), ">").concat(toHTML(children, options), "</").concat(tagName, ">");
+    return isSelfClosing ? "<".concat(tagName).concat(formatAttributes(attributes, options.preferDoubleQuoteAttributes), ">") : "<".concat(tagName).concat(formatAttributes(attributes, options.preferDoubleQuoteAttributes), ">").concat(toHTML(children, options), "</").concat(tagName, ">");
   }).join('');
 }
 var _default = {
diff --git a/docs/dist/himalaya.js.map b/docs/dist/himalaya.js.map
index a87c18c..86209fa 100644
--- a/docs/dist/himalaya.js.map
+++ b/docs/dist/himalaya.js.map
@@ -1 +1 @@
-{"version":3,"names":[],"mappings":"","sources":["himalaya.js"],"sourcesContent":["(function(f){if(typeof exports===\"object\"&&typeof module!==\"undefined\"){module.exports=f()}else if(typeof define===\"function\"&&define.amd){define([],f)}else{var g;if(typeof window!==\"undefined\"){g=window}else if(typeof global!==\"undefined\"){g=global}else if(typeof self!==\"undefined\"){g=self}else{g=this}g.himalaya = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c=\"function\"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error(\"Cannot find module '\"+i+\"'\");throw a.code=\"MODULE_NOT_FOUND\",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u=\"function\"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){\n\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.arrayIncludes = arrayIncludes;\nexports.endsWith = endsWith;\nexports.isRealNaN = isRealNaN;\nexports.startsWith = startsWith;\nexports.stringIncludes = stringIncludes;\n/*\n  We don't want to include babel-polyfill in our project.\n    - Library authors should be using babel-runtime for non-global polyfilling\n    - Adding babel-polyfill/-runtime increases bundle size significantly\n\n  We will include our polyfill instance methods as regular functions.\n*/\n\nfunction startsWith(str, searchString, position) {\n  return str.substr(position || 0, searchString.length) === searchString;\n}\nfunction endsWith(str, searchString, position) {\n  var index = (position || str.length) - searchString.length;\n  var lastIndex = str.lastIndexOf(searchString, index);\n  return lastIndex !== -1 && lastIndex === index;\n}\nfunction stringIncludes(str, searchString, position) {\n  return str.indexOf(searchString, position || 0) !== -1;\n}\nfunction isRealNaN(x) {\n  return typeof x === 'number' && isNaN(x);\n}\nfunction arrayIncludes(array, searchElement, position) {\n  var len = array.length;\n  if (len === 0) return false;\n  var lookupIndex = position | 0;\n  var isNaNElement = isRealNaN(searchElement);\n  var searchIndex = lookupIndex >= 0 ? lookupIndex : len + lookupIndex;\n  while (searchIndex < len) {\n    var element = array[searchIndex++];\n    if (element === searchElement) return true;\n    if (isNaNElement && isRealNaN(element)) return true;\n  }\n  return false;\n}\n\n},{}],2:[function(require,module,exports){\n\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.format = format;\nexports.formatAttributes = formatAttributes;\nexports.splitHead = splitHead;\nexports.unquote = unquote;\nfunction splitHead(str, sep) {\n  var idx = str.indexOf(sep);\n  if (idx === -1) return [str];\n  return [str.slice(0, idx), str.slice(idx + sep.length)];\n}\nfunction unquote(str) {\n  var car = str.charAt(0);\n  var end = str.length - 1;\n  var isQuoteStart = car === '\"' || car === \"'\";\n  if (isQuoteStart && car === str.charAt(end)) {\n    return str.slice(1, end);\n  }\n  return str;\n}\nfunction format(nodes, options) {\n  return nodes.map(function (node) {\n    var type = node.type;\n    var outputNode = type === 'element' ? {\n      type: type,\n      tagName: node.tagName.toLowerCase(),\n      attributes: formatAttributes(node.attributes),\n      children: format(node.children, options)\n    } : {\n      type: type,\n      content: node.content\n    };\n    if (options.includePositions) {\n      outputNode.position = node.position;\n    }\n    return outputNode;\n  });\n}\nfunction formatAttributes(attributes) {\n  return attributes.map(function (attribute) {\n    var parts = splitHead(attribute.trim(), '=');\n    var key = parts[0];\n    var value = typeof parts[1] === 'string' ? unquote(parts[1]) : null;\n    return {\n      key: key,\n      value: value\n    };\n  });\n}\n\n},{}],3:[function(require,module,exports){\n\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.parse = parse;\nexports.parseDefaults = void 0;\nexports.stringify = stringify;\nvar _lexer = _interopRequireDefault(require(\"./lexer\"));\nvar _parser = _interopRequireDefault(require(\"./parser\"));\nvar _format = require(\"./format\");\nvar _stringify = require(\"./stringify\");\nvar _tags = require(\"./tags\");\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\nvar parseDefaults = {\n  voidTags: _tags.voidTags,\n  closingTags: _tags.closingTags,\n  childlessTags: _tags.childlessTags,\n  closingTagAncestorBreakers: _tags.closingTagAncestorBreakers,\n  includePositions: false\n};\nexports.parseDefaults = parseDefaults;\nfunction parse(str) {\n  var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : parseDefaults;\n  var tokens = (0, _lexer[\"default\"])(str, options);\n  var nodes = (0, _parser[\"default\"])(tokens, options);\n  return (0, _format.format)(nodes, options);\n}\nfunction stringify(ast) {\n  var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : parseDefaults;\n  return (0, _stringify.toHTML)(ast, options);\n}\n\n},{\"./format\":2,\"./lexer\":4,\"./parser\":5,\"./stringify\":6,\"./tags\":7}],4:[function(require,module,exports){\n\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.copyPosition = copyPosition;\nexports[\"default\"] = lexer;\nexports.feedPosition = feedPosition;\nexports.findTextEnd = findTextEnd;\nexports.isWhitespaceChar = isWhitespaceChar;\nexports.jumpPosition = jumpPosition;\nexports.lex = lex;\nexports.lexComment = lexComment;\nexports.lexSkipTag = lexSkipTag;\nexports.lexTag = lexTag;\nexports.lexTagAttributes = lexTagAttributes;\nexports.lexTagName = lexTagName;\nexports.lexText = lexText;\nexports.makeInitialPosition = makeInitialPosition;\nvar _compat = require(\"./compat\");\nfunction feedPosition(position, str, len) {\n  var start = position.index;\n  var end = position.index = start + len;\n  for (var i = start; i < end; i++) {\n    var _char = str.charAt(i);\n    if (_char === '\\n') {\n      position.line++;\n      position.column = 0;\n    } else {\n      position.column++;\n    }\n  }\n}\nfunction jumpPosition(position, str, end) {\n  var len = end - position.index;\n  return feedPosition(position, str, len);\n}\nfunction makeInitialPosition() {\n  return {\n    index: 0,\n    column: 0,\n    line: 0\n  };\n}\nfunction copyPosition(position) {\n  return {\n    index: position.index,\n    line: position.line,\n    column: position.column\n  };\n}\nfunction lexer(str, options) {\n  var state = {\n    str: str,\n    options: options,\n    position: makeInitialPosition(),\n    tokens: []\n  };\n  lex(state);\n  return state.tokens;\n}\nfunction lex(state) {\n  var str = state.str,\n    childlessTags = state.options.childlessTags;\n  var len = str.length;\n  while (state.position.index < len) {\n    var start = state.position.index;\n    lexText(state);\n    if (state.position.index === start) {\n      var isComment = (0, _compat.startsWith)(str, '!--', start + 1);\n      if (isComment) {\n        lexComment(state);\n      } else {\n        var tagName = lexTag(state);\n        var safeTag = tagName.toLowerCase();\n        if ((0, _compat.arrayIncludes)(childlessTags, safeTag)) {\n          lexSkipTag(tagName, state);\n        }\n      }\n    }\n  }\n}\nvar alphanumeric = /[A-Za-z0-9]/;\nfunction findTextEnd(str, index) {\n  while (true) {\n    var textEnd = str.indexOf('<', index);\n    if (textEnd === -1) {\n      return textEnd;\n    }\n    var _char2 = str.charAt(textEnd + 1);\n    if (_char2 === '/' || _char2 === '!' || alphanumeric.test(_char2)) {\n      return textEnd;\n    }\n    index = textEnd + 1;\n  }\n}\nfunction lexText(state) {\n  var type = 'text';\n  var str = state.str,\n    position = state.position;\n  var textEnd = findTextEnd(str, position.index);\n  if (textEnd === position.index) return;\n  if (textEnd === -1) {\n    textEnd = str.length;\n  }\n  var start = copyPosition(position);\n  var content = str.slice(position.index, textEnd);\n  jumpPosition(position, str, textEnd);\n  var end = copyPosition(position);\n  state.tokens.push({\n    type: type,\n    content: content,\n    position: {\n      start: start,\n      end: end\n    }\n  });\n}\nfunction lexComment(state) {\n  var str = state.str,\n    position = state.position;\n  var start = copyPosition(position);\n  feedPosition(position, str, 4); // \"<!--\".length\n  var contentEnd = str.indexOf('-->', position.index);\n  var commentEnd = contentEnd + 3; // \"-->\".length\n  if (contentEnd === -1) {\n    contentEnd = commentEnd = str.length;\n  }\n  var content = str.slice(position.index, contentEnd);\n  jumpPosition(position, str, commentEnd);\n  state.tokens.push({\n    type: 'comment',\n    content: content,\n    position: {\n      start: start,\n      end: copyPosition(position)\n    }\n  });\n}\nfunction lexTag(state) {\n  var str = state.str,\n    position = state.position;\n  {\n    var secondChar = str.charAt(position.index + 1);\n    var close = secondChar === '/';\n    var start = copyPosition(position);\n    feedPosition(position, str, close ? 2 : 1);\n    state.tokens.push({\n      type: 'tag-start',\n      close: close,\n      position: {\n        start: start\n      }\n    });\n  }\n  var tagName = lexTagName(state);\n  lexTagAttributes(state);\n  {\n    var firstChar = str.charAt(position.index);\n    var _close = firstChar === '/';\n    feedPosition(position, str, _close ? 2 : 1);\n    var end = copyPosition(position);\n    state.tokens.push({\n      type: 'tag-end',\n      close: _close,\n      position: {\n        end: end\n      }\n    });\n  }\n  return tagName;\n}\n\n// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#special-white-space\nvar whitespace = /\\s/;\nfunction isWhitespaceChar(_char3) {\n  return whitespace.test(_char3);\n}\nfunction lexTagName(state) {\n  var str = state.str,\n    position = state.position;\n  var len = str.length;\n  var start = position.index;\n  while (start < len) {\n    var _char4 = str.charAt(start);\n    var isTagChar = !(isWhitespaceChar(_char4) || _char4 === '/' || _char4 === '>');\n    if (isTagChar) break;\n    start++;\n  }\n  var end = start + 1;\n  while (end < len) {\n    var _char5 = str.charAt(end);\n    var _isTagChar = !(isWhitespaceChar(_char5) || _char5 === '/' || _char5 === '>');\n    if (!_isTagChar) break;\n    end++;\n  }\n  jumpPosition(position, str, end);\n  var tagName = str.slice(start, end);\n  state.tokens.push({\n    type: 'tag',\n    content: tagName\n  });\n  return tagName;\n}\nfunction lexTagAttributes(state) {\n  var str = state.str,\n    position = state.position,\n    tokens = state.tokens;\n  var cursor = position.index;\n  var quote = null; // null, single-, or double-quote\n  var wordBegin = cursor; // index of word start\n  var words = []; // \"key\", \"key=value\", \"key='value'\", etc\n  var len = str.length;\n  while (cursor < len) {\n    var _char6 = str.charAt(cursor);\n    if (quote) {\n      var isQuoteEnd = _char6 === quote;\n      if (isQuoteEnd) {\n        quote = null;\n      }\n      cursor++;\n      continue;\n    }\n    var isTagEnd = _char6 === '/' || _char6 === '>';\n    if (isTagEnd) {\n      if (cursor !== wordBegin) {\n        words.push(str.slice(wordBegin, cursor));\n      }\n      break;\n    }\n    var isWordEnd = isWhitespaceChar(_char6);\n    if (isWordEnd) {\n      if (cursor !== wordBegin) {\n        words.push(str.slice(wordBegin, cursor));\n      }\n      wordBegin = cursor + 1;\n      cursor++;\n      continue;\n    }\n    var isQuoteStart = _char6 === \"'\" || _char6 === '\"';\n    if (isQuoteStart) {\n      quote = _char6;\n      cursor++;\n      continue;\n    }\n    cursor++;\n  }\n  jumpPosition(position, str, cursor);\n  var wLen = words.length;\n  var type = 'attribute';\n  for (var i = 0; i < wLen; i++) {\n    var word = words[i];\n    var isNotPair = word.indexOf('=') === -1;\n    if (isNotPair) {\n      var secondWord = words[i + 1];\n      if (secondWord && (0, _compat.startsWith)(secondWord, '=')) {\n        if (secondWord.length > 1) {\n          var newWord = word + secondWord;\n          tokens.push({\n            type: type,\n            content: newWord\n          });\n          i += 1;\n          continue;\n        }\n        var thirdWord = words[i + 2];\n        i += 1;\n        if (thirdWord) {\n          var _newWord = word + '=' + thirdWord;\n          tokens.push({\n            type: type,\n            content: _newWord\n          });\n          i += 1;\n          continue;\n        }\n      }\n    }\n    if ((0, _compat.endsWith)(word, '=')) {\n      var _secondWord = words[i + 1];\n      if (_secondWord && !(0, _compat.stringIncludes)(_secondWord, '=')) {\n        var _newWord2 = word + _secondWord;\n        tokens.push({\n          type: type,\n          content: _newWord2\n        });\n        i += 1;\n        continue;\n      }\n      var _newWord3 = word.slice(0, -1);\n      tokens.push({\n        type: type,\n        content: _newWord3\n      });\n      continue;\n    }\n    tokens.push({\n      type: type,\n      content: word\n    });\n  }\n}\nvar push = [].push;\nfunction lexSkipTag(tagName, state) {\n  var str = state.str,\n    position = state.position,\n    tokens = state.tokens;\n  var safeTagName = tagName.toLowerCase();\n  var len = str.length;\n  var index = position.index;\n  while (index < len) {\n    var nextTag = str.indexOf('</', index);\n    if (nextTag === -1) {\n      lexText(state);\n      break;\n    }\n    var tagStartPosition = copyPosition(position);\n    jumpPosition(tagStartPosition, str, nextTag);\n    var tagState = {\n      str: str,\n      position: tagStartPosition,\n      tokens: []\n    };\n    var name = lexTag(tagState);\n    if (safeTagName !== name.toLowerCase()) {\n      index = tagState.position.index;\n      continue;\n    }\n    if (nextTag !== position.index) {\n      var textStart = copyPosition(position);\n      jumpPosition(position, str, nextTag);\n      tokens.push({\n        type: 'text',\n        content: str.slice(textStart.index, nextTag),\n        position: {\n          start: textStart,\n          end: copyPosition(position)\n        }\n      });\n    }\n    push.apply(tokens, tagState.tokens);\n    jumpPosition(position, str, tagState.position.index);\n    break;\n  }\n}\n\n},{\"./compat\":1}],5:[function(require,module,exports){\n\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports[\"default\"] = parser;\nexports.hasTerminalParent = hasTerminalParent;\nexports.parse = parse;\nexports.rewindStack = rewindStack;\nvar _compat = require(\"./compat\");\nfunction parser(tokens, options) {\n  var root = {\n    tagName: null,\n    children: []\n  };\n  var state = {\n    tokens: tokens,\n    options: options,\n    cursor: 0,\n    stack: [root]\n  };\n  parse(state);\n  return root.children;\n}\nfunction hasTerminalParent(tagName, stack, terminals) {\n  var tagParents = terminals[tagName];\n  if (tagParents) {\n    var currentIndex = stack.length - 1;\n    while (currentIndex >= 0) {\n      var parentTagName = stack[currentIndex].tagName;\n      if (parentTagName === tagName) {\n        break;\n      }\n      if ((0, _compat.arrayIncludes)(tagParents, parentTagName)) {\n        return true;\n      }\n      currentIndex--;\n    }\n  }\n  return false;\n}\nfunction rewindStack(stack, newLength, childrenEndPosition, endPosition) {\n  stack[newLength].position.end = endPosition;\n  for (var i = newLength + 1, len = stack.length; i < len; i++) {\n    stack[i].position.end = childrenEndPosition;\n  }\n  stack.splice(newLength);\n}\nfunction parse(state) {\n  var tokens = state.tokens,\n    options = state.options;\n  var stack = state.stack;\n  var nodes = stack[stack.length - 1].children;\n  var len = tokens.length;\n  var cursor = state.cursor;\n  while (cursor < len) {\n    var token = tokens[cursor];\n    if (token.type !== 'tag-start') {\n      nodes.push(token);\n      cursor++;\n      continue;\n    }\n    var tagToken = tokens[++cursor];\n    cursor++;\n    var tagName = tagToken.content.toLowerCase();\n    if (token.close) {\n      var index = stack.length;\n      var shouldRewind = false;\n      while (--index > -1) {\n        if (stack[index].tagName === tagName) {\n          shouldRewind = true;\n          break;\n        }\n      }\n      while (cursor < len) {\n        var endToken = tokens[cursor];\n        if (endToken.type !== 'tag-end') break;\n        cursor++;\n      }\n      if (shouldRewind) {\n        rewindStack(stack, index, token.position.start, tokens[cursor - 1].position.end);\n        break;\n      } else {\n        continue;\n      }\n    }\n    var isClosingTag = (0, _compat.arrayIncludes)(options.closingTags, tagName);\n    var shouldRewindToAutoClose = isClosingTag;\n    if (shouldRewindToAutoClose) {\n      var terminals = options.closingTagAncestorBreakers;\n      shouldRewindToAutoClose = !hasTerminalParent(tagName, stack, terminals);\n    }\n    if (shouldRewindToAutoClose) {\n      // rewind the stack to just above the previous\n      // closing tag of the same name\n      var currentIndex = stack.length - 1;\n      while (currentIndex > 0) {\n        if (tagName === stack[currentIndex].tagName) {\n          rewindStack(stack, currentIndex, token.position.start, token.position.start);\n          var previousIndex = currentIndex - 1;\n          nodes = stack[previousIndex].children;\n          break;\n        }\n        currentIndex = currentIndex - 1;\n      }\n    }\n    var attributes = [];\n    var attrToken = void 0;\n    while (cursor < len) {\n      attrToken = tokens[cursor];\n      if (attrToken.type === 'tag-end') break;\n      attributes.push(attrToken.content);\n      cursor++;\n    }\n    cursor++;\n    var children = [];\n    var position = {\n      start: token.position.start,\n      end: attrToken.position.end\n    };\n    var elementNode = {\n      type: 'element',\n      tagName: tagToken.content,\n      attributes: attributes,\n      children: children,\n      position: position\n    };\n    nodes.push(elementNode);\n    var hasChildren = !(attrToken.close || (0, _compat.arrayIncludes)(options.voidTags, tagName));\n    if (hasChildren) {\n      var size = stack.push({\n        tagName: tagName,\n        children: children,\n        position: position\n      });\n      var innerState = {\n        tokens: tokens,\n        options: options,\n        cursor: cursor,\n        stack: stack\n      };\n      parse(innerState);\n      cursor = innerState.cursor;\n      var rewoundInElement = stack.length === size;\n      if (rewoundInElement) {\n        elementNode.position.end = tokens[cursor - 1].position.end;\n      }\n    }\n  }\n  state.cursor = cursor;\n}\n\n},{\"./compat\":1}],6:[function(require,module,exports){\n\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports[\"default\"] = void 0;\nexports.formatAttributes = formatAttributes;\nexports.toHTML = toHTML;\nvar _compat = require(\"./compat\");\nfunction formatAttributes(attributes) {\n  return attributes.reduce(function (attrs, attribute) {\n    var key = attribute.key,\n      value = attribute.value;\n    if (value === null) {\n      return \"\".concat(attrs, \" \").concat(key);\n    }\n    var quoteEscape = value.indexOf(\"'\") !== -1;\n    var quote = quoteEscape ? '\"' : \"'\";\n    return \"\".concat(attrs, \" \").concat(key, \"=\").concat(quote).concat(value).concat(quote);\n  }, '');\n}\nfunction toHTML(tree, options) {\n  return tree.map(function (node) {\n    if (node.type === 'text') {\n      return node.content;\n    }\n    if (node.type === 'comment') {\n      return \"<!--\".concat(node.content, \"-->\");\n    }\n    var tagName = node.tagName,\n      attributes = node.attributes,\n      children = node.children;\n    var isSelfClosing = (0, _compat.arrayIncludes)(options.voidTags, tagName.toLowerCase());\n    return isSelfClosing ? \"<\".concat(tagName).concat(formatAttributes(attributes), \">\") : \"<\".concat(tagName).concat(formatAttributes(attributes), \">\").concat(toHTML(children, options), \"</\").concat(tagName, \">\");\n  }).join('');\n}\nvar _default = {\n  toHTML: toHTML\n};\nexports[\"default\"] = _default;\n\n},{\"./compat\":1}],7:[function(require,module,exports){\n\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.voidTags = exports.closingTags = exports.closingTagAncestorBreakers = exports.childlessTags = void 0;\n/*\n  Tags which contain arbitary non-parsed content\n  For example: <script> JavaScript should not be parsed\n*/\nvar childlessTags = ['style', 'script', 'template'];\n\n/*\n  Tags which auto-close because they cannot be nested\n  For example: <p>Outer<p>Inner is <p>Outer</p><p>Inner</p>\n*/\nexports.childlessTags = childlessTags;\nvar closingTags = ['html', 'head', 'body', 'p', 'dt', 'dd', 'li', 'option', 'thead', 'th', 'tbody', 'tr', 'td', 'tfoot', 'colgroup'];\n\n/*\n  Closing tags which have ancestor tags which\n  may exist within them which prevent the\n  closing tag from auto-closing.\n  For example: in <li><ul><li></ul></li>,\n  the top-level <li> should not auto-close.\n*/\nexports.closingTags = closingTags;\nvar closingTagAncestorBreakers = {\n  li: ['ul', 'ol', 'menu'],\n  dt: ['dl'],\n  dd: ['dl'],\n  tbody: ['table'],\n  thead: ['table'],\n  tfoot: ['table'],\n  tr: ['table'],\n  td: ['table']\n};\n\n/*\n  Tags which do not need the closing tag\n  For example: <img> does not need </img>\n*/\nexports.closingTagAncestorBreakers = closingTagAncestorBreakers;\nvar voidTags = ['!doctype', 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'];\nexports.voidTags = voidTags;\n\n},{}]},{},[3])(3)\n});\n"],"file":"himalaya.js"}
\ No newline at end of file
+{"version":3,"names":[],"mappings":"","sources":["himalaya.js"],"sourcesContent":["(function(f){if(typeof exports===\"object\"&&typeof module!==\"undefined\"){module.exports=f()}else if(typeof define===\"function\"&&define.amd){define([],f)}else{var g;if(typeof window!==\"undefined\"){g=window}else if(typeof global!==\"undefined\"){g=global}else if(typeof self!==\"undefined\"){g=self}else{g=this}g.himalaya = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c=\"function\"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error(\"Cannot find module '\"+i+\"'\");throw a.code=\"MODULE_NOT_FOUND\",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u=\"function\"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){\n\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.arrayIncludes = arrayIncludes;\nexports.endsWith = endsWith;\nexports.isRealNaN = isRealNaN;\nexports.startsWith = startsWith;\nexports.stringIncludes = stringIncludes;\n/*\r\n  We don't want to include babel-polyfill in our project.\r\n    - Library authors should be using babel-runtime for non-global polyfilling\r\n    - Adding babel-polyfill/-runtime increases bundle size significantly\r\n\r\n  We will include our polyfill instance methods as regular functions.\r\n*/\n\nfunction startsWith(str, searchString, position) {\n  return str.substr(position || 0, searchString.length) === searchString;\n}\nfunction endsWith(str, searchString, position) {\n  var index = (position || str.length) - searchString.length;\n  var lastIndex = str.lastIndexOf(searchString, index);\n  return lastIndex !== -1 && lastIndex === index;\n}\nfunction stringIncludes(str, searchString, position) {\n  return str.indexOf(searchString, position || 0) !== -1;\n}\nfunction isRealNaN(x) {\n  return typeof x === 'number' && isNaN(x);\n}\nfunction arrayIncludes(array, searchElement, position) {\n  var len = array.length;\n  if (len === 0) return false;\n  var lookupIndex = position | 0;\n  var isNaNElement = isRealNaN(searchElement);\n  var searchIndex = lookupIndex >= 0 ? lookupIndex : len + lookupIndex;\n  while (searchIndex < len) {\n    var element = array[searchIndex++];\n    if (element === searchElement) return true;\n    if (isNaNElement && isRealNaN(element)) return true;\n  }\n  return false;\n}\n\n},{}],2:[function(require,module,exports){\n\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.format = format;\nexports.formatAttributes = formatAttributes;\nexports.splitHead = splitHead;\nexports.unquote = unquote;\nfunction splitHead(str, sep) {\n  var idx = str.indexOf(sep);\n  if (idx === -1) return [str];\n  return [str.slice(0, idx), str.slice(idx + sep.length)];\n}\nfunction unquote(str) {\n  var car = str.charAt(0);\n  var end = str.length - 1;\n  var isQuoteStart = car === '\"' || car === \"'\";\n  if (isQuoteStart && car === str.charAt(end)) {\n    return str.slice(1, end);\n  }\n  return str;\n}\nfunction format(nodes, options) {\n  return nodes.map(function (node) {\n    var type = node.type;\n    var outputNode = type === 'element' ? {\n      type: type,\n      tagName: node.tagName.toLowerCase(),\n      attributes: formatAttributes(node.attributes),\n      children: format(node.children, options)\n    } : {\n      type: type,\n      content: node.content\n    };\n    if (options.includePositions) {\n      outputNode.position = node.position;\n    }\n    return outputNode;\n  });\n}\nfunction formatAttributes(attributes) {\n  return attributes.map(function (attribute) {\n    var parts = splitHead(attribute.trim(), '=');\n    var key = parts[0];\n    var value = typeof parts[1] === 'string' ? unquote(parts[1]) : null;\n    return {\n      key: key,\n      value: value\n    };\n  });\n}\n\n},{}],3:[function(require,module,exports){\n\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.parse = parse;\nexports.parseDefaults = void 0;\nexports.stringify = stringify;\nvar _lexer = _interopRequireDefault(require(\"./lexer\"));\nvar _parser = _interopRequireDefault(require(\"./parser\"));\nvar _format = require(\"./format\");\nvar _stringify = require(\"./stringify\");\nvar _tags = require(\"./tags\");\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\nvar parseDefaults = {\n  voidTags: _tags.voidTags,\n  closingTags: _tags.closingTags,\n  childlessTags: _tags.childlessTags,\n  closingTagAncestorBreakers: _tags.closingTagAncestorBreakers,\n  includePositions: false,\n  preferDoubleQuoteAttributes: false\n};\nexports.parseDefaults = parseDefaults;\nfunction parse(str) {\n  var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : parseDefaults;\n  var tokens = (0, _lexer[\"default\"])(str, options);\n  var nodes = (0, _parser[\"default\"])(tokens, options);\n  return (0, _format.format)(nodes, options);\n}\nfunction stringify(ast) {\n  var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : parseDefaults;\n  return (0, _stringify.toHTML)(ast, options);\n}\n\n},{\"./format\":2,\"./lexer\":4,\"./parser\":5,\"./stringify\":6,\"./tags\":7}],4:[function(require,module,exports){\n\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.copyPosition = copyPosition;\nexports[\"default\"] = lexer;\nexports.feedPosition = feedPosition;\nexports.findTextEnd = findTextEnd;\nexports.isWhitespaceChar = isWhitespaceChar;\nexports.jumpPosition = jumpPosition;\nexports.lex = lex;\nexports.lexComment = lexComment;\nexports.lexSkipTag = lexSkipTag;\nexports.lexTag = lexTag;\nexports.lexTagAttributes = lexTagAttributes;\nexports.lexTagName = lexTagName;\nexports.lexText = lexText;\nexports.makeInitialPosition = makeInitialPosition;\nvar _compat = require(\"./compat\");\nfunction feedPosition(position, str, len) {\n  var start = position.index;\n  var end = position.index = start + len;\n  for (var i = start; i < end; i++) {\n    var _char = str.charAt(i);\n    if (_char === '\\n') {\n      position.line++;\n      position.column = 0;\n    } else {\n      position.column++;\n    }\n  }\n}\nfunction jumpPosition(position, str, end) {\n  var len = end - position.index;\n  return feedPosition(position, str, len);\n}\nfunction makeInitialPosition() {\n  return {\n    index: 0,\n    column: 0,\n    line: 0\n  };\n}\nfunction copyPosition(position) {\n  return {\n    index: position.index,\n    line: position.line,\n    column: position.column\n  };\n}\nfunction lexer(str, options) {\n  var state = {\n    str: str,\n    options: options,\n    position: makeInitialPosition(),\n    tokens: []\n  };\n  lex(state);\n  return state.tokens;\n}\nfunction lex(state) {\n  var str = state.str,\n    childlessTags = state.options.childlessTags;\n  var len = str.length;\n  while (state.position.index < len) {\n    var start = state.position.index;\n    lexText(state);\n    if (state.position.index === start) {\n      var isComment = (0, _compat.startsWith)(str, '!--', start + 1);\n      if (isComment) {\n        lexComment(state);\n      } else {\n        var tagName = lexTag(state);\n        var safeTag = tagName.toLowerCase();\n        if ((0, _compat.arrayIncludes)(childlessTags, safeTag)) {\n          lexSkipTag(tagName, state);\n        }\n      }\n    }\n  }\n}\nvar alphanumeric = /[A-Za-z0-9]/;\nfunction findTextEnd(str, index) {\n  while (true) {\n    var textEnd = str.indexOf('<', index);\n    if (textEnd === -1) {\n      return textEnd;\n    }\n    var _char2 = str.charAt(textEnd + 1);\n    if (_char2 === '/' || _char2 === '!' || alphanumeric.test(_char2)) {\n      return textEnd;\n    }\n    index = textEnd + 1;\n  }\n}\nfunction lexText(state) {\n  var type = 'text';\n  var str = state.str,\n    position = state.position;\n  var textEnd = findTextEnd(str, position.index);\n  if (textEnd === position.index) return;\n  if (textEnd === -1) {\n    textEnd = str.length;\n  }\n  var start = copyPosition(position);\n  var content = str.slice(position.index, textEnd);\n  jumpPosition(position, str, textEnd);\n  var end = copyPosition(position);\n  state.tokens.push({\n    type: type,\n    content: content,\n    position: {\n      start: start,\n      end: end\n    }\n  });\n}\nfunction lexComment(state) {\n  var str = state.str,\n    position = state.position;\n  var start = copyPosition(position);\n  feedPosition(position, str, 4); // \"<!--\".length\n  var contentEnd = str.indexOf('-->', position.index);\n  var commentEnd = contentEnd + 3; // \"-->\".length\n  if (contentEnd === -1) {\n    contentEnd = commentEnd = str.length;\n  }\n  var content = str.slice(position.index, contentEnd);\n  jumpPosition(position, str, commentEnd);\n  state.tokens.push({\n    type: 'comment',\n    content: content,\n    position: {\n      start: start,\n      end: copyPosition(position)\n    }\n  });\n}\nfunction lexTag(state) {\n  var str = state.str,\n    position = state.position;\n  {\n    var secondChar = str.charAt(position.index + 1);\n    var close = secondChar === '/';\n    var start = copyPosition(position);\n    feedPosition(position, str, close ? 2 : 1);\n    state.tokens.push({\n      type: 'tag-start',\n      close: close,\n      position: {\n        start: start\n      }\n    });\n  }\n  var tagName = lexTagName(state);\n  lexTagAttributes(state);\n  {\n    var firstChar = str.charAt(position.index);\n    var _close = firstChar === '/';\n    feedPosition(position, str, _close ? 2 : 1);\n    var end = copyPosition(position);\n    state.tokens.push({\n      type: 'tag-end',\n      close: _close,\n      position: {\n        end: end\n      }\n    });\n  }\n  return tagName;\n}\n\n// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#special-white-space\nvar whitespace = /\\s/;\nfunction isWhitespaceChar(_char3) {\n  return whitespace.test(_char3);\n}\nfunction lexTagName(state) {\n  var str = state.str,\n    position = state.position;\n  var len = str.length;\n  var start = position.index;\n  while (start < len) {\n    var _char4 = str.charAt(start);\n    var isTagChar = !(isWhitespaceChar(_char4) || _char4 === '/' || _char4 === '>');\n    if (isTagChar) break;\n    start++;\n  }\n  var end = start + 1;\n  while (end < len) {\n    var _char5 = str.charAt(end);\n    var _isTagChar = !(isWhitespaceChar(_char5) || _char5 === '/' || _char5 === '>');\n    if (!_isTagChar) break;\n    end++;\n  }\n  jumpPosition(position, str, end);\n  var tagName = str.slice(start, end);\n  state.tokens.push({\n    type: 'tag',\n    content: tagName\n  });\n  return tagName;\n}\nfunction lexTagAttributes(state) {\n  var str = state.str,\n    position = state.position,\n    tokens = state.tokens;\n  var cursor = position.index;\n  var quote = null; // null, single-, or double-quote\n  var wordBegin = cursor; // index of word start\n  var words = []; // \"key\", \"key=value\", \"key='value'\", etc\n  var len = str.length;\n  while (cursor < len) {\n    var _char6 = str.charAt(cursor);\n    if (quote) {\n      var isQuoteEnd = _char6 === quote;\n      if (isQuoteEnd) {\n        quote = null;\n      }\n      cursor++;\n      continue;\n    }\n    var isTagEnd = _char6 === '/' || _char6 === '>';\n    if (isTagEnd) {\n      if (cursor !== wordBegin) {\n        words.push(str.slice(wordBegin, cursor));\n      }\n      break;\n    }\n    var isWordEnd = isWhitespaceChar(_char6);\n    if (isWordEnd) {\n      if (cursor !== wordBegin) {\n        words.push(str.slice(wordBegin, cursor));\n      }\n      wordBegin = cursor + 1;\n      cursor++;\n      continue;\n    }\n    var isQuoteStart = _char6 === \"'\" || _char6 === '\"';\n    if (isQuoteStart) {\n      quote = _char6;\n      cursor++;\n      continue;\n    }\n    cursor++;\n  }\n  jumpPosition(position, str, cursor);\n  var wLen = words.length;\n  var type = 'attribute';\n  for (var i = 0; i < wLen; i++) {\n    var word = words[i];\n    var isNotPair = word.indexOf('=') === -1;\n    if (isNotPair) {\n      var secondWord = words[i + 1];\n      if (secondWord && (0, _compat.startsWith)(secondWord, '=')) {\n        if (secondWord.length > 1) {\n          var newWord = word + secondWord;\n          tokens.push({\n            type: type,\n            content: newWord\n          });\n          i += 1;\n          continue;\n        }\n        var thirdWord = words[i + 2];\n        i += 1;\n        if (thirdWord) {\n          var _newWord = word + '=' + thirdWord;\n          tokens.push({\n            type: type,\n            content: _newWord\n          });\n          i += 1;\n          continue;\n        }\n      }\n    }\n    if ((0, _compat.endsWith)(word, '=')) {\n      var _secondWord = words[i + 1];\n      if (_secondWord && !(0, _compat.stringIncludes)(_secondWord, '=')) {\n        var _newWord2 = word + _secondWord;\n        tokens.push({\n          type: type,\n          content: _newWord2\n        });\n        i += 1;\n        continue;\n      }\n      var _newWord3 = word.slice(0, -1);\n      tokens.push({\n        type: type,\n        content: _newWord3\n      });\n      continue;\n    }\n    tokens.push({\n      type: type,\n      content: word\n    });\n  }\n}\nvar push = [].push;\nfunction lexSkipTag(tagName, state) {\n  var str = state.str,\n    position = state.position,\n    tokens = state.tokens;\n  var safeTagName = tagName.toLowerCase();\n  var len = str.length;\n  var index = position.index;\n  while (index < len) {\n    var nextTag = str.indexOf('</', index);\n    if (nextTag === -1) {\n      lexText(state);\n      break;\n    }\n    var tagStartPosition = copyPosition(position);\n    jumpPosition(tagStartPosition, str, nextTag);\n    var tagState = {\n      str: str,\n      position: tagStartPosition,\n      tokens: []\n    };\n    var name = lexTag(tagState);\n    if (safeTagName !== name.toLowerCase()) {\n      index = tagState.position.index;\n      continue;\n    }\n    if (nextTag !== position.index) {\n      var textStart = copyPosition(position);\n      jumpPosition(position, str, nextTag);\n      tokens.push({\n        type: 'text',\n        content: str.slice(textStart.index, nextTag),\n        position: {\n          start: textStart,\n          end: copyPosition(position)\n        }\n      });\n    }\n    push.apply(tokens, tagState.tokens);\n    jumpPosition(position, str, tagState.position.index);\n    break;\n  }\n}\n\n},{\"./compat\":1}],5:[function(require,module,exports){\n\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports[\"default\"] = parser;\nexports.hasTerminalParent = hasTerminalParent;\nexports.parse = parse;\nexports.rewindStack = rewindStack;\nvar _compat = require(\"./compat\");\nfunction parser(tokens, options) {\n  var root = {\n    tagName: null,\n    children: []\n  };\n  var state = {\n    tokens: tokens,\n    options: options,\n    cursor: 0,\n    stack: [root]\n  };\n  parse(state);\n  return root.children;\n}\nfunction hasTerminalParent(tagName, stack, terminals) {\n  var tagParents = terminals[tagName];\n  if (tagParents) {\n    var currentIndex = stack.length - 1;\n    while (currentIndex >= 0) {\n      var parentTagName = stack[currentIndex].tagName;\n      if (parentTagName === tagName) {\n        break;\n      }\n      if ((0, _compat.arrayIncludes)(tagParents, parentTagName)) {\n        return true;\n      }\n      currentIndex--;\n    }\n  }\n  return false;\n}\nfunction rewindStack(stack, newLength, childrenEndPosition, endPosition) {\n  stack[newLength].position.end = endPosition;\n  for (var i = newLength + 1, len = stack.length; i < len; i++) {\n    stack[i].position.end = childrenEndPosition;\n  }\n  stack.splice(newLength);\n}\nfunction parse(state) {\n  var tokens = state.tokens,\n    options = state.options;\n  var stack = state.stack;\n  var nodes = stack[stack.length - 1].children;\n  var len = tokens.length;\n  var cursor = state.cursor;\n  while (cursor < len) {\n    var token = tokens[cursor];\n    if (token.type !== 'tag-start') {\n      nodes.push(token);\n      cursor++;\n      continue;\n    }\n    var tagToken = tokens[++cursor];\n    cursor++;\n    var tagName = tagToken.content.toLowerCase();\n    if (token.close) {\n      var index = stack.length;\n      var shouldRewind = false;\n      while (--index > -1) {\n        if (stack[index].tagName === tagName) {\n          shouldRewind = true;\n          break;\n        }\n      }\n      while (cursor < len) {\n        var endToken = tokens[cursor];\n        if (endToken.type !== 'tag-end') break;\n        cursor++;\n      }\n      if (shouldRewind) {\n        rewindStack(stack, index, token.position.start, tokens[cursor - 1].position.end);\n        break;\n      } else {\n        continue;\n      }\n    }\n    var isClosingTag = (0, _compat.arrayIncludes)(options.closingTags, tagName);\n    var shouldRewindToAutoClose = isClosingTag;\n    if (shouldRewindToAutoClose) {\n      var terminals = options.closingTagAncestorBreakers;\n      shouldRewindToAutoClose = !hasTerminalParent(tagName, stack, terminals);\n    }\n    if (shouldRewindToAutoClose) {\n      // rewind the stack to just above the previous\n      // closing tag of the same name\n      var currentIndex = stack.length - 1;\n      while (currentIndex > 0) {\n        if (tagName === stack[currentIndex].tagName) {\n          rewindStack(stack, currentIndex, token.position.start, token.position.start);\n          var previousIndex = currentIndex - 1;\n          nodes = stack[previousIndex].children;\n          break;\n        }\n        currentIndex = currentIndex - 1;\n      }\n    }\n    var attributes = [];\n    var attrToken = void 0;\n    while (cursor < len) {\n      attrToken = tokens[cursor];\n      if (attrToken.type === 'tag-end') break;\n      attributes.push(attrToken.content);\n      cursor++;\n    }\n    cursor++;\n    var children = [];\n    var position = {\n      start: token.position.start,\n      end: attrToken.position.end\n    };\n    var elementNode = {\n      type: 'element',\n      tagName: tagToken.content,\n      attributes: attributes,\n      children: children,\n      position: position\n    };\n    nodes.push(elementNode);\n    var hasChildren = !(attrToken.close || (0, _compat.arrayIncludes)(options.voidTags, tagName));\n    if (hasChildren) {\n      var size = stack.push({\n        tagName: tagName,\n        children: children,\n        position: position\n      });\n      var innerState = {\n        tokens: tokens,\n        options: options,\n        cursor: cursor,\n        stack: stack\n      };\n      parse(innerState);\n      cursor = innerState.cursor;\n      var rewoundInElement = stack.length === size;\n      if (rewoundInElement) {\n        elementNode.position.end = tokens[cursor - 1].position.end;\n      }\n    }\n  }\n  state.cursor = cursor;\n}\n\n},{\"./compat\":1}],6:[function(require,module,exports){\n\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports[\"default\"] = void 0;\nexports.formatAttributes = formatAttributes;\nexports.toHTML = toHTML;\nvar _compat = require(\"./compat\");\nfunction formatAttributes(attributes, preferDoubleQuoteAttributes) {\n  return attributes.reduce(function (attrs, attribute) {\n    var key = attribute.key,\n      value = attribute.value;\n    if (value === null) {\n      return \"\".concat(attrs, \" \").concat(key);\n    }\n    var quote;\n    if (preferDoubleQuoteAttributes) {\n      var quoteEscape = value.indexOf('\"') !== -1;\n      quote = quoteEscape ? \"'\" : '\"';\n    } else {\n      var _quoteEscape = value.indexOf(\"'\") !== -1;\n      quote = _quoteEscape ? '\"' : \"'\";\n    }\n    return \"\".concat(attrs, \" \").concat(key, \"=\").concat(quote).concat(value).concat(quote);\n  }, '');\n}\nfunction toHTML(tree, options) {\n  return tree.map(function (node) {\n    if (node.type === 'text') {\n      return node.content;\n    }\n    if (node.type === 'comment') {\n      return \"<!--\".concat(node.content, \"-->\");\n    }\n    var tagName = node.tagName,\n      attributes = node.attributes,\n      children = node.children;\n    var isSelfClosing = (0, _compat.arrayIncludes)(options.voidTags, tagName.toLowerCase());\n    return isSelfClosing ? \"<\".concat(tagName).concat(formatAttributes(attributes, options.preferDoubleQuoteAttributes), \">\") : \"<\".concat(tagName).concat(formatAttributes(attributes, options.preferDoubleQuoteAttributes), \">\").concat(toHTML(children, options), \"</\").concat(tagName, \">\");\n  }).join('');\n}\nvar _default = {\n  toHTML: toHTML\n};\nexports[\"default\"] = _default;\n\n},{\"./compat\":1}],7:[function(require,module,exports){\n\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.voidTags = exports.closingTags = exports.closingTagAncestorBreakers = exports.childlessTags = void 0;\n/*\r\n  Tags which contain arbitary non-parsed content\r\n  For example: <script> JavaScript should not be parsed\r\n*/\nvar childlessTags = ['style', 'script', 'template'];\n\n/*\r\n  Tags which auto-close because they cannot be nested\r\n  For example: <p>Outer<p>Inner is <p>Outer</p><p>Inner</p>\r\n*/\nexports.childlessTags = childlessTags;\nvar closingTags = ['html', 'head', 'body', 'p', 'dt', 'dd', 'li', 'option', 'thead', 'th', 'tbody', 'tr', 'td', 'tfoot', 'colgroup'];\n\n/*\r\n  Closing tags which have ancestor tags which\r\n  may exist within them which prevent the\r\n  closing tag from auto-closing.\r\n  For example: in <li><ul><li></ul></li>,\r\n  the top-level <li> should not auto-close.\r\n*/\nexports.closingTags = closingTags;\nvar closingTagAncestorBreakers = {\n  li: ['ul', 'ol', 'menu'],\n  dt: ['dl'],\n  dd: ['dl'],\n  tbody: ['table'],\n  thead: ['table'],\n  tfoot: ['table'],\n  tr: ['table'],\n  td: ['table']\n};\n\n/*\r\n  Tags which do not need the closing tag\r\n  For example: <img> does not need </img>\r\n*/\nexports.closingTagAncestorBreakers = closingTagAncestorBreakers;\nvar voidTags = ['!doctype', 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'];\nexports.voidTags = voidTags;\n\n},{}]},{},[3])(3)\n});\n"],"file":"himalaya.js"}
\ No newline at end of file
diff --git a/docs/index.html b/docs/index.html
index ca56c0a..c74b4f0 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -23,6 +23,12 @@
         display: flex;
         align-items: center;
         padding: 10px 20px;
+        justify-content: space-between
+      }
+
+      header .info {
+        display: flex;
+        align-items: center;
       }
 
       header h1 {
@@ -83,6 +89,10 @@
         padding: 10px 20px;
       }
 
+      pre {
+        text-wrap: auto;
+      }
+
       #source {
         flex: 1;
         display: flex;
@@ -103,9 +113,17 @@
   <body>
     <div class='page'>
       <header>
-        <h1>Himalaya</h1>
-        <a href='https://github.com/andrejewski/himalaya'>on Github</a>
-        <a href='http://chrisandrejewski.com'>by Chris Andrejewski</a>
+        <div class='info'>
+          <h1>Himalaya</h1>
+          <a href='https://github.com/andrejewski/himalaya'>on Github</a>
+          <a href='http://chrisandrejewski.com'>by Chris Andrejewski</a>
+        </div>
+        <div>
+          <label>
+            <input id='show-stringify-pane' type='checkbox' />
+            <span>Show stringify pane</span>
+          </label>
+        </div>
       </header>
       <div class='converter'>
         <div class='split'>
@@ -129,7 +147,19 @@ <h1>Himalaya</h1>
             </label>
           </div>
           <div class='pane pane-output'>
-            <pre id='output'></pre>
+            <pre id='output-parse'></pre>
+          </div>
+        </div>
+        <div class='split' id="stringify-pane" style="display: none;">
+          <div class='pane-header'>
+            <label>Stringify</label>
+            <label>
+              <input id='prefer-double-quote-attributes' type='checkbox' />
+              <span>Prefer double quote attributes</span>
+            </label>
+          </div>
+          <div class='pane pane-output'>
+            <pre id='output-stringify'></pre>
           </div>
         </div>
       </div>
@@ -175,13 +205,23 @@ <h1>Himalaya</h1>
           code = removeWhitespace(code)
         }
 
-        $('#output').innerText = JSON.stringify(code, null, 2)
+        $('#output-parse').innerText = JSON.stringify(code, null, 2)
+
+        var options = { ...himalaya.parseDefaults, preferDoubleQuoteAttributes: $('#prefer-double-quote-attributes').checked }
+        $('#output-stringify').innerText = himalaya.stringify(code, options)
       }
 
       $('#source').onkeyup = updateOutput
       $('#whitespace').onchange = updateOutput
       $('#positions').onchange = updateOutput
+      $('#prefer-double-quote-attributes').onchange = updateOutput
       updateOutput()
+
+      function updatePaneDisplay () {
+        $('#stringify-pane').style.display = $('#show-stringify-pane').checked ? 'flex' : 'none'
+      }
+
+      $('#show-stringify-pane').onchange = updatePaneDisplay
     </script>
   </body>
 </html>
diff --git a/src/index.js b/src/index.js
index 288d0e8..cad6f23 100644
--- a/src/index.js
+++ b/src/index.js
@@ -15,6 +15,7 @@ export const parseDefaults = {
   childlessTags,
   closingTagAncestorBreakers,
   includePositions: false,
+  preferDoubleQuoteAttributes: false,
 }
 
 export function parse(str, options = parseDefaults) {
diff --git a/src/stringify.js b/src/stringify.js
index 70bd509..0e0c772 100644
--- a/src/stringify.js
+++ b/src/stringify.js
@@ -1,13 +1,19 @@
 import { arrayIncludes } from './compat'
 
-export function formatAttributes(attributes) {
+export function formatAttributes(attributes, preferDoubleQuoteAttributes) {
   return attributes.reduce((attrs, attribute) => {
     const { key, value } = attribute
     if (value === null) {
       return `${attrs} ${key}`
     }
-    const quoteEscape = value.indexOf("'") !== -1
-    const quote = quoteEscape ? '"' : "'"
+    let quote;
+    if (preferDoubleQuoteAttributes) {
+      const quoteEscape = value.indexOf('"') !== -1
+      quote = quoteEscape ? "'" : '"'
+    } else {
+      const quoteEscape = value.indexOf("'") !== -1
+      quote = quoteEscape ? '"' : "'"
+    }
     return `${attrs} ${key}=${quote}${value}${quote}`
   }, '')
 }
@@ -27,8 +33,8 @@ export function toHTML(tree, options) {
         tagName.toLowerCase(),
       )
       return isSelfClosing
-        ? `<${tagName}${formatAttributes(attributes)}>`
-        : `<${tagName}${formatAttributes(attributes)}>${toHTML(
+        ? `<${tagName}${formatAttributes(attributes, options.preferDoubleQuoteAttributes)}>`
+        : `<${tagName}${formatAttributes(attributes, options.preferDoubleQuoteAttributes)}>${toHTML(
             children,
             options,
           )}</${tagName}>`
diff --git a/test/stringify.js b/test/stringify.js
index 67f851e..13e4ac4 100644
--- a/test/stringify.js
+++ b/test/stringify.js
@@ -1,5 +1,5 @@
 import test from 'ava'
-import { parse, stringify } from '../lib'
+import { parse, parseDefaults, stringify } from '../lib'
 import { formatAttributes } from '../lib/stringify'
 
 test('stringify() should handle simple conversions', (t) => {
@@ -71,3 +71,19 @@ test('formatAttributes should stringify attribute lists correctly', (t) => {
     " data-key='123'",
   )
 })
+
+test('stringify() should use single quotes if attribute contains double quote', (t) => {
+  const html = `<div data-val='cake is "good"'></div>`
+  t.is(stringify(parse(html)), html)
+})
+
+test('stringify() should use double quotes if attribute contains single quote', (t) => {
+  const html = `<div data-val="cake is 'good'"></div>`
+  t.is(stringify(parse(html)), html)
+})
+
+test('stringify() should prefer double quotes if requested', (t) => {
+  const input = `<div data-val='cake is good'></div>`
+  const output = `<div data-val="cake is good"></div>`
+  t.is(stringify(parse(input), { ...parseDefaults, preferDoubleQuoteAttributes: true }), output)
+})


Reviews:
user: login: andrejewski
body: Just a few tiny things but looks good! Could you also post a screenshot of the updated UI?
state: CHANGES_REQUESTED
author_association: OWNER
submitted_at: 2025-04-03T04:24:04Z
user: login: GriffinCodes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T12:39:11Z
user: login: andrejewski
body: Awesome, thanks! I'll get a patch release out of this out soon!
state: APPROVED
author_association: OWNER
submitted_at: 2025-04-04T04:39:31Z

Review comments:
diff_hunk: @@ -15,14 +15,15 @@ export const parseDefaults = {
   childlessTags,
   closingTagAncestorBreakers,
   includePositions: false,
+  preferDoubleQuoteAttributes: false,
 }
 
 export function parse(str, options = parseDefaults) {
-  const tokens = lexer(str, options)
-  const nodes = parser(tokens, options)
-  return format(nodes, options)
+  const tokens = lexer(str, { ...parseDefaults, ...options })
+  const nodes = parser(tokens, { ...parseDefaults, ...options })
+  return format(nodes, { ...parseDefaults, ...options })
 }
 
 export function stringify(ast, options = parseDefaults) {
-  return toHTML(ast, options)
+  return toHTML(ast, { ...parseDefaults, ...options })
path: src/index.js
user: login: andrejewski
body: These edits are not needed, the idea with `parseDefaults` is that the caller will extend them themselves:

```js
import {parseDefaults, parse} from 'himalaya'

parse('...', {...parseDefaults, preferDoubleQuoteAttributes: true})
```
created_at: 2025-04-03T04:13:15Z
author_association: OWNER
diff_hunk: @@ -71,3 +71,18 @@ test('formatAttributes should stringify attribute lists correctly', (t) => {
     " data-key='123'",
   )
 })
+
+test('stringify() should use single quotes if attribute contains double quote', (t) => {
+  const html = `<div data-val='cake is "good"'></div>`
+  t.is(stringify(parse(html)), html)
+})
+
+test('stringify() should use double quotes if attribute contains single quote', (t) => {
+  const html = `<div data-val="cake is 'good'"></div>`
+  t.is(stringify(parse(html)), html)
+})
+
+test('stringify() should prefer double quotes if requested', (t) => {
+  const html = `<div data-val="cake is good"></div>`
+  t.is(stringify(parse(html), { preferDoubleQuoteAttributes: true }), html)
path: test/stringify.js
user: login: andrejewski
body: Better to use the single quote case as a test input so that change is more clear (otherwise we'd may be accidentally testing a no-op)
```suggestion
  const input = `<div data-val='cake is good'></div>`
  const output = `<div data-val="cake is good"></div>`
  t.is(stringify(parse(input), { preferDoubleQuoteAttributes: true }), output)
```
created_at: 2025-04-03T04:18:11Z
author_association: OWNER
diff_hunk: @@ -129,7 +143,19 @@ <h1>Himalaya</h1>
             </label>
           </div>
           <div class='pane pane-output'>
-            <pre id='output'></pre>
+            <pre id='output-parse'></pre>
+          </div>
+        </div>
+        <div class='split' id="stringify-pane" style="display: none;">
+          <div class='pane-header'>
+            <label>Stringify</label>
+            <label>
+              <input id='prefer-double-quote-attributes' type='checkbox' />
+              <span>Prefer Double Quote Attributes</span>
path: docs/index.html
user: login: andrejewski
body: All other checkboxes are sentence case which is the better format
```suggestion
              <span>Prefer double quote attributes</span>
```
created_at: 2025-04-03T04:20:35Z
author_association: OWNER
diff_hunk: @@ -15,14 +15,15 @@ export const parseDefaults = {
   childlessTags,
   closingTagAncestorBreakers,
   includePositions: false,
+  preferDoubleQuoteAttributes: false,
 }
 
 export function parse(str, options = parseDefaults) {
-  const tokens = lexer(str, options)
-  const nodes = parser(tokens, options)
-  return format(nodes, options)
+  const tokens = lexer(str, { ...parseDefaults, ...options })
+  const nodes = parser(tokens, { ...parseDefaults, ...options })
+  return format(nodes, { ...parseDefaults, ...options })
 }
 
 export function stringify(ast, options = parseDefaults) {
-  return toHTML(ast, options)
+  return toHTML(ast, { ...parseDefaults, ...options })
path: src/index.js
user: login: GriffinCodes
body: I don't quite agree that the user should have to pass in defaults, but I will defer to you
created_at: 2025-04-03T12:39:11Z
author_association: CONTRIBUTOR

PR comments:
user: login: GriffinCodes
created_at: 2025-04-03T12:39:57Z
body: Thanks for the feedback! Here's the requested screenshots

![image](https://github.com/user-attachments/assets/ede82443-5154-4aae-96dc-6694e30ed1ab)

![image](https://github.com/user-attachments/assets/46d86734-7f9d-4f3c-b37b-987d11fdb515)



author_association: CONTRIBUTOR
user: login: andrejewski
created_at: 2025-04-04T07:12:40Z
body: @GriffinCodes This change is now live in `v1.1.1` on npm, thank you for the contribution!
author_association: OWNER