Greasy Fork

Greasy Fork is available in English.

Mangadex Preview Post

WhatYouSeeIsWhatYouGet preview generator for MangaDex comments/posts/profile. Shows a formatted preview next to the edit box.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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/Christopher-McGinnis
// @author      Christopher McGinnis
// @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()
    })
}