Main =
  init: ->
    # XXX dwb userscripts extension reloads scripts run at document-start when replaceState/pushState is called.
    # XXX Firefox reinjects WebExtension content scripts when extension is updated / reloaded.
    try
      w = window
      w = (w.wrappedJSObject or w) if $.platform is 'crx'
      return if '<%= meta.name %> antidup' of w
      w['<%= meta.name %> antidup'] = true

    if location.hostname is 'www.google.com'
      $.get 'Captcha Fixes', true, ({'Captcha Fixes': enabled}) ->
        if enabled
          $.ready -> Captcha.fixes.init()
      return

    # Don't run inside ad iframes.
    try
      return if window.frameElement and window.frameElement.src in ['', 'about:blank']

    # Detect multiple copies of 4chan X
    return if doc and $.hasClass(doc, 'fourchan-x')
    $.asap docSet, ->
      $.addClass doc, 'fourchan-x', 'seaweedchan'
      $.addClass doc, "ua-#{$.engine}" if $.engine
    $.on d, '4chanXInitFinished', ->
      if Main.expectInitFinished
        delete Main.expectInitFinished
      else
        new Notice 'error', 'Error: Multiple copies of 4chan X are enabled.'
        $.addClass doc, 'tainted'

    # Flatten default values from Config into Conf
    flatten = (parent, obj) ->
      if obj instanceof Array
        Conf[parent] = $.dict.clone(obj[0])
      else if typeof obj is 'object'
        for key, val of obj
          flatten key, val
      else # string or number
        Conf[parent] = obj
      return

    # XXX Remove document-breaking ad
    if location.hostname in ['boards.4chan.org', 'boards.4channel.org']
      $.global ->
        fromCharCode0 = String.fromCharCode
        String.fromCharCode = ->
          if document.body
            String.fromCharCode = fromCharCode0
          else if document.currentScript and not document.currentScript.src
            throw Error()
          fromCharCode0.apply @, arguments
      $.asap docSet, ->
        $.onExists doc, 'iframe[srcdoc]', $.rm

    flatten null, Config

    for db in DataBoard.keys
      Conf[db] = $.dict()
    Conf['customTitles'] = $.dict.clone {'4chan.org': {boards: {'qa': {'boardTitle': {orig: '/qa/ - Question & Answer', title: '/qa/ - 2D/Random'}}}}}
    Conf['boardConfig'] = boards: $.dict()
    Conf['archives'] = Redirect.archives
    Conf['selectedArchives'] = $.dict()
    Conf['cooldowns'] = $.dict()
    Conf['Index Sort'] = $.dict()
    Conf["Last Long Reply Thresholds #{i}"] = $.dict() for i in [0...2]
    Conf['siteProperties'] = $.dict()

    # XXX old key names
    Conf['Except Archives from Encryption'] = false
    Conf['JSON Navigation'] = true
    Conf['Oekaki Links'] = true
    Conf['Show Name and Subject'] = false
    Conf['QR Shortcut'] = true
    Conf['Bottom QR Link'] = true
    Conf['Toggleable Thread Watcher'] = true
    Conf['siteSoftware'] = ''
    Conf['Use Faster Image Host'] = 'true'

    # Enforce JS whitelist
    if (
      /\.4chan(?:nel)?\.org$/.test(location.hostname) and
      !SW.yotsuba.regexp.pass.test(location.href) and
      !$$('script:not([src])', d).filter((s) -> /this\[/.test(s.textContent)).length
    )
      ($.getSync or $.get) {'jsWhitelist': Conf['jsWhitelist']}, ({jsWhitelist}) ->
        $.addCSP "script-src #{jsWhitelist.replace(/^#.*$/mg, '').replace(/[\s;]+/g, ' ').trim()}"

    # Get saved values as items
    items = $.dict()
    items[key] = undefined for key of Conf
    items['previousversion'] = undefined
    ($.getSync or $.get) items, (items) ->
      if !$.perProtocolSettings and /\.4chan(?:nel)?\.org$/.test(location.hostname) and (items['Redirect to HTTPS'] ? Conf['Redirect to HTTPS']) and location.protocol isnt 'https:'
        location.replace('https://' + location.host + location.pathname + location.search + location.hash)
        return
      $.asap docSet, ->

        # Don't hide the local storage warning behind a settings panel.
        if $.cantSet
          # pass

        # Fresh install
        else if !items.previousversion?
          Main.isFirstRun = true
          Main.ready ->
            $.set 'previousversion', g.VERSION
            Settings.open()

        # Migrate old settings
        else if items.previousversion isnt g.VERSION
          Main.upgrade items

        # Combine default values with saved values
        for key, val of Conf
          Conf[key] = items[key] ? val

        Site.init Main.initFeatures

  upgrade: (items) ->
    {previousversion} = items
    changes = Settings.upgrade items, previousversion
    items.previousversion = changes.previousversion = g.VERSION
    $.set changes, ->
      if items['Show Updated Notifications'] ? true
        el = $.el 'span',
          `<%= html(meta.name + ' has been updated to <a href="' + meta.changelog + '" target="_blank">version ${g.VERSION}</a>.') %>`
        new Notice 'info', el, 15

  parseURL: (site=g.SITE, url=location) ->
    r = {}

    return r if !site
    r.siteID = site.ID

    return r if site.isBoardlessPage?(url)
    pathname = url.pathname.split /\/+/
    r.boardID = pathname[1]

    if site.isFileURL(url)
      r.VIEW = 'file'
    else if site.isAuxiliaryPage?(url)
      # pass
    else if pathname[2] in ['thread', 'res']
      r.VIEW = 'thread'
      r.threadID = r.THREADID = +pathname[3].replace(/\.\w+$/, '')
    else if pathname[2] is 'archive' and pathname[3] is 'res'
      r.VIEW = 'thread'
      r.threadID = r.THREADID = +pathname[4].replace(/\.\w+$/, '')
      r.threadArchived = true
    else if /^(?:catalog|archive)(?:\.\w+)?$/.test(pathname[2])
      r.VIEW = pathname[2].replace(/\.\w+$/, '')
    else if /^(?:index|\d*)(?:\.\w+)?$/.test(pathname[2])
      r.VIEW = 'index'
    r

  initFeatures: ->
    $.global ->
      document.documentElement.classList.add 'js-enabled'
      window.FCX = {}
    Main.jsEnabled = $.hasClass doc, 'js-enabled'

    # XXX https://bugs.chromium.org/p/chromium/issues/detail?id=920638
    $.ajaxPageInit?()

    $.extend g, Main.parseURL()
    g.BOARD = new Board g.boardID if g.boardID

    if !g.VIEW
      g.SITE.initAuxiliary?()
      return

    if g.VIEW is 'file'
      $.asap (-> d.readyState isnt 'loading'), ->
        if g.SITE.software is 'yotsuba' and Conf['404 Redirect'] and g.SITE.is404?()
          pathname = location.pathname.split /\/+/
          Redirect.navigate 'file', {
            boardID:  g.BOARD.ID
            filename: pathname[pathname.length - 1]
          }
        else if video = $ 'video'
          if Conf['Volume in New Tab']
            Volume.setup video
          if Conf['Loop in New Tab']
            video.loop = true
            video.controls = false
            video.play()
            ImageCommon.addControls video
      return

    g.threads = new SimpleDict()
    g.posts   = new SimpleDict()

    # set up CSS when <head> is completely loaded
    $.onExists doc, 'body', Main.initStyle

    # c.time 'All initializations'
    for [name, feature] in Main.features
      continue if g.SITE.disabledFeatures and name in g.SITE.disabledFeatures
      # c.time "#{name} initialization"
      try
        feature.init()
      catch err
        Main.handleErrors
          message: "\"#{name}\" initialization crashed."
          error: err
      # finally
      #   c.timeEnd "#{name} initialization"

    # c.timeEnd 'All initializations'

    $.ready Main.initReady

  initStyle: ->
    return if !Main.isThisPageLegit()

    # disable the mobile layout
    $('link[href*=mobile]', d.head)?.disabled = true
    doc.dataset.host = location.host
    $.addClass doc, "sw-#{g.SITE.software}"
    $.addClass doc, if g.VIEW is 'thread' then 'thread-view' else g.VIEW
    $.onExists doc, '.ad-cnt, .adg-rects > .desktop', (ad) -> $.onExists ad, 'img, iframe', -> $.addClass doc, 'ads-loaded'
    $.addClass doc, 'autohiding-scrollbar' if Conf['Autohiding Scrollbar']
    $.ready ->
      if d.body.clientHeight > doc.clientHeight and (window.innerWidth is doc.clientWidth) isnt Conf['Autohiding Scrollbar']
        Conf['Autohiding Scrollbar'] = !Conf['Autohiding Scrollbar']
        $.set 'Autohiding Scrollbar', Conf['Autohiding Scrollbar']
        $.toggleClass doc, 'autohiding-scrollbar'
    $.addStyle CSS.sub(CSS.boards), 'fourchanx-css'
    Main.bgColorStyle = $.el 'style', id: 'fourchanx-bgcolor-css'

    keyboard = false
    $.on d, 'mousedown', -> keyboard = false
    $.on d, 'keydown', (e) -> (keyboard = true if e.keyCode is 9) # tab
    window.addEventListener 'focus', (-> doc.classList.toggle 'keyboard-focus', keyboard), true

    Main.setClass()

  setClass: ->
    knownStyles = ['yotsuba', 'yotsuba-b', 'futaba', 'burichan', 'photon', 'tomorrow', 'spooky']

    if g.SITE.software is 'yotsuba' and g.VIEW is 'catalog'
      if (mainStyleSheet = $.id('base-css'))
        style = mainStyleSheet.href.match(/catalog_(\w+)/)?[1].replace('_new', '').replace(/_+/g, '-')
        if style in knownStyles
          $.addClass doc, style
          return

    style = mainStyleSheet = styleSheets = null

    setStyle = ->
      # Use preconfigured CSS for 4chan's default themes.
      if g.SITE.software is 'yotsuba'
        $.rmClass doc, style
        style = null
        for styleSheet in styleSheets
          if styleSheet.href is mainStyleSheet?.href
            style = styleSheet.title.toLowerCase().replace('new', '').trim().replace /\s+/g, '-'
            style = styleSheet.href.match(/[a-z]*(?=[^/]*$)/)[0] if style is '_special'
            style = null unless style in knownStyles
            break
        if style
          $.addClass doc, style
          $.rm Main.bgColorStyle
          return

      # Determine proper dialog background color for other themes.
      div = g.SITE.bgColoredEl()
      div.style.position = 'absolute';
      div.style.visibility = 'hidden';
      $.add d.body, div
      bgColor = window.getComputedStyle(div).backgroundColor
      $.rm div
      rgb = bgColor.match(/[\d.]+/g)
      # Use body background if reply background is transparent
      unless /^rgb\(/.test(bgColor)
        s = window.getComputedStyle(d.body)
        bgColor = "#{s.backgroundColor} #{s.backgroundImage} #{s.backgroundRepeat} #{s.backgroundPosition}"
      css = """
        .dialog, .suboption-list > div:last-of-type, :root.catalog-hover-expand .catalog-container:hover > .post {
          background: #{bgColor};
        }
        .unread-mark-read {
          background-color: rgba(#{rgb[...3].join(', ')}, #{0.5*(rgb[3] || 1)});
        }
      """
      if $.luma(rgb) < 100
        css += """
          .watch-thread-link {
            background-image: url("data:image/svg+xml,<svg viewBox='0 0 26 26' preserveAspectRatio='true' xmlns='http://www.w3.org/2000/svg'><path fill='rgb(200,200,200)' d='M24.132,7.971c-2.203-2.205-5.916-2.098-8.25,0.235L15.5,8.588l-0.382-0.382c-2.334-2.333-6.047-2.44-8.25-0.235c-2.204,2.203-2.098,5.916,0.235,8.249l8.396,8.396l8.396-8.396C26.229,13.887,26.336,10.174,24.132,7.971z'/></svg>");
          }
        """
      Main.bgColorStyle.textContent = css
      $.after $.id('fourchanx-css'), Main.bgColorStyle

    $.onExists d.head, g.SITE.selectors.styleSheet, (el) ->
      mainStyleSheet = el
      if g.SITE.software is 'yotsuba'
        styleSheets = $$ 'link[rel="alternate stylesheet"]', d.head
      new MutationObserver(setStyle).observe mainStyleSheet, {
        attributes: true
        attributeFilter: ['href']
      }
      $.on mainStyleSheet, 'load', setStyle
      setStyle()
    unless mainStyleSheet
      for styleSheet in $$ 'link[rel="stylesheet"]', d.head
        $.on styleSheet, 'load', setStyle
      setStyle()

  initReady: ->
    if g.SITE.is404?()
      if g.VIEW is 'thread'
        ThreadWatcher.set404 g.BOARD.ID, g.THREADID, ->
          if Conf['404 Redirect']
            Redirect.navigate 'thread',
              boardID:  g.BOARD.ID
              threadID: g.THREADID
              postID:   +location.hash.match /\d+/ # post number or 0
            , "/#{g.BOARD}/"

      return

    if g.SITE.isIncomplete?()
      msg = $.el 'div',
        `<%= html('The page didn&#039;t load completely.<br>Some features may not work unless you <a href="javascript:;">reload</a>.') %>`
      $.on $('a', msg), 'click', -> location.reload()
      new Notice 'warning', msg

    # Parse HTML or skip it and start building from JSON.
    if g.VIEW is 'catalog'
      Main.initCatalog()
    else if !Index.enabled
      Main.initThread() 
    else
      Main.expectInitFinished = true
      $.event '4chanXInitFinished'

  initThread: ->
    s = g.SITE.selectors
    if (board = $ s.board)
      threads = []
      posts   = []
      errors  = []

      Main.addThreadsObserver = new MutationObserver Main.addThreads
      Main.addPostsObserver   = new MutationObserver Main.addPosts
      Main.addThreadsObserver.observe board, {childList: true}

      Main.parseThreads $$(s.thread, board), threads, posts, errors
      Main.handleErrors errors if errors.length

      if g.VIEW is 'thread'
        if g.threadArchived
          threads[0].isArchived = true
          threads[0].kill()
        g.SITE.parseThreadMetadata?(threads[0])

      Main.callbackNodes 'Thread', threads
      Main.callbackNodesDB 'Post', posts, ->
        QuoteThreading.insert post for post in posts
        Main.expectInitFinished = true
        $.event '4chanXInitFinished'

    else
      Main.expectInitFinished = true
      $.event '4chanXInitFinished'

  parseThreads: (threadRoots, threads, posts, errors) ->
    for threadRoot in threadRoots
      boardObj = if (boardID = threadRoot.dataset.board)
        boardID = encodeURIComponent boardID
        g.boards[boardID] or new Board(boardID)
      else
        g.BOARD
      threadID = +threadRoot.id.match(/\d*$/)[0]
      return if !threadID or boardObj.threads.get(threadID)?.nodes.root
      thread = new Thread threadID, boardObj
      thread.nodes.root = threadRoot
      threads.push thread
      postRoots = $$ g.SITE.selectors.postContainer, threadRoot
      postRoots.unshift threadRoot if g.SITE.isOPContainerThread
      Main.parsePosts postRoots, thread, posts, errors
      Main.addPostsObserver.observe threadRoot, {childList: true}

  parsePosts: (postRoots, thread, posts, errors) ->
    for postRoot in postRoots when !postRoot.dataset.fullID and $(g.SITE.selectors.comment, postRoot)
      try
        posts.push new Post postRoot, thread, thread.board
      catch err
        # Skip posts that we failed to parse.
        errors.push
          message: "Parsing of Post No.#{postRoot.id.match(/\d+/)} failed. Post will be skipped."
          error: err
          html: postRoot.outerHTML
    return

  addThreads: (records) ->
    threadRoots = []
    for record in records
      for node in record.addedNodes when node.nodeType is Node.ELEMENT_NODE and node.matches(g.SITE.selectors.thread)
        threadRoots.push node
    return unless threadRoots.length
    threads = []
    posts   = []
    errors  = []
    Main.parseThreads threadRoots, threads, posts, errors
    Main.handleErrors errors if errors.length
    Main.callbackNodes 'Thread', threads
    Main.callbackNodesDB 'Post', posts, ->
      $.event 'PostsInserted', null, records[0].target

  addPosts: (records) ->
    threads   = []
    threadsRM = []
    posts     = []
    errors    = []
    for record in records
      thread = Get.threadFromRoot record.target
      postRoots = []
      for node in record.addedNodes when node.nodeType is Node.ELEMENT_NODE
        if node.matches(g.SITE.selectors.postContainer) or (node = $(g.SITE.selectors.postContainer, node))
          postRoots.push node
      n = posts.length
      Main.parsePosts postRoots, thread, posts, errors
      if posts.length > n and thread not in threads
        threads.push thread
      anyRemoved = false
      for el in record.removedNodes
        if Get.postFromRoot(el)?.nodes.root is el and !doc.contains(el)
          anyRemoved = true
          break
      if anyRemoved and thread not in threadsRM
        threadsRM.push thread
    Main.handleErrors errors if errors.length
    Main.callbackNodesDB 'Post', posts, ->
      for thread in threads
        $.event 'PostsInserted', null, thread.nodes.root
      for thread in threadsRM
        $.event 'PostsRemoved', null, thread.nodes.root
      return

  initCatalog: ->
    s = g.SITE.selectors.catalog
    if s and (board = $ s.board)
      threads = []
      errors  = []

      Main.addCatalogThreadsObserver = new MutationObserver Main.addCatalogThreads
      Main.addCatalogThreadsObserver.observe board, {childList: true}

      Main.parseCatalogThreads $$(s.thread, board), threads, errors
      Main.handleErrors errors if errors.length

      Main.callbackNodes 'CatalogThreadNative', threads

    Main.expectInitFinished = true
    $.event '4chanXInitFinished'

  parseCatalogThreads: (threadRoots, threads, errors) ->
    for threadRoot in threadRoots
      try
        thread = new CatalogThreadNative threadRoot
        if thread.thread.catalogViewNative?.nodes.root isnt threadRoot
          thread.thread.catalogViewNative = thread
          threads.push thread
      catch err
        # Skip threads that we failed to parse.
        errors.push
          message: "Parsing of Catalog Thread No.#{(threadRoot.dataset.id or threadRoot.id).match(/\d+/)} failed. Thread will be skipped."
          error: err
          html: threadRoot.outerHTML
    return

  addCatalogThreads: (records) ->
    threadRoots = []
    for record in records
      for node in record.addedNodes when node.nodeType is Node.ELEMENT_NODE and node.matches(g.SITE.selectors.catalog.thread)
        threadRoots.push node
    return unless threadRoots.length
    threads = []
    errors  = []
    Main.parseCatalogThreads threadRoots, threads, errors
    Main.handleErrors errors if errors.length
    Main.callbackNodes 'CatalogThreadNative', threads

  callbackNodes: (klass, nodes) ->
    i = 0
    cb = Callbacks[klass]
    while node = nodes[i++]
      cb.execute node
    return

  callbackNodesDB: (klass, nodes, cb) ->
    i   = 0
    cbs = Callbacks[klass]
    fn  = ->
      return false if not (node = nodes[i])
      cbs.execute node
      ++i % 25

    softTask = ->
      while fn()
        continue
      unless nodes[i]
        (cb() if cb)
        return
      setTimeout softTask, 0 

    softTask()

  handleErrors: (errors) ->
    # Detect conflicts with 4chan X v2
    if d.body and $.hasClass(d.body, 'fourchan_x') and not $.hasClass(doc, 'tainted')
      new Notice 'error', 'Error: Multiple copies of 4chan X are enabled.'
      $.addClass doc, 'tainted'

    # Detect conflicts with native extension
    if g.SITE.testNativeExtension and not $.hasClass(doc, 'tainted')
      {enabled} = g.SITE.testNativeExtension()
      if enabled
        $.addClass doc, 'tainted'
        if Conf['Disable Native Extension'] and !Main.isFirstRun
          msg = $.el 'div',
            `<%= html('Failed to disable the native extension. You may need to <a href="' + meta.faq + '#blocking-native-extension" target="_blank">block it</a>.') %>`
          new Notice 'error', msg

    unless errors instanceof Array
      error = errors
    else if errors.length is 1
      error = errors[0]
    if error
      new Notice 'error', Main.parseError(error, Main.reportLink([error])), 15
      return

    div = $.el 'div',
      `<%= html('${errors.length} errors occurred.&{Main.reportLink(errors)} [<a href="javascript:;">show</a>]') %>`
    $.on div.lastElementChild, 'click', ->
      [@textContent, logs.hidden] = if @textContent is 'show' then (
        ['hide', false]
      ) else (
        ['show', true]
      )

    logs = $.el 'div',
      hidden: true
    for error in errors
      $.add logs, Main.parseError error

    new Notice 'error', [div, logs], 30

  parseError: (data, reportLink) ->
    c.error data.message, data.error.stack
    message = $.el 'div',
      `<%= html('${data.message}?{reportLink}{&{reportLink}}') %>`
    error = $.el 'div',
      textContent: "#{data.error.name or 'Error'}: #{data.error.message or 'see console for details'}"
    lines = data.error.stack?.match(/\d+(?=:\d+\)?$)/mg)?.join().replace(/^/, ' at ') or ''
    context = $.el 'div',
      textContent: "(<%= meta.name %> <%= meta.fork %> v#{g.VERSION} #{$.platform} on #{$.engine}#{lines})"
    [message, error, context]

  reportLink: (errors) ->
    data = errors[0]
    title  = data.message
    title += " (+#{errors.length - 1} other errors)" if errors.length > 1
    details = ''
    addDetails = (text) ->
      unless encodeURIComponent(title + details + text + '\n').length > `<%= meta.newIssueMaxLength - meta.newIssue.replace(/%(title|details)/, '').length %>`
        details += text + '\n'
    addDetails """
      [Please describe the steps needed to reproduce this error.]

      Script: <%= meta.name %> <%= meta.fork %> v#{g.VERSION} #{$.platform}
      URL: #{location.href}
      User agent: #{navigator.userAgent}
    """
    if $.platform is 'userscript' and (info = if GM? then GM.info else (if GM_info? then GM_info))
      addDetails "Userscript manager: #{info.scriptHandler} #{info.version}"
    addDetails '\n' + data.error
    addDetails data.error.stack.replace(data.error.toString(), '').trim() if data.error.stack
    addDetails '\n`' + data.html + '`' if data.html
    details = details.replace /file:\/{3}.+\//g, '' # Remove local file paths
    url = '<%= meta.newIssue %>'.replace('%title', encodeURIComponent title).replace('%details', encodeURIComponent details)
    `<%= html('<span class="report-error"> [<a href="${url}" target="_blank">report</a>]</span>') %>`

  isThisPageLegit: ->
    # not 404 error page or similar.
    unless 'thisPageIsLegit' of Main
      Main.thisPageIsLegit = if g.SITE.isThisPageLegit
        g.SITE.isThisPageLegit()
      else
        !/^[45]\d\d\b/.test(document.title) and !/\.(?:json|rss)$/.test(location.pathname)
    Main.thisPageIsLegit

  ready: (cb) ->
    $.ready ->
      (cb() if Main.isThisPageLegit())

  features: [
    ['Polyfill',                  Polyfill]
    ['Board Configuration',       BoardConfig]
    ['Normalize URL',             NormalizeURL]
    ['Delay Redirect on Post',    PostRedirect]
    ['Captcha Configuration',     Captcha.replace]
    ['Image Host Rewriting',      ImageHost]
    ['Redirect',                  Redirect]
    ['Header',                    Header]
    ['Catalog Links',             CatalogLinks]
    ['Settings',                  Settings]
    ['Index Generator',           Index]
    ['Disable Autoplay',          AntiAutoplay]
    ['Announcement Hiding',       PSAHiding]
    ['Fourchan thingies',         Fourchan]
    ['Tinyboard Glue',            Tinyboard]
    ['Color User IDs',            IDColor]
    ['Highlight by User ID',      IDHighlight]
    ['Count Posts by ID',         IDPostCount]
    ['Custom CSS',                CustomCSS]
    ['Thread Links',              ThreadLinks]
    ['Linkify',                   Linkify]
    ['Reveal Spoilers',           RemoveSpoilers]
    ['Resurrect Quotes',          Quotify]
    ['Filter',                    Filter]
    ['Thread Hiding Buttons',     ThreadHiding]
    ['Reply Hiding Buttons',      PostHiding]
    ['Recursive',                 Recursive]
    ['Strike-through Quotes',     QuoteStrikeThrough]
    ['Captcha Solving Service',   Captcha.service]
    ['Quick Reply Personas',      QR.persona]
    ['Quick Reply',               QR]
    ['Cooldown',                  QR.cooldown]
    ['Post Jumper',               PostJumper]
    ['Pass Link',                 PassLink]
    ['Menu',                      Menu]
    ['Index Generator (Menu)',    Index.menu]
    ['Report Link',               ReportLink]
    ['Copy Text Link',            CopyTextLink]
    ['Thread Hiding (Menu)',      ThreadHiding.menu]
    ['Reply Hiding (Menu)',       PostHiding.menu]
    ['Delete Link',               DeleteLink]
    ['Filter (Menu)',             Filter.menu]
    ['Edit Link',                 QR.oekaki.menu]
    ['Download Link',             DownloadLink]
    ['Archive Link',              ArchiveLink]
    ['Quote Inlining',            QuoteInline]
    ['Quote Previewing',          QuotePreview]
    ['Quote Backlinks',           QuoteBacklink]
    ['Mark Quotes of You',        QuoteYou]
    ['Mark OP Quotes',            QuoteOP]
    ['Mark Cross-thread Quotes',  QuoteCT]
    ['Anonymize',                 Anonymize]
    ['Time Formatting',           Time]
    ['Relative Post Dates',       RelativeDates]
    ['File Info Formatting',      FileInfo]
    ['Fappe Tyme',                FappeTyme]
    ['Gallery',                   Gallery]
    ['Gallery (menu)',            Gallery.menu]
    ['Sauce',                     Sauce]
    ['Image Expansion',           ImageExpand]
    ['Image Expansion (Menu)',    ImageExpand.menu]
    ['Reveal Spoiler Thumbnails', RevealSpoilers]
    ['Image Loading',             ImageLoader]
    ['Image Hover',               ImageHover]
    ['Volume Control',            Volume]
    ['WEBM Metadata',             Metadata]
    ['Comment Expansion',         ExpandComment]
    ['Thread Expansion',          ExpandThread]
    ['Favicon',                   Favicon]
    ['Unread',                    Unread]
    ['Unread Line in Index',      UnreadIndex]
    ['Quote Threading',           QuoteThreading]
    ['Thread Stats',              ThreadStats]
    ['Thread Updater',            ThreadUpdater]
    ['Thread Watcher',            ThreadWatcher]
    ['Thread Watcher (Menu)',     ThreadWatcher.menu]
    ['Mark New IPs',              MarkNewIPs]
    ['Index Navigation',          Nav]
    ['Keybinds',                  Keybinds]
    ['Banner',                    Banner]
    ['Announcements',             PSA]
    ['Flash Features',            Flash]
    ['Reply Pruning',             ReplyPruning]
    ['Mod Contact Links',         ModContact]
  ]

<% if (readJSON('/.tests_enabled')) { %>
Main.features.push ['Build Test', Test]
<% } %>
