Brandon_R_Beck / Mangadex Preview Post

// ==UserScript==
// @name        Mangadex Preview Post
// @description WhatYouSeeIsWhatYouGet preview generator for MangaDex comments/posts/profile. Shows a formatted preview next to the edit box.
// @namespace   https://github.com/Brandon-Beck
// @author      Brandon Beck
// @license     MIT
// @icon        https://mangadex.org/favicon-96x96.png
// @version     0.3.15
// @grant       GM_xmlhttpRequest
// @require     https://gitcdn.xyz/cdn/pegjs/pegjs/0b102d29a86254a50275b900706098aeca349740/website/vendor/pegjs/peg.js
// @match       https://mangadex.org/*
// ==/UserScript==
/* global $ */

'use strict'

// FIXME: The entire MediaTag code is an ugly mess
const isUserscript = window.GM_xmlhttpRequest !== undefined
// Ensure Console/Bookmarklet is not run on other sites.
if (!isUserscript && !window.location.href.startsWith('https://mangadex.org')) {
  /* eslint-disable-next-line no-alert */
  alert('Mangadex Post Preview script only works on https://mangadex.org')
  throw Error('Mangadex Post Preview script only works on https://mangadex.org')
}
// This is used when run in Browser Console / Bookmarklet mode
// Loads the same scripts used in UserScript.
// Does not run at all in userscript mode.
function loadScript(url) {
  // Adding the script tag to the head as suggested before
  const { head } = document
  const script = document.createElement('script')
  script.type = 'text/javascript'
  script.src = url
  // Then bind the event to the callback function.
  // There are several events for cross browser compatibility.
  return new Promise((resolve ,reject) => {
    // script.onreadystatechange = resolve
    script.onload = resolve
    script.onerror = reject
    // Fire the loading
    head.appendChild(script)
  })
}
const imageBlobs = {}
function getImageBlob(url) {
  if (!imageBlobs[url]) {
    imageBlobs[url] = new Promise((ret ,err) => {
      GM_xmlhttpRequest({
        method: 'GET'
        ,url
        ,responseType: 'blob'
        ,onerror: err
        ,ontimeout: err
        ,onload: (response) => {
          if (((response.status >= 200 && response.status <= 299) || response.status === 304)
                        && response.response) {
            imageBlobs[url] = Promise.resolve(response.response)
            return ret(imageBlobs[url])
          }
          return err(response)
        }
      })
    })
  }
  return imageBlobs[url]
}
function getImageObjectURL(url) {
  return getImageBlob(url).then(b => URL.createObjectURL(b))
}
const imgCache = {}
// Clones are made because the same image may be used more than once in a post.
function cloneImageCacheEntry(source) {
  const element = source.element.cloneNode()
  // Take for granted that we are loaded if our source was loaded.
  // Not necessarily true, but things should already be set as if it were
  // since we cloned the values.
  const { loadPromise } = source
  return {
    element ,loadPromise
  }
}
// Firefox Speed Test: Fastest to Slowest
// getImgForURLViaFetch: Caches blobs
// -- No noticable lag or problems with several small images on page.
// -- Very usable with Hell's test, though there is a small bit of lag
// -- (Rebuilds hell in under 1 second.
// -- Does better than getImgForURL does with a normal post with small images)
// getImgForURLViaImg: Caches imgs, clones on reuse
// -- Holding down a key causes noticable shakyness. No real script lag,
// -- but the images width/height seem to start off at 0 and then
// -- suddenly grow. Verry offsetting to look at
// -- Survives Hell's test almost just as well. Very minor additional lag.
// -- As such, I believe this is quite scalable.
// getImgForURLNoCache: Creates new img and sets src like normal
// -- Noticable lag. Preview will not update while a key is being spammed.
// -- Slightly jumpy like above, but not as noticable since the lag
// -- spreads it out.
// -- Survives Hell's test just as well as getImgForURLViaImg.
// -- Whatever benifit we get from cloning may not apply here.
// -- Perhaps due to the fact we are looking up only 1 image several hundrad times.
// BROKEN getImgForURLViaFetchClone: Caches Img of blobs.
// -- Does not work when image is used multiple times, for some reason.
// -- Should be comparable to getImgForURLViaFetch, if it worked.
// -- Failed to render any images for hell's test.
function getImgForURLViaImg(url) {
  if (imgCache[url] !== undefined) {
    return cloneImageCacheEntry(imgCache[url])
  }
  // TODO add images loaded in thread to cache.
  const element = document.createElement('img')
  // element.element.src=LOADING_IMG
  const loadPromise = new Promise((ret ,err) => {
    element.onload = () => ret(element)
    element.onerror = e => err(new Error(e.toString()))
    element.src = url
  })
  imgCache[url] = {
    element ,loadPromise
  }
  // First use. Clone not needed since gaurenteed to be unused
  return imgCache[url]
}
function getImgForURLViaFetch(url) {
  const promise = getImageObjectURL(url)
  const element = document.createElement('img')
  // element.element.src=LOADING_IMG
  const loadPromise = promise.then(e => new Promise((resolve ,reject) => {
    element.onload = () => {
      URL.revokeObjectURL(e)
      resolve(element)
    }
    element.onerror = (err) => {
      URL.revokeObjectURL(e)
      reject(new Error(err.toString()))
    }
    element.src = e
  }))
  // Clone not needed since a new img is generated every time.
  return {
    element ,loadPromise
  }
}
function getImgForURL(url) {
  if (isUserscript) {
    return getImgForURLViaFetch(url)
  }
  return getImgForURLViaImg(url)
}
/* PEG grammer */
// TODO:
// Partial rebuilds! only update what changed
// FIXME:
// Img is text only. not recursive
let generatedBBCodePegParser
function tokensToSimpleAST(tokens) {
  // FIXME Figure out Why pegjs returns null. is it an error, does empty
  // do an early escape? does having none of a token:expresion+
  // return null instead of [] (tested return token? token: [], didn't help)
  if (tokens == null) {
    return []
  }
  // Why did I make a root again?
  const astroot = [
    {
      type: 'root'
      ,content: []
      ,location: [0 ,0]
    }
  ]
  const stack = [astroot[0]]
  let astcur = astroot[0]
  /* eslint-disable prefer-destructuring */
  let mediaStateOpened
  let mediaErrorState = false
  tokens.forEach((token) => {
    if (mediaStateOpened && token.type !== 'linebreak') {
      const openMedia = astcur.content[astcur.content.length - 1]
      if (token.type === 'close' && token.tag === 'img') {
        if (!mediaErrorState) {
          mediaStateOpened.explicitlyClosed = true
          mediaStateOpened.location[1] = token.location[1]
        }
        else {
          if (openMedia.type === 'openmedia') {
            const errorAst = {
              type: 'error'
              ,content: `[${mediaStateOpened.tag}]${mediaStateOpened.content}`
              ,location: mediaStateOpened.location
            }
            astcur.content.pop()
            astcur.content.push(errorAst)
          }
          const errorAst = {
            type: 'error'
            ,content: `[/${token.tag}]`
            ,location: openMedia.location
          }
          astcur.content.push(errorAst)
        }
        mediaErrorState = false
        mediaStateOpened = undefined
        astcur.location[1] = token.location[1]
        return undefined
      }
      if (openMedia.type === 'openmedia') {
        if (openMedia.content === ''
                    && (token.type === 'link' || token.type === 'text')
                    && token.content.match(/^[^ \t\n\r:[\]]+:\/\/[^ \t\n\r[\]]+$/)) {
          openMedia.content = token.content
          astcur.location[1] = token.location[1]
          mediaErrorState = false
          return undefined
        }
        const errorAst = {
          type: 'error'
          ,content: `[${openMedia.tag}]${openMedia.content}`
          ,location: openMedia.location
        }
        astcur.content.pop()
        astcur.content.push(errorAst)
      }
      mediaErrorState = true
      if (token.type === 'open' || token.type === 'prefix'
                || token.type === 'opendata' || token.type === 'openmedia') {
        const errorAst = {
          type: 'error'
          ,content: `[${token.tag}]`
          ,location: token.location
        }
        astcur.content.push(errorAst)
      }
      else if (token.type === 'close') {
        const errorAst = {
          type: 'error'
          ,content: `[/${token.tag}]`
          ,location: token.location
        }
        astcur.content.push(errorAst)
      }
      else if (token.type === 'link') {
        const errorAst = {
          type: 'error'
          ,content: `${token.content}`
          ,location: token.location
        }
        astcur.content.push(errorAst)
      }
      else if (token.type === 'error' || token.type === 'text') {
        const errorAst = {
          type: 'error'
          ,content: `${token.content}`
          ,location: token.location
        }
        astcur.content.push(errorAst)
      }
      astcur.location[1] = token.location[1]
      return undefined
    }
    if (token.type === 'close') {
      let idx = Object.values(stack).reverse().findIndex(e => (e.type === 'open' || e.type === 'opendata' || e.type === 'prefix') && e.tag === token.tag)
      if (idx !== -1) {
        idx += 1
        // NOTE should we set ast location end? Yes!
        for (let i = stack.length - idx; i < stack.length; i++) {
          stack[i].location[1] = token.location[1]
        }
        stack.splice(-idx ,idx)
        astcur.location[1] = token.location[1]
        astcur = stack[stack.length - 1]
      }
      else {
        const thisast = {
          type: 'error'
          ,content: `[/${token.tag}]`
          ,location: token.location
        }
        astcur.location[1] = token.location[1]
        astcur.content.push(thisast)
      }
    }
    else if (token.type === 'open') {
      const thisast = {
        type: token.type
        ,tag: token.tag
        ,content: []
        ,location: token.location
      }
      // Must update end location when tag closes
      astcur.content.push(thisast)
      astcur.location[1] = token.location[1]
      // ;({ location: [,astcur.location[1]] } = token)
      astcur = thisast
      stack.push(thisast)
    }
    else if (token.type === 'prefix') {
      const thisast = {
        type: token.type
        ,tag: token.tag
        ,content: []
        ,location: token.location
      }
      // cannot directly nest bullet in bullet (must have a non-prexix container class)
      if (astcur.type === 'prefix') {
        // FIXME are we supposed to subtract 1 here?
        astcur.location[1] = token.location[0] // - 1
        stack.pop()
        astcur = stack[stack.length - 1]
      }
      astcur.content.push(thisast)
      astcur.location[1] = token.location[1]
      astcur = thisast
      stack.push(thisast)
    }
    else if (token.type === 'opendata') {
      const thisast = {
        type: token.type
        ,tag: token.tag
        ,content: []
        ,location: token.location
      }
      thisast.data = token.attr
      astcur.content.push(thisast)
      astcur.location[1] = token.location[1]
      astcur = thisast
      stack.push(thisast)
    }
    else if (token.type === 'openmedia') {
      const thisast = {
        type: token.type
        ,tag: token.tag
        ,content: ''
        ,location: token.location
        ,explicitlyClosed: false
      }
      astcur.content.push(thisast)
      astcur.location[1] = token.location[1]
      mediaStateOpened = thisast
      mediaErrorState = true
      // astcur = thisast
      // stack.push(thisast)
    }
    else if (token.type === 'linebreak') {
      // TODO should check if prefix instead if prefix is to be expanded appon
      // if (astcur.type === 'prefix') {
      // FIXME are we supposed to subtract 1 here?
      //  astcur.location[1] = token.location[0] // - 1
      // Are Linebreaks added when we are exiting a prefix? Seems like it!
      // Not sure why though...
      //  astcur.content.push(token)
      //  stack.pop()
      //  astcur = stack[stack.length - 1]
      // }
      // else {
      ({ location: [,astcur.location[1]] } = token)
      astcur.content.push(token)
      // }
    }
    else if (token.type === 'link') {
      astcur.location[1] = token.location[1]
      const previousSiblingAst = astcur.content[astcur.content.length - 1]
      if ((astcur.type === 'root' && !previousSiblingAst)
                || (previousSiblingAst && (previousSiblingAst.type === 'linebreak'
                    || (previousSiblingAst.type === 'text' && previousSiblingAst.content.endsWith(' '))))) {
        astcur.content.push(token)
      }
      else {
        astcur.content.push({
          type: 'error'
          ,location: token.location
          ,content: token.content
        })
      }
    }
    else {
      astcur.location[1] = token.location[1]
      astcur.content.push(token)
    }
    return undefined
  })
  // Close all tags (location). Remember we start at 1 bc root is just a container
  for (let i = 1; i < stack.length; i++) {
    stack[i].location[1] = astcur.location[1]
  }
  if (mediaStateOpened) {
    // FIXME make sure this makes sense
    const openMedia = astcur.content[astcur.content.length - 1]
    if (openMedia.type === 'openmedia') {
      const errorAst = {
        type: 'error'
        ,content: `[${mediaStateOpened.tag}]${mediaStateOpened.content}`
        ,location: mediaStateOpened.location
      }
      astcur.content.pop()
      astcur.content.push(errorAst)
    }
    const errorMediaCloseAst = {
      type: 'error'
      ,content: `[/${mediaStateOpened.tag}]` // FIXME should I use token or doErrornousMediaClose location>

      ,location: mediaStateOpened.location
    }
    // astcur.location[1] = token.location[1]
    astcur.content.push(errorMediaCloseAst)
    mediaStateOpened = undefined
  }
  // stack.splice(start, end) not needed
  return astroot[0].content
  /* eslint-enable prefer-destructuring */
}
function simpleAstTrim(ast) {
  let contentStartIndex = ast.findIndex(e => !(e.type === 'linebreak'
        || ((e.type === 'text' || e.type === 'error') && e.content.match(/^ +$/))))
  if (contentStartIndex === -1) contentStartIndex = 0
  let contentEndIndex = ast.slice().reverse().findIndex(e => !(e.type === 'linebreak'
        || ((e.type === 'text' || e.type === 'error') && e.content.match(/^ +$/))))
  if (contentEndIndex === -1) contentEndIndex = 0
  else contentEndIndex = ast.length - contentEndIndex
  return ast.slice(contentStartIndex ,contentEndIndex)
}
function bbcodeTokenizer() {
  if (generatedBBCodePegParser) return generatedBBCodePegParser
  generatedBBCodePegParser = peg.generate(String.raw`
start = tokens:Expressions? {return tokens}
Expressions = tokens:Expression+ {
  return tokens
}
Expression = res:(OpenTag / OpenMediaTag / OpenDataTag / CloseTag / PrefixTag / LineBreak / ImplicitLinkLoose / Text )
/*head:Term tail:(_ ("+" / "-") _ Term)* {
      return tail.reduce(function(result, element) {
        if (element[1] === "+") { return result + element[3]; }
        if (element[1] === "-") { return result - element[3]; }
      }, head);
    }
*/
Tag = tag:(OpenCloseTag / PrefixTag) {return tag}
OpenCloseTag = open:(OpenCloseNormalTag / OpenCloseMediaTag) {
    return {type:open.tag, data:open.attr, content}
}
OpenCloseMediaTag = open:OpenMediaTag content:Expression? close:CloseTag?
  &{
    let hasClose = close != null
    if (false && hasClose && open.tag != close.tag) {
      throw new Error(
          "Expected [/" + open.tag + "] but [/" + close.tag + "] found."
      );
    }
    return true
} {
    return {type:open.tag, content: open.content}
}
OpenCloseNormalTag = open:(OpenTag / OpenDataTag) content:Expression? close:CloseTag?
  &{
    let hasClose = close != null
    if (false && hasClose && open.tag != close.tag) {
      throw new Error(
          "Expected [/" + open.tag + "] but [/" + close.tag + "] found."
      );
    }
    return true
} {
    return {type:open.tag, data:open.attr, content}
}
PrefixTag = "[" tag:PrefixTagList "]" { return {type:"prefix", tag:tag, location:[location().start.offset,location().end.offset]} }

// PrefixTag = "[" tag:PrefixTagList "]" content:(!("[/" ListTags "]" / LineBreak ) .)* { return {type:tag,unparsed:content.join('')} }

ListTags = "list" / "ul" / "ol" / "li"

NormalTagList = "list" / "spoiler" / "center" / "code" / "quote" /  "sub" / "sup" / "left" / "right" / "ol" / "ul" / "h1" / "h2" / "h3" / "h4" / "hr" / "h" / "b" / "s" / "i" / "u"
MediaTagList = "img"
DataTagList = "url"
PrefixTagList = "*"

Data
  = text:(!"]". Data?) {
  /*if(text[2] != null) {
    return {type: "data", content:text[1] + text[2].content }
  }
  return {type: "data", content:text[1] }
  */
  if(text[2] != null) {
    return text[1] + text[2]
  }
  return text[1]
}
OpenTag = "[" tag:NormalTagList "]" { return {type:"open", tag:tag, location:[location().start.offset,location().end.offset] } }
// content:ExplicitLinkLoose
OpenMediaTag = "[" tag:MediaTagList "]" { return {type:"openmedia", tag:tag, location:[location().start.offset,location().end.offset] } }
AttrTagProxy = "=" attr:ExplicitLinkLoose {return attr.content}
OpenDataTag = "[" tag:DataTagList attr:AttrTagProxy  "]" { return {type:"opendata", tag:tag,attr:attr, location:[location().start.offset,location().end.offset]} }

CloseTag = "[/" tag:(DataTagList / MediaTagList / NormalTagList / PrefixTagList ) "]" { return {type:"close", tag:tag, location:[location().start.offset,location().end.offset]} }

// FIXME find actual values
// Explicit URL Links. Regex is something like [a-zA-Z0-9<LOTS OF SPECIAL CHARS>]://[a-zA-Z0-9]
ExplicitLinkAddressStrict
  = text:(!([ \t\n\r\[\]]). ExplicitLinkAddressStrict?)
   {
    return text.join('')
  }
ExplicitLinkProtoStrict
  = text:([a-zA-Z0-9]+)
   {
    return text.join('')
  }
ExplicitLinkStrict
  = text:(ExplicitLinkProtoStrict "://" ExplicitLinkAddressStrict) !([ \t\n\r])
   {
    return {type: "link", content:text.join(''), location:[location().start.offset,location().end.offset] }
  }
ExplicitLinkAddressLoose
  = text:(!([ \t\n\r\[\]]). ExplicitLinkAddressLoose?)
   {
    return text.join('')
  }
ExplicitLinkProtoLoose
  = text:(!([ \t\n\r\[\]\:\/]). ExplicitLinkProtoLoose?)
   {
    return text.join('')
  }
ExplicitLinkLoose
  = text:(ExplicitLinkProtoLoose "://" ExplicitLinkAddressLoose) !([ \t\n\r])
   {
    return {type: "link", content:text.join(''), location:[location().start.offset,location().end.offset] }
  }
// Implicit URL links. At least these are valid. (http|ftp)s?://[a-zA-Z0-9./\-%"':@+]+
ImplicitLinkAddressStrict
  = text:[a-zA-Z0-9./\-%"':@+]+
   {
    return text.join('')
  }
ImplicitLinkStrict
  = text:(
    ("http" / "ftp") "s"?
    "://" ImplicitLinkAddressStrict) !([^ \t\n\r])
   {
    return {type: "link", content:text.join(''), location:[location().start.offset,location().end.offset] }
  }
ImplicitLinkAddressLoose
  = text:(!([ \t\n\r\[\]]). ImplicitLinkAddressLoose?)
   {
    return text.join('')
  }
ImplicitLinkLoose
  = text:(
    ("http" / "ftp") "s"?
    "://" ImplicitLinkAddressLoose) !([^ \t\n\r])
   {
    return {type: "link", content:text.join(''), location:[location().start.offset,location().end.offset] }
  }
Text
  = text:(!(Tag / CloseTag / LineBreak / ImplicitLinkLoose). Text?) {
  if(text[2] != null) {
    return {type: "text", content:text[1] + text[2].content, location:[location().start.offset,text[2].location[1]] }
  }
  return {type: "text", content:text[1], location:[location().start.offset,location().end.offset] }
}
Word
  = text:(!(Tag / CloseTag / LineBreak / " "). Word?) {
  if(text[2] != null) {
    return {type: "word", content:text[1] + text[2].content, location:[location().start.offset,text[2].location[1]] }
  }
  return {type: "word", content:text[1], location:[location().start.offset,location().end.offset] }
}
Space
  = text:(" "+) {
  return {type: "space", content:text.join(''), location:[location().start.offset,location().end.offset] }
}
ContiguousText
  = text:(!(Tag / CloseTag / LineBreak / _ ). ContiguousText?) {
  if(text[2] != null) {
    return {type: "text", content:text[1] + text[2].content, location:[location().start.offset,text[2].location[1]] }
  }
  return {type: "text", content:text[1], location:[location().start.offset,location().end.offset] }
}
LineBreak
  = [\n] {
  return {type: "linebreak", location:[location().start.offset,location().end.offset] }
}
ErrorCatcher
  = errTxt:. {return {type: "error", content: errTxt, location:[location().start.offset,location().end.offset]} }

_ "whitespace"
  = [ \t\n\r]*



`)
  return generatedBBCodePegParser
}
// New steps:
// PegSimpleAST -> AST_WithHTML
// AST_WithHTML + cursor_location -> HtmlElement
// AST_WithHTML + text_change_location_and_range + all_text -> LocalAST_WithHTML_OfChange + local_ast_text_range -> LocalAST_WithHTML -> HtmlElement
function astToHtmlAst(ast) {
  if (ast == null) {
    return []
  }
  if (typeof (ast) !== 'object') {
    // This should never happen
    return []
  }
  function appendText(accum ,htmlAst ,otext) {
    // MD Single spacing
    // FIXME do this in parser
    // let text = otext.replace(/^\n +/ ,'\n')
    // text = otext.replace(/^ +/ ,'')
    if (accum[accum.length - 1]
            && accum[accum.length - 1].element.nodeType === document.TEXT_NODE) {
      /* eslint-disable-next-line no-param-reassign */
      let text = accum[accum.length - 1].element.nodeValue + otext
      text = text.replace(/^\n[ \t]+/ ,'\n')
      text = text.replace(/[ \t]+/g ,' ')
      accum[accum.length - 1].element.nodeValue = text
      return undefined
    }
    const text = otext.replace(/[ \t]+/g ,' ')
    accum.push({
      type: 'text'
      ,element: document.createTextNode(text)
      ,location: htmlAst.location
    })
    return undefined
  }
  const res = ast.reduce((accum ,e) => {
    if (e.type === 'text') {
      appendText(accum ,e ,e.content)
    }
    else if (e.type === 'linebreak') {
      const brAst = {
        element: document.createElement('br')
        ,location: e.location
        ,type: 'container'
        ,contains: []
      }
      accum.push(brAst)
      // NOTE: Why? No clue what the goal was with this, but it is how md does it
      // FIXME prefer br element for scroll
      const newlineTextNode = {
        element: document.createTextNode('\n')
        ,location: e.location
        ,type: 'container'
        ,contains: []
      }
      accum.push(newlineTextNode)
    }
    else if (e.type === 'error') {
      appendText(accum ,e ,e.content)
    }
    else if (e.type === 'link') {
      // accum += `<a href="${e.data}" target="_blank">${pegAstToHtml(e.content)}</a>`
      const linkAst = {
        element: document.createElement('a')
        ,location: e.location
        ,type: 'container'
        ,contains: []
      }
      const linkTextAst = {
        element: document.createTextNode(e.content)
        ,location: e.location
        ,type: 'container'
        ,contains: []
      }
      accum.push(linkAst)
      linkAst.element.target = '_blank'
      linkAst.element.rel = 'nofollow'
      if (e.content) {
        linkAst.element.href = e.content
      }
      linkAst.contains.push(linkTextAst)
      linkAst.contains.forEach((childAstElement) => {
        linkAst.element.appendChild(childAstElement.element)
      })
    }
    else if (e.type === 'openmedia') {
      // FIXME should Only pass url via image when parsing
      const imageCacheEntry = getImgForURL(e.content)
      const element = {
        element: imageCacheEntry.element
        ,location: e.location
        ,type: 'image'
        ,imagePromise: imageCacheEntry.loadPromise.then(() => e.content)
      }
      element.element.classList.add('align-bottom')
      element.element.style.maxWidth = '100%'
      // FIXME Do not do this. Move away from isEqualNode which cares about this space instead
      // Why does .style sometimes add the space on its own? are you screwing with me?
      const styleAttr = element.element.attributes.getNamedItem('style')
      if (styleAttr) styleAttr.value = `${styleAttr.value.trim()} `
      // element.element.src=LOADING_IMG
      accum.push(element)
    }
    // Everything after this must have a tag attribute!
    // not nesting to avoid right shift
    else if (!(e.type === 'open' || e.type === 'prefix' || e.type === 'opendata')) {
      // @ts-ignore: Not a string, but doesn't need to be. Make or edit type
      throw new Error({
        message: 'Unknown AST recieved!' ,child_ast: e ,container_ast: ast
      })
    }
    else if (e.tag === 'u' || e.tag === 's' || e.tag === 'sub'
            || e.tag === 'sup' || e.tag === 'ol' || e.tag === 'code'
            || e.tag === 'h1' || e.tag === 'h2' || e.tag === 'h3'
            || e.tag === 'h4' || e.tag === 'h5' || e.tag === 'h6') {
      const element = {
        element: document.createElement(e.tag)
        ,location: e.location
        ,type: 'container'
        ,contains: []
      }
      accum.push(element)
      element.contains = astToHtmlAst(e.content)
      element.contains.forEach((childAstElement) => {
        element.element.appendChild(childAstElement.element)
      })
    }
    else if (e.tag === 'list' || e.tag === 'ul') {
      const element = {
        element: document.createElement('ul')
        ,location: e.location
        ,type: 'container'
        ,contains: []
      }
      accum.push(element)
      element.contains = astToHtmlAst(e.content)
      element.contains.forEach((childAstElement) => {
        element.element.appendChild(childAstElement.element)
      })
    }
    else if (e.tag === 'hr') {
      const element = {
        element: document.createElement(e.tag)
        ,location: e.location
        ,type: 'container'
        ,contains: []
      }
      accum.push(element)
      // FIXME Contain children, in a non nested fashion
      // element.contains=astToHtmlAst(e.content)
      astToHtmlAst(e.content).forEach((childAstElement) => {
        accum.push(childAstElement)
      })
    }
    else if (e.tag === 'b') {
      const element = {
        element: document.createElement('strong')
        ,location: e.location
        ,type: 'container'
        ,contains: []
      }
      accum.push(element)
      element.contains = astToHtmlAst(e.content)
      element.contains.forEach((childAstElement) => {
        element.element.appendChild(childAstElement.element)
      })
    }
    else if (e.tag === 'i') {
      const element = {
        element: document.createElement('em')
        ,location: e.location
        ,type: 'container'
        ,contains: []
      }
      accum.push(element)
      element.contains = astToHtmlAst(e.content)
      element.contains.forEach((childAstElement) => {
        element.element.appendChild(childAstElement.element)
      })
    }
    else if (e.tag === 'h') {
      const element = {
        element: document.createElement('mark')
        ,location: e.location
        ,type: 'container'
        ,contains: []
      }
      accum.push(element)
      element.contains = astToHtmlAst(e.content)
      element.contains.forEach((childAstElement) => {
        element.element.appendChild(childAstElement.element)
      })
    }
    else if (e.tag === 'url') {
      // accum += `<a href="${e.data}" target="_blank">${pegAstToHtml(e.content)}</a>`
      const element = {
        element: document.createElement('a')
        ,location: e.location
        ,type: 'container'
        ,contains: []
      }
      accum.push(element)
      element.element.target = '_blank'
      if (e.data) {
        element.element.href = e.data
      }
      element.contains = astToHtmlAst(e.content)
      element.contains.forEach((childAstElement) => {
        element.element.appendChild(childAstElement.element)
      })
    }
    else if (e.tag === 'quote') {
      const element = {
        element: document.createElement('div')
        ,location: e.location
        ,type: 'container'
        ,contains: []
      }
      accum.push(element)
      element.element.style.width = '100%'
      element.element.style.display = 'inline-block'
      // FIXME dont use isEqualNode. Fix this compare (style uses 0px automaticly on ff)
      const styleAttr = element.element.attributes.getNamedItem('style')
      if (styleAttr) styleAttr.value += ' margin: 1em 0;'
      else element.element.style.margin = '1em 0'
      element.element.classList.add('well' ,'well-sm')
      element.contains = astToHtmlAst(e.content)
      element.contains.forEach((childAstElement) => {
        element.element.appendChild(childAstElement.element)
      })
    }
    else if (e.tag === 'spoiler') {
      const button = {
        element: document.createElement('button')
        ,location: e.location
        ,type: 'container'
        ,contains: []
      }
      button.element.textContent = 'Spoiler'
      button.element.classList.add('btn' ,'btn-sm' ,'btn-warning' ,'btn-spoiler')
      button.element.type = 'button'
      accum.push(button)
      const element = {
        element: document.createElement('div')
        ,location: e.location
        ,type: 'container'
        ,contains: []
      }
      accum.push(element)
      element.element.classList.add('spoiler' ,'display-none')
      element.contains = astToHtmlAst(e.content)
      // FIXME: [spoiler] and [/spoiler] should scroll to button. set inner location.
      // didnt work though... as if btn location wasnt set exits
      // if (element.contains[0]) {
      //  element.location[0] = element.contains[0].location[0]
      //  element.location[1] = element.contains[element.contains.length - 1].location[1]
      // }
      element.contains.forEach((childAstElement) => {
        element.element.appendChild(childAstElement.element)
      })
      // NOTE: The world was fixed and mended together! This might be equivilent now
      /* In a perfect world. it would work like this... but md is a bit broken
            ;(button.element as HTMLButtonElement).addEventListener('click',()=>{
              ;(element.element as HTMLDivElement).classList.toggle('display-none')
            })
            Code to do this is afer makepreview, to ensure buggieness is preserved */
    }
    else if (e.tag === 'center' || e.tag === 'left' || e.tag === 'right') {
      // accum += `<p class="text-center">${pegAstToHtml(e.content)}</p>`
      const element = {
        element: document.createElement('div')
        ,location: e.location
        ,type: 'container'
        ,contains: []
      }
      accum.push(element)
      element.element.classList.add(`text-${e.tag}`)
      element.contains = astToHtmlAst(e.content)
      element.contains.forEach((childAstElement) => {
        element.element.appendChild(childAstElement.element)
      })
    }
    else if (e.tag === '*') {
      const element = {
        element: document.createElement('li')
        ,location: e.location
        ,type: 'container'
        ,contains: []
      }
      accum.push(element)
      element.contains = astToHtmlAst(e.content)
      element.contains.forEach((childAstElement) => {
        element.element.appendChild(childAstElement.element)
      })
    }
    else if (e.content != null) {
      // FIXME? Is this possible? Root?
      astToHtmlAst(e.content).forEach((childAstElement) => {
        accum.push(childAstElement)
      })
    }
    else {
      // FIXME: Does this even happen?
      throw Error(`Recieved unknown and unhandeled ast entry '${JSON.stringify(e)}'`)
      /* accum.push({
              type: 'text'
              ,element: document.createTextNode(e.content)
              ,location: e.location
            }) */
    }
    return accum
  } ,[])
  /* TODO: Implement bi-directional scrolling. scroll textarea to current visible content
    res.filter(e => e.element.nodeName.toLowerCase() !== 'button')
      .forEach((e) => {
        e.element.addEventListener('click' ,() => {
          selectTextAreaPosition(e.location[0])
        })
      }) */
  return res
}
/* *********************************************
 * Validate Result
 ********************************************* */
function comparePreviewToPost(previewAst ,post) {
  // FIXME work with image blob src
  if (previewAst.length !== post.childNodes.length) {
    console.warn(`Preview children count ${previewAst.length} does not match Post children count ${post.childNodes.length} for post #${post.parentElement.parentElement.id}`)
    console.warn(previewAst)
    return false
  }
  const invalidAstKey = previewAst.findIndex((childAst ,key) => {
    if (!post.childNodes[key].isEqualNode(childAst.element)) {
      return true
    }
    return false
  })
  if (invalidAstKey !== -1) {
    console.warn(`Preview did NOT match post #${post.parentElement.parentElement.id}!`)
    console.warn('Ast Elm')
    console.warn(previewAst[invalidAstKey].element)
    console.warn('Post Elm')
    console.warn(post.childNodes[invalidAstKey])
    return false
  }
  return true
}
/* *********************************************
 * Build Interface
 ********************************************* */
function makePreview(txt) {
  // TODO compare bbcode to old BBCode
  // generate tokens and only for changed region
  // replace changed region html
  const astHtml = astToHtmlAst(simpleAstTrim(tokensToSimpleAST(bbcodeTokenizer().parse(txt))))
  const previewDiv = document.createElement('div')
  previewDiv.style.flexGrow = '1'
  astHtml.forEach(e => previewDiv.appendChild(e.element))
  // Conform to MD style
  previewDiv.classList.add('postbody' ,'mb-3' ,'mt-4')
  // FIXME: Ensure this is equivilent
  // Threads get wordWrap from tr.post
  // Profile gets it from card
  // Not sure why word break is needed, since I don't see it in md's css
  previewDiv.style.wordWrap = 'break-word'
  // previewDiv.style.overflowWrap = 'break-word'
  previewDiv.style.wordBreak = 'break-word'
  return [previewDiv ,astHtml]
}
function createPreviewCallbacks() {
  const nav = document.querySelector('nav.navbar.fixed-top')
  // @ts-ignore
  let navY
  if (nav === null) {
    navY = 0
  }
  else if (nav.getBoxQuads !== undefined) {
    navY = nav.getBoxQuads()[0].p3.y
  }
  else {
    navY = nav.getBoundingClientRect().height
  }
  const navHeight = navY
  // let image_buffers: Map<string, Blob>
  let forms = Object.values(document.querySelectorAll('.post_edit_form'))
  forms = forms.concat(Object.values(document.querySelectorAll('#post_reply_form')))
  forms = forms.concat(Object.values(document.querySelectorAll('#change_profile_form, #start_thread_form')))
  forms.forEach((forum) => {
    // Try to make it side by side
    // e.parentElement.parentElement.insertBefore(previewDiv,e.parentElement)
    // e.parentElement.classList.add("sticky-top", "pt-5", "col-6")
    const textarea = (forum.querySelector('textarea'))
    if (!textarea) {
      // FIXME throw errors. Kind of want to short circit this one though
      return Error('Failed to find text area for forum')
    }
    // Setup variables
    let curDisplayedVersion = 0
    let nextVersion = 1
    let updateTimeout
    let updateTimeoutDelay = 50
    const maxAcceptableDelay = 10000
    const useFallbackPreview = false
    // Prepare form
    if (!forum.parentElement) {
      return undefined
    }
    // Setup our custom styles
    /* eslint-disable no-param-reassign */
    forum.parentElement.style.alignItems = 'flex-start'
    forum.parentElement.classList.add('d-flex')
    forum.parentElement.style.flexDirection = 'row-reverse'
    forum.style.position = 'sticky'
    forum.style.top = '0px'
    // Causes buttons to wrap on resize
    forum.style.width = 'min-content'
    // Padding keeps us from hitting the navbar. Margin lines us back up with the preview
    forum.style.paddingTop = `${navHeight}px`
    forum.style.marginTop = `-${navHeight}px`
    /* eslint-enable no-param-reassign */
    textarea.style.resize = 'both'
    // FIXME set textarea maxheight. form should be 100vh max.
    textarea.style.minWidth = '120px'
    textarea.style.width = '25vw'
    textarea.style.paddingLeft = '0'
    textarea.style.paddingRight = '0'
    // Make Initial Preview
    // FIXME use Update preview for initial preview as well
    let [previewDiv ,astHtml] = makePreview(textarea.value)
    forum.parentElement.insertBefore(previewDiv ,forum)
    // Run sanity check if in console mode
    if (!isUserscript && forum.classList.contains('post_edit_form')) {
      const post = document.querySelector(`#post_${forum.id} .postbody`)
      if (post) comparePreviewToPost(astHtml ,post)
    }
    // Move editor to left column if in a thread.
    const tableLeft = forum.parentElement.parentElement.firstElementChild
    if (tableLeft !== forum.parentElement) {
      if (tableLeft.firstChild.nodeName.toLowerCase() === 'img') {
        // We are a thread post! Lets integrate into the thread
        tableLeft.firstChild.remove()
        tableLeft.appendChild(forum)
        // Conform to MD thread post style
        forum.parentElement.classList.remove('p-3')
        forum.parentElement.classList.add('pb-3')
        forum.parentElement.parentElement.classList.add('post')
        // FIXME: Profile page also needs formating.
        // md's wordWrap is break-word, but it seems to
        // be acting like wordwrap: anywhere for some reason.
      }
      else {
        // Add padding to new posts and profile, so the preview doesn't touch
        // textarea the border
        forum.classList.add('pr-3')
        // Fixes profile interface overlap problem
        if (forum.id === 'change_profile_form') {
          textarea.parentElement.style.flexBasis = '100%'
          textarea.parentElement.style.maxWidth = '100%'
        }
        // FIXME: d-flex is causing preview to affect settings tabs
        // other than the profile tab. Making the entire preview
        // an invisible block that fills the page
        // invisible links can be accidently clicked on as well
      }
    }
    let currentSpoiler
    function searchAst(ast ,cpos) {
      // slice bc reverse is in place
      const a = ast.slice().reverse().find(e => e.location[0] <= cpos && cpos <= e.location[1])
      if (a) {
        if (a.type === 'container') {
          // unhide spoilers
          // Ensure we are not a Text node and that we are a spoiler
          if (!currentSpoiler && a.element.nodeType !== 3
                        && a.element.classList.contains('spoiler')
                        && a.element.style.display !== 'block') {
            currentSpoiler = a.element
            currentSpoiler.style.display = 'block'
          }
          const b = searchAst(a.contains ,cpos)
          if (b) {
            return b
          }
        }
        return a.element
      }
      return undefined
    }
    // Auto scroll into view
    function scrollToPos(pos = textarea.selectionStart) {
      // Hide previous spoiler
      if (currentSpoiler) {
        currentSpoiler.style.display = 'none'
        currentSpoiler = undefined
      }
      // Get element from ast that starts closest to pos
      const elm = searchAst(astHtml ,pos)
      if (elm) {
        // FIXME Scroll pos is a bit hard to find.
        // getBoxQuads, getClientRect, getBoundingClientRect all give the offset from the viewport
        // Height of child elements not calculated in...
        // SAFE for (text)nodes?, not safe for elements with nested content
        if (elm.nodeType === 3) {
          // @ts-ignore
          let y
          if (elm.getBoxQuads !== undefined) {
            y = elm.getBoxQuads()[0].p1.y
          }
          else {
            // if we do not have getBoxQuads, we will have to test from the
            // container element instead of the text node;
            y = elm.parentElement.getBoundingClientRect().top
          }
          // FIXME. Must be a better way to scroll (especialy in case of nested scroll frames)
          // Scroll to top
          document.scrollingElement.scrollBy(0 ,y)
        }
        else {
          // FIXME. Must be a better way to scroll (especialy in case of nested scroll frames)
          // Scroll to ~ center directly
          // const y: number = (elm as HTMLElement).offsetTop
          // document.scrollingElement!.scrollTo({top:y})
          // Scroll to top
          elm.scrollIntoView()
        }
        // Scroll out of nav
        document.scrollingElement.scrollBy(0 ,-navHeight)
        // Add this line to scroll to center
        // document.scrollingElement!.scrollBy(0,-(window.innerHeight-navHeight)/2)
        // Finally, ensure we keep the textarea in view
        const bound = forum.getBoundingClientRect()
        // document.scrollingElement!.scrollBy(0,bound.bottom - bound.height)
        document.scrollingElement.scrollBy(0 ,bound.top)
      }
    }
    textarea.addEventListener('selectionchange' ,() => {
      // Only autoscroll if our ast is in sync with the preview.
      if (curDisplayedVersion === nextVersion - 1
                && astHtml[astHtml.length - 1] != null
                && astHtml[astHtml.length - 1].location[1] === textarea.value.length) {
        scrollToPos()
      }
    })
    function UpdatePreview() {
      // Measure load speed. Used for setting update delay dynamicly.
      const startTime = Date.now()
      // Create a preview buffer
      const thisVersion = nextVersion++
      const [newPreview ,newAstHtml] = makePreview(textarea.value)
      // Setup spoilers the same way md does
      $(newPreview).find('.btn-spoiler').click(function spoilerButton() {
        // @ts-ignore
        $(this).next('.spoiler').toggle()
      })
      // previewDiv, astHtml
      const imgLoadPromises = []
      Object.values(newPreview.querySelectorAll('img')).forEach((img) => {
        imgLoadPromises.push(new Promise((resolve) => {
          img.addEventListener('load' ,resolve)
          // Errors dont really matter to us
          img.addEventListener('error' ,resolve)
          // Esure we are not already done
          if (img.complete) {
            resolve()
          }
        }))
      })
      // Wait for all images to load or error (size calculations needed) before we swap and rescroll
      // This is the part that actualy updates the preview
      Promise.all(imgLoadPromises).then(() => {
        const endTime = Date.now()
        const updateLoadDelay = endTime - startTime
        if (!useFallbackPreview && updateLoadDelay > maxAcceptableDelay) {
          // NOTE: Fallback preview removed. Focusing on speed improvments of normal preview
          // useFallbackPreview = true
          // dbg(`It took ${updateLoadDelay} milli to update. Max acceptable delay was ${maxAcceptableDelay}! Switching to fallback preview!`)
          // We intentionally do not update the timout delay when we swap to fallback preview
        }
        else {
          // average out the times
          updateTimeoutDelay = (updateTimeoutDelay + updateLoadDelay) / 2
          // dbg(`It took ${updateLoadDelay} milli to update. Changing delay to ${updateTimeoutDelay} `)
        }
        // Return if we are older than cur preview
        if (thisVersion < curDisplayedVersion) {
          newPreview.remove()
          return
        }
        curDisplayedVersion = thisVersion
        // Replace the Preview with the buffered content
        previewDiv.parentElement.insertBefore(newPreview ,previewDiv)
        previewDiv.remove()
        previewDiv = newPreview
        astHtml = newAstHtml
        // Scroll to position
        scrollToPos()
      })
    }
    function UpdatePreviewProxy() {
      // dbg(`Reseting timeout with delay ${updateTimeoutDelay} `)
      clearTimeout(updateTimeout)
      updateTimeout = setTimeout(UpdatePreview ,updateTimeoutDelay)
    }
    const buttons = Object.values(forum.querySelectorAll('button'))
    buttons.forEach((btn) => {
      btn.addEventListener('click' ,UpdatePreviewProxy)
    })
    textarea.oninput = UpdatePreviewProxy
    return undefined
  })
}
/* *************************************
 * Run It!
 ************************************* */
if (isUserscript) createPreviewCallbacks()
else {
  // Import and wait for PegJS
  // then createPreviewCallbacks()
  loadScript('https://gitcdn.xyz/cdn/pegjs/pegjs/0b102d29a86254a50275b900706098aeca349740/website/vendor/pegjs/peg.js')
    .then(() => {
      createPreviewCallbacks()
    })
}