$(document).ready( ->
  $editors = $('.editor-container')
  window.editor = new Scribe.Editor($editors.get(0))
  editorToolbar = new Scribe.Toolbar('editor-toolbar', editor)
  window.ScribeDriver =
    resetScribe: ->
      $('.editor-container').html($('#editor-cache').html())
      window.editor = new Scribe.Editor($editors.get(0))

    getActiveFormats: ->
      actives = $('#editor-toolbar > .active')
      _.map(actives, (elem) ->
        $(elem).html().toLowerCase()
      )

    cleanup: (delta) ->
      NBSP_FILTER = /\u00a0/g
      for op in delta.ops
        if window.Tandem.Delta.isInsert op
          op.value = op.value.replace(NBSP_FILTER, " ")
      return delta

    setDeltaReplay: (docDelta, deltaRef) ->
      d = JSON.parse(docDelta)
      window.ScribeDriver[deltaRef] =
        new window.Tandem.Delta(d.startLength, d.endLength, d.ops)

    createDelta: (template) ->
      startLength = template['startLength']
      endLength = template['endLength']
      ops = _.map(template['ops'], (op) ->
        if op['value']?
          return new window.Tandem.InsertOp(op['value'], op['attributes'])
        else if op['start']?
          return new window.Tandem.RetainOp(op['start'], op['end'], op['attributes'])
        else
          throw "Receive op that is not insert or retain: #{JSON.stringify(op)}"
      )
      return new window.Tandem.Delta(startLength, endLength, ops)

    autoFormatDelta: (delta) ->
      appendingToLine = (index) ->
        op = window.ScribeDriver.docDelta.getOpsAt(index, 1)
        return op.length > 0 and _.first(op).value == "\n"

      prependingToLine = (index) ->
        op = window.ScribeDriver.docDelta.getOpsAt(index - 1, 1)
        return index == 0 or (op.length > 0 and _.first(op).value == "\n")

      getAttrsAt = (index) ->
        attrs = {}
        op = _.first(window.ScribeDriver.docDelta.getOpsAt(index, 1))
        attrs = op.attributes if op
        return attrs

      index = 0
      for op, opIndex in delta.ops
        if window.Tandem.Delta.isInsert op
          precedingAttrs = followingAttrs = {}
          if appendingToLine(index)
            precedingAttrs = getAttrsAt(index - 1)
            delete precedingAttrs['link'] if precedingAttrs['link']?
          else if prependingToLine(index)
            precedingAttrs = followingAttrs = getAttrsAt(index)
            delete precedingAttrs['link'] if precedingAttrs['link']?
            delete followingAttrs['link'] if followingAttrs['link']?
          else
            precedingAttrs = getAttrsAt(index - 1)
            followingAttrs = getAttrsAt(index)
            delete followingAttrs['link'] if followingAttrs['link']
          chunks = op.value.split("\n")
          head = _.first(chunks)
          tail = _.tail(chunks)
          head = new window.Tandem.InsertOp(head, precedingAttrs)
          tail = _.map(tail, (elem) ->
            return new window.Tandem.InsertOp(elem, followingAttrs)
          )
          final = [head]
          for elem in tail
            final.push(new window.Tandem.InsertOp("\n"))
            final.push(elem)
          delta.ops.splice(opIndex, 1, final...)
          delta.compact()
          return delta
        else
          index += op.getLength()

      return delta

    createRandomDelta: ->
      randomDelta = window.Tandem.DeltaGen.getRandomDelta(window.ScribeDriver.docDelta, 1)
      return window.ScribeDriver.autoFormatDelta(randomDelta)

    initializeScribe: ->
      window.editor.setDelta(window.ScribeDriver.docDelta)

    checkConsistency: ->
      actual = window.ScribeDriver.cleanup(editor.getDelta())
      consistent = window.ScribeDriver.docDelta.compose(window.ScribeDriver.currentDelta).isEqual(actual)
      return {
        success: consistent,
        actual_delta: JSON.stringify(actual)
      }
)


keep keep keep keep keep replace keep keep keep keep keep

ScribeDOM           = require('./dom')
ScribeDefaultTheme  = require('./themes/default')
ScribeDocument      = require('./document')
ScribeKeyboard      = require('./keyboard')
ScribeLine          = require('./line')
ScribeNormalizer    = require('./normalizer')
ScribePasteManager  = require('./paste-manager')
ScribeRenderer      = require('./renderer')
ScribeSelection     = require('./selection')
ScribeUndoManager   = require('./undo-manager')
Tandem              = require('tandem-core')

DEFAULT_API_OPTIONS = { silent: false, source: 'api' }

_deleteAt = (index, length) ->
  return if length <= 0
  @selection.preserve(index, -1 * length, =>
    [firstLine, offset] = @doc.findLineAtOffset(index)
    curLine = firstLine
    while curLine? and length > 0
      deleteLength = Math.min(length, curLine.length - offset)
      nextLine = curLine.next
      if curLine.length == deleteLength
        if curLine == @doc.lines.first and curLine == @doc.lines.last
          curLine.node.innerHTML = ''
          curLine.trailingNewline = false
          curLine.rebuild()
        else
          ScribeDOM.removeNode(curLine.node)
          @doc.removeLine(curLine)
      else
        curLine.deleteText(offset, deleteLength)
      length -= deleteLength
      curLine = nextLine
      offset = 0
    if firstLine? and !firstLine.trailingNewline
      @doc.mergeLines(firstLine, firstLine.next)
  )

_forceTrailingNewline = ->
  unless @doc.lines.last?.trailingNewline
    this.insertAt(this.getLength(), "\n")

# formatAt (Number index, Number length, String name, Mixed value) ->
_formatAt = (index, length, name, value) ->
  @selection.preserve(index, 0, =>
    [line, offset] = @doc.findLineAtOffset(index)
    while line? and length > 0
      if ScribeLine.FORMATS[name]?
        # If newline character is being applied with formatting
        if length > line.length - offset
          line.format(name, value)
      else
        line.formatText(offset, Math.min(length, line.length - offset), name, value)
      length -= (line.length - offset)
      offset = 0
      line = line.next
  )

_insertAt = (index, text, formatting = {}) ->
  @selection.preserve(index, text.length, =>
    text = text.replace(/\r\n/g, '\n')
    text = text.replace(/\r/g, '\n')
    lineTexts = text.split('\n')
    [line, offset] = @doc.findLineAtOffset(index)
    if line == @doc.lines.last and offset >= line.length and line.trailingNewline
      line = @doc.splitLine(line, line.length)
      line.trailingNewline = false
      line.resetContent()
      offset = 0
    _.each(lineTexts, (lineText, i) =>
      line.insertText(offset, lineText, formatting)
      if i < lineTexts.length - 1       # Are there more lines to insert?
        line = @doc.splitLine(line, offset + lineText.length)
        offset = 0
    )
  )

_preformat = (name, value) ->
  format = @doc.formatManager.formats[name]
  throw new Error("Unsupported format #{name} #{value}") unless format?
  format.preformat(value)

_trackDelta = (fn, options) ->
  oldIndex = @savedRange?.start.index
  fn()
  newDelta = @doc.toDelta()
  try
    newIndex = @selection.getRange()?.start.index     # this.getSelection() triggers infinite loop
    if oldIndex? and newIndex? and oldIndex <= @delta.endLength and newIndex <= newDelta.endLength
      [oldLeftDelta, oldRightDelta] = @delta.split(oldIndex)
      [newLeftDelta, newRightDelta] = newDelta.split(newIndex)
      decomposeLeft = newLeftDelta.decompose(oldLeftDelta)
      decomposeRight = newRightDelta.decompose(oldRightDelta)
      decomposeA = decomposeLeft.merge(decomposeRight)
  catch ignored
  decomposeB = newDelta.decompose(@delta)
  if decomposeA and decomposeB
    decompose = if decomposeA.ops.length < decomposeB.ops.length then decomposeA else decomposeB
  else
    decompose = decomposeA or decomposeB
  return decompose

_update = ->
  delta = _trackDelta.call(this, =>
    this.doSilently( =>
      ScribeNormalizer.normalizeEmpty(@root)
      @selection.preserve( =>
        ScribeNormalizer.breakBlocks(@root)
        lines = @doc.lines.toArray()
        lineNode = @root.firstChild
        _.each(lines, (line, index) =>
          while line.node != lineNode
            if line.node.parentNode == @root
              @doc.normalizer.normalizeLine(lineNode)
              newLine = @doc.insertLineBefore(lineNode, line)
              lineNode = lineNode.nextSibling
            else
              return @doc.removeLine(line)
          @doc.updateLine(line)
          lineNode = lineNode.nextSibling
        )
        while lineNode != null
          @doc.normalizer.normalizeLine(lineNode)
          newLine = @doc.appendLine(lineNode)
          lineNode = lineNode.nextSibling
      )
    )
  )
  return if delta.isIdentity() then false else delta

class ScribeEditor extends EventEmitter2
  @editors: []

  @ID_PREFIX: 'editor-'

  @DEFAULTS:
    cursor: 0
    enabled: true
    pollInterval: 100
    formatManager: {}
    renderer: {}
    undoManager: {}
    modules: {}
    theme: ScribeDefaultTheme

  @events:
    API_TEXT_CHANGE  : 'api-text-change'
    FOCUS_CHANGE     : 'focus-change'
    PRE_EVENT        : 'pre-event'
    PREFORMAT        : 'preformat'
    POST_EVENT       : 'post-event'
    SELECTION_CHANGE : 'selection-change'
    USER_TEXT_CHANGE : 'user-text-change'

  constructor: (@iframeContainer, options = {}) ->
    @id = _.uniqueId(ScribeEditor.ID_PREFIX)
    @options = _.defaults(options, ScribeEditor.DEFAULTS)
    @options.renderer['id'] = @id
    @iframeContainer = document.getElementById(@iframeContainer) if _.isString(@iframeContainer)
    this.reset(true)
    @theme = new @options.theme(this)
    @modules = _.reduce(@options.modules, (modules, options, name) =>
      modules[name] = @theme.addModule(name, options)
      return modules
    , {})
    setInterval( =>
      delta = this.update()
      if delta
        oldDelta = @delta
        @delta = oldDelta.compose(delta)
        this.emit(ScribeEditor.events.USER_TEXT_CHANGE, delta, @delta)
      @selection.update(delta)
    , @options.pollInterval)
    this.on(ScribeEditor.events.SELECTION_CHANGE, (range) =>
      @savedRange = range
    )
    this.enable() if @options.enabled

  disable: ->
    this.doSilently( =>
      @root.setAttribute('contenteditable', false)
    )

  enable: ->
    this.doSilently( =>
      @root.setAttribute('contenteditable', true)
    )

  reset: (keepHTML = false) ->
    @ignoreDomChanges = true
    @options.renderer.keepHTML = keepHTML
    @iframeContainer.innerHTML = @root.innerHTML if @root?
    @renderer = new ScribeRenderer(@iframeContainer, @options)
    @contentWindow = @renderer.iframe.contentWindow
    @root = @renderer.root
    @doc = new ScribeDocument(@root, @options)
    @delta = @doc.toDelta()
    @keyboard = new ScribeKeyboard(this)
    @selection = new ScribeSelection(this)
    @undoManager = new ScribeUndoManager(this, @options)
    @pasteManager = new ScribePasteManager(this)
    @ignoreDomChanges = false
    ScribeEditor.editors.push(this)

  applyDelta: (delta, options = {}) ->
    options = _.defaults(options, DEFAULT_API_OPTIONS)
    return if delta.isIdentity()
    # Make exception for systems that assume editors start with empty text
    if delta.startLength == 0 and this.getLength() == 1
      return this.setDelta(delta, options)
    this.doSilently( =>
      localDelta = this.update()
      if localDelta
        @delta = @delta.compose(localDelta)
        tempDelta = localDelta
        localDelta = localDelta.follows(delta, true)
        delta = delta.follows(tempDelta, false)
      unless delta.isIdentity()   # Follows may have turned delta into the identity
        throw new Error("Trying to apply delta to incorrect doc length") unless delta.startLength == @delta.endLength
        delta.apply(_insertAt, _deleteAt, _formatAt, this)
        oldDelta = @delta
        @delta = oldDelta.compose(delta)
        unless options.silent
          eventName = if options.source == 'api' then ScribeEditor.events.API_TEXT_CHANGE else ScribeEditor.events.USER_TEXT_CHANGE
          this.emit(eventName, delta)
      if localDelta and !localDelta.isIdentity()
        this.emit(ScribeEditor.events.USER_TEXT_CHANGE, localDelta)
      # TODO enable when we figure out addNewline issue, currently will fail if we do add newline
      # console.assert(delta.endLength == this.getLength(), "Applying delta resulted in incorrect end length", delta, this.getLength())
      _forceTrailingNewline.call(this)
    )

  emit: (eventName, args...) ->
    super(ScribeEditor.events.PRE_EVENT, eventName, args...)
    super(eventName, args...)
    super(ScribeEditor.events.POST_EVENT, eventName, args...)

  deleteAt: (index, length, options = {}) ->
    this.applyDelta(Tandem.Delta.makeDeleteDelta(@delta.endLength, index, length), options)

  doSilently: (fn) ->
    oldIgnoreDomChange = @ignoreDomChanges
    @ignoreDomChanges = true
    fn()
    @ignoreDomChanges = oldIgnoreDomChange

  formatAt: (index, length, name, value, options = {}) ->
    if length > 0
      attribute = {}
      attribute[name] = value
      this.applyDelta(Tandem.Delta.makeRetainDelta(@delta.endLength, index, length, attribute), options)
    else
      _preformat.call(this, name, value)

  getAt: (index = 0, length = null) ->
    length = this.getLength() - index unless length?
    return _.map(this.getDelta().getOpsAt(index, length), (op) ->
      return op.value
    ).join('')

  getDelta: ->
    return @delta

  getLength: ->
    return @delta.endLength

  getSelection: ->
    return @selection.getRange()

  insertAt: (index, text, formatting = {}, options = {}) ->
    this.applyDelta(Tandem.Delta.makeInsertDelta(@delta.endLength, index, text, formatting), options)

  setDelta: (delta) ->
    oldLength = delta.startLength
    delta.startLength = this.getLength()
    this.applyDelta(delta, { silent: true })
    @undoManager.clear()
    delta.startLength = oldLength

  setSelection: (range, silent = false) ->
    @selection.setRange(range, silent)

  update: ->
    if @innerHTML != @root.innerHTML
      delta = _update.call(this)
      @innerHTML = @root.innerHTML
      return delta
    else
      return false

module.exports = ScribeEditor


describe('Leaf', ->
  it('Leaf Iterator', ->
    lines = ['

', '
12
', '
45
', '

', '

', '

78

', '

']
    $('#test-container').html(lines.join(''))
    editor = new ScribeEditor('test-container')
    lines = editor.doc.lines.toArray()
    leaves = _.reduce(lines, (leaves, line) ->
      leaves.push(_.map(line.leaves.toArray(), (leaf) ->
        return leaf.id
      ))
      return leaves
    , [])
    leaves = _.flatten(leaves)
    leafIterator = new ScribeLeafIterator(lines[0].leaves.first, lines[lines.length-1].leaves.last)
    arr = _.map(leafIterator.toArray(), (leaf) ->
      return leaf.id
    )
    expect(_.isEqual(arr, leaves)).to.be(true)
  )
)


_     = require('underscore')._
base  = require('./all.conf')

browsers =
  'mac-chrome'  : ['OS X', 'Mountain Lion', 'chrome', '30.0 beta']
  'mac-firefox' : ['OS X', 'Mountain Lion', 'firefox', 'latest']
  'mac-safari'  : ['OS X', 'Mountain Lion', 'safari', 'latest']

  'windows-8-chrome'  : ['Windows', '8', 'chrome', '30.0 beta']
  'windows-8-firefox' : ['Windows', '8', 'firefox', 'latest']
  'windows-8-safari'  : ['Windows', '8', 'safari', 'latest']
  'windows-8-ie-10'   : ['Windows', '8', 'ie', '10.0']

  'windows-7-ie-9'    : ['Windows', '7', 'ie', '9.0']
  'windows-7-ie-8'    : ['Windows', '7', 'ie', '8.0']
browserList = []
customLaunchers = _.reduce(browsers, (memo, browser, name) ->
  browserList.push(name)
  [osName, osVersion, browserName, browserVersion] = browser
  memo[name] =
    base: 'BrowserStack'
    browser: browserName
    browser_version: browserVersion
    os: osName
    os_version: osVersion
  return memo
, {})

module.exports = (config) ->
  base.call(this, config)
  config.set(
    browsers: browserList
    customLaunchers: customLaunchers
    exclude: ['tests/mocha/editor.js']
    reporters: ['dots']
  )


require_relative '../lib/scribe_driver'

describe "Test undo redo" do
  include ScribeDriver

  # Amount of time to sleep between edits to prevent the undo manager coalescing edits
  COALESCE_WINDOW = 1

  before do
    setup_test_suite
    ScribeDriver::JS.set_doc_delta(@driver)
  end

  after do
    @driver.quit
  end

  def without_coalescing(edit)
    edit.call
    sleep COALESCE_WINDOW
  end

  it "should undo/redo typing" do
    start_delta = { "startLength" => 0,
      "endLength" => 1, "ops" => [{ "value" => "\n" }] }
    end_delta = { "startLength" => 0,
      "endLength" => 4, "ops" => [{ "value" => "abc\n" }] }
    @adapter.type_text "abc"
    @adapter.undo
    assert ScribeDriver::JS.editor_delta_equals(start_delta), "Failed undoing typing 'abc'."
    @adapter.redo
    assert ScribeDriver::JS.editor_delta_equals(end_delta), "Failed redoing typing 'abc'."
  end

  it "should undo redo deletes" do
    text = "abc"
    undone_doc = { "startLength" => 0,
      "endLength" => 4, "ops" => [{ "value" => "abc\n" }] }
    redone_doc = { "startLength" => 0,
      "endLength" => 1, "ops" => [{ "value" => "\n" }] }
    without_coalescing Proc.new { @adapter.type_text text }
    @adapter.delete_at 0, text.length
    @adapter.undo
    assert ScribeDriver::JS.editor_delta_equals(undone_doc), "Failed undoing deletion of entire doc."
    @adapter.redo
    assert ScribeDriver::JS.editor_delta_equals(redone_doc), "Failed redoing deletion of entire doc."
  end

  it "should undo redo formatting" do
    text = "abc"
    undone_doc = { "startLength" => 0,
      "endLength" => 4, "ops" => [{ "value" => "abc\n" }] }
    redone_doc = { "startLength" => 0,
      "endLength" => 4, "ops" => [{"value" => "abc", "attributes" => {"bold" => true}}, { "value" => "\n" }] }
    without_coalescing Proc.new { @adapter.type_text text }
    @adapter.format_at 0, 3, {:bold => true}
    @adapter.undo
    assert ScribeDriver::JS.editor_delta_equals(undone_doc), "Failed undoing bolding of text."
    @adapter.redo
    assert ScribeDriver::JS.editor_delta_equals(redone_doc), "Failed redoing bolding of text."
  end

  it "should undo redo a chain of edits" do
    text = "abc"
    edits = [ Proc.new { @adapter.type_text text },
              Proc.new { @adapter.format_at 0, 3, {:bold => true} },
              Proc.new { @adapter.format_at 0, 3, {:italic => true} }]
    edits.each do |edit|
      without_coalescing edit
    end

    expected_deltas = [{ "startLength" => 0,
                         "endLength" => 4,
                         "ops" => [{ "value" => "abc", "attributes" => {"bold" => true}},
                                   {"value" => "\n" }] },
                       { "startLength" => 0,
                         "endLength" => 4,
                         "ops" => [{ "value" => "abc\n" }] },
                       { "startLength" => 0,
                         "endLength" => 1,
                         "ops" => [{"value" => "\n" }] }
    ]

    expected_deltas.each do |expected|
      @adapter.undo
      assert ScribeDriver::JS.editor_delta_equals(expected), "Failed undoing edit."
    end

    expected_deltas = [{ "startLength" => 0,
                         "endLength" => 4,
                         "ops" => [{"value" => "abc\n" }] },
                       { "startLength" => 0,
                         "endLength" => 4,
                         "ops" => [{ "value" => "abc", "attributes" => {"bold" => true}},
                                     {"value" => "\n" }] },
                       { "startLength" => 0,
                         "endLength" => 4,
                         "ops" => [{ "value" => "abc", "attributes" => {"bold" => true, "italic" => true}},
                                   {"value" => "\n" }] }
    ]

    expected_deltas.each do |expected|
      @adapter.redo
      assert ScribeDriver::JS.editor_delta_equals(expected), "Failed redoing edit."
    end
  end

  it "should undo redo replaced text" do
    text = "abc"
    new_text = "zzz"

    start_delta = { "startLength" => 0,
      "endLength" => 4, "ops" => [{ "value" => "abc\n" }] }
    end_delta = { "startLength" => 0,
      "endLength" => 4, "ops" => [{ "value" => "zzz\n" }] }

    edits = [ Proc.new { @adapter.type_text text },
             Proc.new { @adapter.move_cursor 0 },
             Proc.new { @adapter.highlight 3 } ]
    edits.each do |edit|
      without_coalescing edit
    end

    @adapter.type_text new_text
    @adapter.undo
    assert ScribeDriver::JS.editor_delta_equals(start_delta), "Failed undoing overwriting of 'abc' with 'zzz'."
    @adapter.redo
    assert ScribeDriver::JS.editor_delta_equals(end_delta), "Failed redoing overwriting of 'abc' with 'zzz'."
  end
end


keep keep keep keep keep replace keep keep keep keep

ScribeDOM   = require('./dom')
ScribeUtils = require('./utils')

class ScribeNormalizer
  # Missing rule implies removal
  @TAG_RULES:
    'A'         : {}
    'ADDRESSS'  : {rename: 'div'}
    'B'         : {}
    'BLOCKQUOTE': {rename: 'div'}
    'BR'        : {}
    'BIG'       : {rename: 'span'}
    'CENTER'    : {rename: 'span'}
    'DD'        : {rename: 'div'}
    'DEL'       : {rename: 's'}
    'DIV'       : {}
    'DL'        : {rename: 'div'}
    'EM'        : {rename: 'i'}
    'FONT'      : (formatManager, node) ->
      resultNode = ScribeDOM.unwrap(node)
      _.each({ color: 'fore-color', face: 'font-name', size: 'font-size' }, (format, attr) ->
        if node.hasAttribute(attr)
          formatNode = formatManager.createFormatContainer(format, node.getAttribute(attr))
          resultNode = ScribeDOM.wrap(formatNode, resultNode)
      )
      return resultNode
    'H1'        : {rename: 'div'}
    'H2'        : {rename: 'div'}
    'H3'        : {rename: 'div'}
    'H4'        : {rename: 'div'}
    'H5'        : {rename: 'div'}
    'H6'        : {rename: 'div'}
    'HR'        : {rename: 'br'}
    'I'         : {}
    'INS'       : {rename: 'span'}
    'LI'        : {rename: 'div'}
    'OL'        : {rename: 'div'}
    'P'         : {rename: 'div'}
    'PRE'       : {rename: 'div'}
    'S'         : {}
    'SMALL'     : {rename: 'span'}
    'SPAN'      : {}
    'STRIKE'    : {rename: 's'}
    'STRONG'    : {rename: 'b'}
    'TABLE'     : {rename: 'div'}
    'TBODY'     : {rename: 'div'}
    'TD'        : {rename: 'span'}
    'TFOOT'     : {rename: 'div'}
    'TH'        : {rename: 'span'}
    'THEAD'     : {rename: 'div'}
    'TR'        : {rename: 'div'}
    'U'         : {}
    'UL'        : {rename: 'div'}

  @breakBlocks: (root) ->
    ScribeNormalizer.groupBlocks(root)
    _.each(root.querySelectorAll('br'), (node) =>
      ScribeNormalizer.normalizeBreak(node, root)
    )
    _.each(root.childNodes, (childNode) =>
      ScribeNormalizer.breakLine(childNode)
    )

  @breakLine: (lineNode) ->
    return if lineNode.childNodes.length == 1 and lineNode.firstChild.tagName == 'BR'
    ScribeUtils.traversePostorder(lineNode, (node) =>
      if ScribeUtils.isBlock(node)
        node = ScribeDOM.switchTag(node, 'div') if node.tagName != 'DIV'
        if node.nextSibling?
          line = lineNode.ownerDocument.createElement('div')
          lineNode.parentNode.insertBefore(line, lineNode.nextSibling)
          while node.nextSibling?
            line.appendChild(node.nextSibling)
          ScribeNormalizer.breakLine(line)
        return ScribeDOM.unwrap(node)
      else
        return node
    )

  @groupBlocks: (root) ->
    curLine = root.firstChild
    while curLine?
      if ScribeUtils.isBlock(curLine)
        curLine = curLine.nextSibling
      else
        line = root.ownerDocument.createElement('div')
        root.insertBefore(line, curLine)
        while curLine? and !ScribeUtils.isBlock(curLine)
          nextLine = curLine.nextSibling
          line.appendChild(curLine)
          curLine = nextLine
        curLine = line

  @normalizeBreak: (node, root) ->
    return if node == root
    if node.previousSibling?
      if node.nextSibling?
        ScribeUtils.splitBefore(node, root)
      node.parentNode.removeChild(node)
    else if node.nextSibling?
      if ScribeUtils.splitBefore(node.nextSibling, root)
        ScribeNormalizer.normalizeBreak(node, root)
    else if node.parentNode != root and node.parentNode.parentNode != root
      # Make sure

 is not unintentionally unwrapped
      ScribeDOM.unwrap(node.parentNode)
      ScribeNormalizer.normalizeBreak(node, root)

  @normalizeEmpty: (root) ->
    return unless ScribeUtils.isIE()
    _.each(root.querySelectorAll('br'), (node) ->
      # See IE's newline section in doc/browser-quirks
      ScribeDOM.removeNode(node) if node.previousSibling? or node.nextSibling?
    )

  @normalizeHtml: (html) ->
    # Remove leading and tailing whitespace
    html = html.replace(/^\s\s*/, '').replace(/\s\s*$/, '')
    # Remove whitespace between tags
    html = html.replace(/\>\s+\</g, '><')
    # Standardize br
    html = html.replace(/
<\/br>/, '
')
    return html

  @requireLeaf: (lineNode) ->
    unless lineNode.childNodes.length > 0
      if lineNode.tagName == 'OL' || lineNode.tagName == 'UL'
        lineNode.appendChild(lineNode.ownerDocument.createElement('li'))
        lineNode = lineNode.firstChild
      lineNode.appendChild(lineNode.ownerDocument.createElement('br'))

  @wrapText: (lineNode) ->
    ScribeUtils.traversePreorder(lineNode, 0, (node) =>
      ScribeDOM.normalize(node)
      if node.nodeType == ScribeDOM.TEXT_NODE && (node.nextSibling? || node.previousSibling? || node.parentNode == lineNode or node.parentNode.tagName == 'LI')
        span = node.ownerDocument.createElement('span')
        ScribeDOM.wrap(span, node)
        node = span
      return node
    )

  constructor: (@container, @formatManager) ->

  applyRules: (root) ->
    ScribeUtils.traversePreorder(root, 0, (node, index) =>
      if node.nodeType == ScribeDOM.ELEMENT_NODE
        rules = ScribeNormalizer.TAG_RULES[node.tagName]
        if rules?
          if _.isFunction(rules)
            node = rules.call(null, @formatManager, node)
          else if _.isObject(rules)
            _.each(rules, (data, rule) ->
              switch rule
                when 'rename' then node = ScribeDOM.switchTag(node, data)
                else return
            )
        else
          node = ScribeDOM.unwrap(node)
      return node
    )

  normalizeDoc: ->
    @container.appendChild(@container.ownerDocument.createElement('div')) unless @container.firstChild
    this.applyRules(@container)
    ScribeNormalizer.breakBlocks(@container)
    _.each(@container.childNodes, (lineNode) =>
      this.normalizeLine(lineNode)
    )

  normalizeLine: (lineNode) ->
    return if lineNode.childNodes.length == 1 and lineNode.childNodes[0].tagName == 'BR'
    this.applyRules(lineNode)
    this.normalizeTags(lineNode)
    ScribeNormalizer.requireLeaf(lineNode)
    this.removeRedundant(lineNode)
    ScribeNormalizer.wrapText(lineNode)

  normalizeTags: (lineNode) ->
    ScribeUtils.traversePreorder(lineNode, 0, (node) =>
      containerNode = node
      [nodeFormat, nodeValue] = @formatManager.getFormat(node)
      if _.isArray(nodeFormat)
        _.each(nodeFormat.slice(1), (format, i) =>
          if @formatManager.formats[format]?
            container = @formatManager.formats[format].createContainer(nodeValue[i+1])
            containerNode = ScribeDOM.wrap(container, node)
        )
        nodeFormat = nodeFormat[0]
        nodeValue = nodeValue[0]
      if @formatManager.formats[nodeFormat]?
        @formatManager.formats[nodeFormat].clean(node)
      else
        ScribeDOM.removeAttributes(node)
      return containerNode
    )

  removeRedundant: (lineNode) ->
    nodes = [lineNode]
    attributes = [{}]
    ScribeUtils.traversePreorder(lineNode, 0, (node) =>
      [formatName, formatValue] = @formatManager.getFormat(node)
      parentAttributes = attributes[_.indexOf(nodes, node.parentNode)]
      redundant = do (node) =>
        return false unless node.nodeType == ScribeDOM.ELEMENT_NODE
        return node.tagName != 'BR' or node.parentNode.childNodes.length > 1 if ScribeUtils.getNodeLength(node) == 0
        # Parent format value will overwrite child's so no need to check formatValue
        return parentAttributes[formatName]? if formatName?
        return false unless node.tagName == 'SPAN'
        # Check if childNodes need us
        return true if node.childNodes.length == 0 or !_.any(node.childNodes, (child) -> child.nodeType != ScribeDOM.ELEMENT_NODE)
        # Check if parent needs us
        return true if node.previousSibling == null && node.nextSibling == null and node.parentNode != lineNode and node.parentNode.tagName != 'LI'
        return false
      if redundant
        node = ScribeDOM.unwrap(node)
      if node?
        nodes.push(node)
        if formatName?
          nodeAttributes = _.clone(parentAttributes)
          nodeAttributes[formatName] = formatValue
          attributes.push(nodeAttributes)
        else
          attributes.push(parentAttributes)
      return node
    )

module.exports = ScribeNormalizer


WARNING
===

Please do not publicize this repository in any way. There are a few known documentation gaps and browser quirks we wish to address before publication. Thank you for your patience.

Scribe Rich Text Editor
===

Cross platform rich text editor built with coauthoring in mind.

[![Build Status](https://secure.travis-ci.org/stypi/scribe.png)](http://travis-ci.org/stypi/scribe)

How to Use
---

```javascript
var editor = new Scribe.Editor('editor')      // 'editor' is id of dom container
```

Editor API
---

### Text Operations

- editor.getAt(index, length) - get length text starting from index, default to entire document
- editor.insertAt(index, text, formatting) - insert text at index
- editor.deleteAt(index, length) - delete length characters starting from index
- editor.formatAt(index, length, name, value) - apply formatting to length characters starting at index

### Delta operations

See [Tandem](https://github.com/stypi/tandem) for details on deltas.

- editor.applyDelta(delta) - applies delta to editor
- editor.getDelta() - returns delta that describes contents of editor
- editor.setDelta(delta) - sets editor to delta, assuming delta contains only insert operations

### Events

Events names are accessible through Scribe.Editor.events

- editor.on('api-text-change', function(delta))
- editor.on('user-text-change', function(delta))
- editor.on('selection-change', function(range)) - range is the new range of the selection

Toolbar API
---

You can create a toolbar to assist in formatting the editor.

```javascript
var toolbar = new Scribe.Toolbar('toolbar', editor)   // 'toolbar' is the id of dom container
```

Any DOM node with the following classes that is clicked will trigger the corresponding format change:

- bold
- italic
- strike
- underline
- link

Toolbar will listen to selection changes and will add/remove the 'active' class to the corresponding DOM node.

Any DOM node with the following classes that is changed (change DOM event, ex. <select>) will trigger the corresponding format change:

- background - background color
- color - text color
- family - font family
- size - font size

Toolbar will listen to selection changes and will set the corresponding DOM node value to the selected text value.

Local Development
---

### Installation

Install dependencies

    npm install -g grunt-cli
    npm install
    bundle install

### Building

We use grunt to compile coffeescript, stylus, and jade

    grunt

### Version numbers

Until we write a script, version numbers will have to be updated in the following files:

- Gruntfile.coffee
- index.coffee
- package.json

Testing
---

### Unit tests

Visit build/tests/unit.html to run unit tests or run:

    make test

### Browser Testing

You can run the unit tests on all browsers on your machine by running:

    make test-all

You can also test on different operating systems or browsers on Browserstack:

    export BROWSER_STACK_USERNAME=yourusername
    export BROWSER_STACK_ACCESS_KEY=yourapikey
    make test-remote

### Webdriver Testing
For webdriver unit tests, run:

    make webdriver-unit-chrome
    make webdriver-unit-firefox

For the fuzzer, run:

    make webdriver-fuzzer-[browser]
    make webdriver-fuzzer-[browser]-replay _replay_file_

Possible values for ```_browser_``` are ```chrome```, ```internet_explorer```, or ```firefox```.
```_replay_file_``` must be the absolute path to the file the fuzzer creates after a failed run.
This file contains the final edit that caused the fuzzer to fail, so that you can replay the edit until you fix the bug.

In order to run any Webdriver tests against Chrome or IE, you'll need to download
[ChromeDriver](https://code.google.com/p/chromedriver/downloads/list) or
[InternetExplorerDriver](https://code.google.com/p/selenium/downloads/list) and add it to your system's path.
Support for Firefox is built in and requires no special downloads.

The fuzzer currently works against IE 9 & 10, Firefox 19 - 21, and Chrome 26.


require "selenium-webdriver"
require 'colorize'
require 'debugger'
gem "minitest"
require 'minitest/autorun'
require 'minitest/pride'
require_relative '../lib/minitest/focus'
require_relative '../lib/webdriver_adapter'
require_relative '../lib/minitest/focus'
module ScribeDriver
  module JS
    def self.execute_js(src, args = nil)
      ScribeDriver.driver.switch_to.default_content
      result = ScribeDriver.driver.execute_script(src, *args)
      ScribeDriver.driver.switch_to.frame(ScribeDriver.driver.find_element(:tag_name, "iframe"))
      return result
    end

    def self.get_cursor_position
      return self.execute_js "return window.editor.getSelection().start.index"
    end

    def self.get_as_str(ref)
      return self.execute_js "return JSON.stringify(window.ScribeDriver['#{ref}'])"
    end

    def self.get_expected_as_str
      src = "return JSON.stringify(window.ScribeDriver.docDelta.compose(window.ScribeDriver.currentDelta));"
      return self.execute_js src
    end

    def self.get_cur_doc_delta_as_str
      return execute_js "return JSON.stringify(editor.getDelta());"
    end

    def self.set_scribe_delta(driver)
      return execute_js "window.ScribeDriver.initializeScribe()"
    end

    def self.editor_delta_equals(delta)
      return self.execute_js "return window.ScribeDriver.createDelta(#{delta.to_json}).isEqual(window.editor.getDelta())"
    end

    def self.make_insert_delta(startLength, index, value, attributes)
      return self.execute_js "return window.ScribeDriver.autoFormatDelta(window.Tandem.Delta.makeInsertDelta(#{startLength}, #{index}, '#{value}', #{attributes}));"
    end

    def self.set_scribe_delta(delta)
      self.execute_js "window.editor.setDelta(window.ScribeDriver.createDelta(#{delta.to_json}));"
    end

    def self.set_doc_delta(delta = nil)
      if not delta.nil?
        self.execute_js "window.ScribeDriver.docDelta = window.ScribeDriver.createDelta(#{delta.to_json});"
      else
        self.execute_js "window.ScribeDriver.docDelta = window.ScribeDriver.cleanup(editor.getDelta());"
      end
    end

    def self.get_doc_length
      return self.execute_js "return window.editor.getLength();"
    end

    def self.check_consistency
      return self.execute_js "return window.ScribeDriver.checkConsistency();"
    end

    def self.set_current_delta(delta)
      return self.execute_js "window.ScribeDriver.currentDelta = window.ScribeDriver.createDelta(#{delta.to_json})"
    end
  end

  def self.driver
    @@driver
  end

  #############################################################################
  # WebDriver helpers
  #############################################################################
  def setup_test_suite
    browser = :chrome
    browser = ARGV[0].to_sym if ARGV.length >= 1
    editor_url = "file://#{File.join(File.expand_path(__FILE__),
      '../../../..', 'build/tests/webdriver/webdriver.html')}"
    @driver = ScribeDriver.create_scribe_driver(browser, editor_url)
    @editor = @driver.find_element(:class, "editor")
    @adapter = WebdriverAdapter.new @driver, @editor
    @adapter.focus()
  end

  def reset_scribe(delta)
    ScribeDriver::JS.set_doc_delta delta
    ScribeDriver::JS.set_scribe_delta delta
    @adapter.doc_length = ScribeDriver::JS.get_doc_length
  end

  def apply_delta(delta, err_msg, toolbar_only = false)
    ScribeDriver::JS.set_current_delta(delta)
    @adapter.apply_delta(delta, toolbar_only)
    actual_delta, success = ScribeDriver::JS.check_consistency().values_at("actual_delta", "success")
    success.must_equal true, err_msg
  end

  # Credit: http://jkotests.wordpress.com/2013/06/26/silencing-diagnostic-output-when-starting-webdrivers/
  class Selenium::WebDriver::Chrome::Service
    old_initialize = instance_method(:initialize)
    define_method(:initialize) do |executable_path, port, *extra_args|
      old_initialize.bind(self).call(executable_path, port, '--silent', *extra_args)
    end
  end

  class Selenium::WebDriver::IE::Server
    old_server_args = instance_method(:server_args)
    define_method(:server_args) do
      old_server_args.bind(self).() << "--silent"
    end
  end

  def self.create_scribe_driver(browser, url)
    if browser == :firefox
      profile = Selenium::WebDriver::Firefox::Profile.new
      profile.native_events = true
      @@driver = Selenium::WebDriver.for browser, :profile => profile
    elsif browser == :chrome
      log_path = FileUtils.mkpath(File.join(File.dirname(File.dirname(File.expand_path(__FILE__))), "logs", "driver"))
      log_path = File.join log_path.first, "chromedriver_#{Time.now.to_i.to_s}.log"
      @@driver = Selenium::WebDriver.for browser, :service_log_path => log_path
    else
      @@driver = Selenium::WebDriver.for browser
    end
    @@driver.manage.timeouts.implicit_wait = 10
    @@driver.get url
    @@driver.switch_to.frame(@@driver.find_element(:tag_name, "iframe"))
    return @@driver
  end
end


require 'colorize'
require 'debugger'
gem "minitest"
require 'minitest/autorun'
require 'minitest/pride'
require_relative '../lib/scribe_driver'
require_relative '../lib/webdriver_adapter'
require_relative '../lib/minitest/focus'

describe "Insert" do
  include ScribeDriver

  before do
    setup_test_suite
  end

  after do
    @driver.quit
  end

  def apply_delta(delta, err_msg)
    ScribeDriver::JS.set_current_delta(delta)
    @adapter.apply_delta(delta)
    actual_delta, success = ScribeDriver::JS.check_consistency().values_at("actual_delta", "success")
    unless success
      doc_delta = ScribeDriver::JS.get_as_str("docDelta")
      cur_delta = ScribeDriver::JS.get_as_str("currentDelta")
      expected_delta = ScribeDriver::JS.get_expected_as_str
      doc_delta = JSON.pretty_generate(JSON.parse(doc_delta))
      cur_delta = JSON.pretty_generate(JSON.parse(cur_delta))
      expected_delta = JSON.pretty_generate(JSON.parse(expected_delta))
      actual_delta = JSON.pretty_generate(JSON.parse(actual_delta))
      error_details = "#{'doc_delta: '.light_cyan + doc_delta},\n"
      error_details << "#{'cur_delta: '.light_cyan + cur_delta},\n"
      error_details << "#{'expected_delta: '.light_cyan + expected_delta},\n"
      error_details << "#{'actual: '.light_cyan + actual_delta}"
      err_msg << "\n#{error_details}"
    end
    success.must_equal true, err_msg
  end

  def insert_at_every_position(text)
    ScribeDriver::JS.execute_js("window.ScribeDriver.resetScribe()")
    @editor = @driver.find_element(:class, "editor")
    @adapter = WebdriverAdapter.new @driver, @editor
    @adapter.focus()
    ScribeDriver::JS.set_doc_delta
    @adapter.doc_length = ScribeDriver::JS.get_doc_length
    doc_length = ScribeDriver::JS.get_doc_length - 1 # - 1 accounts for phantom newline
    (0...doc_length).each do |insert_at|
      cur_length = ScribeDriver::JS.get_doc_length
      delta = ScribeDriver::JS.make_insert_delta(cur_length, @adapter.cursor_pos + 1, text, {})
      apply_delta(delta, "Failed inserting #{text} at index #{insert_at}")
      ScribeDriver::JS.set_doc_delta
    end
  end

  def run_insert_test(initial, insert_delta, err_msg)
    reset_scribe initial
    apply_delta insert_delta, err_msg
  end

  it "should insert a single character at every position" do
    insert_at_every_position "a"
  end

  it "should insert multiple characters at every position" do
    insert_at_every_position "abc"
  end

  it "should insert multiline characters at every position" do
    insert_at_every_position "abc\\ndef\\nghi"
  end

  it "should insert a newline into the empty document" do
    start_delta = { "startLength" => 0,
                    "endLength" => 1,
                    "ops" => [{ "value" => "\n", "attributes" => {}}]}

    current_delta = { "startLength" => 1,
                      "endLength" => 2,
                      "ops" => [{ "value" => "\n", "attributes" => {} },
                                { "start" => 0, "end" => 1}]}
    run_insert_test start_delta, current_delta, "Inserting \n at start of empty document fails."
  end

  it "should append a newline to nonempty document" do
    start_delta = { "startLength" => 0,
                    "endLength" => 4,
                    "ops" => [{ "value" => "abc\n", "attributes" => {}}]}

    current_delta = { "startLength" => 4,
                      "endLength" => 5,
                      "ops" => [{ "start" => 0, "end" => 3, "attributes" => {} },
                                { "value" => "\n", "attributes" => {} },
                                { "start" => 3, "end" => 4, "attributes" => {} }]}

    run_insert_test start_delta, current_delta, "Inserting \n at start of empty document fails."
  end

  describe "tab" do
    it "should indent text when tab is prepended" do
      start_delta = { "startLength" => 0,
                      "endLength" => 4,
                      "ops" => [{ "value" => "abc\n", "attributes" => {}}]}

      current_delta = { "startLength" => 4,
                        "endLength" => 5,
                        "ops" => [{ "value" => "\t", "attributes" => {}},
                                  { "start" => 0, "end" => 4}]}
      run_insert_test start_delta, current_delta, "Failed inserting tab at 0th index."
    end

    it "should indent highlighted lines" do
      start_delta = { "startLength" => 0,
                      "endLength" => 6,
                      "ops" => [{ "value" => "1\n2\n3\n", "attributes" => {}}]}
      reset_scribe start_delta

      @adapter.move_cursor 0
      @adapter.highlight 3
      @editor.send_keys :tab
      expected_delta = { "startLength" => 0,
                         "endLength" => 8,
                         "ops" => [{ "value" => "\t1\n\t2\n3\n", "attributes" => {}}]}

      assert ScribeDriver::JS.editor_delta_equals(expected_delta), "Indenting multiple lines with tab failed."
    end

    it "should prepend a tab followed by more text" do
      start_delta = { "startLength" => 0,
                      "endLength" => 4,
                      "ops" => [{ "value" => "abc\n", "attributes" => {}}]}

      current_delta = { "startLength" => 4,
                        "endLength" => 8,
                        "ops" => [{ "value" => "\tabc", "attributes" => {}},
                                  { "start" => 0, "end" => 4}]}
      run_insert_test start_delta, current_delta, "Failed inserting tab + text at 0th index."
    end

    it "should append a tab" do
      start_delta = { "startLength" => 0,
                      "endLength" => 4,
                      "ops" => [{ "value" => "abc\n", "attributes" => {}}]}

      current_delta = { "startLength" => 4,
                        "endLength" => 5,
                        "ops" => [{ "start" => 0, "end" => 3, "attributes" => {}},
                                  { "value" => "\t", "attributes" => {}},
                                  { "start" => 3, "end" => 4}]}

      run_insert_test start_delta, current_delta, "Failed appending tab."
    end

    it "should append a tab followed by more text" do
      start_delta = { "startLength" => 0,
                      "endLength" => 4,
                      "ops" => [{ "value" => "abc\n", "attributes" => {}}]}

      current_delta = { "startLength" => 4,
                        "endLength" => 8,
                        "ops" => [{ "start" => 0, "end" => 3, "attributes" => {}},
                                  { "value" => "\tdef", "attributes" => {}},
                                  { "start" => 3, "end" => 4}]}

      run_insert_test start_delta, current_delta, "Failed appending tab."
    end
  end
end


describe('Toolbar', ->
  toolbarTests = new ScribeEditorTest(
    initial: '
one.comBold
'
  )

  afterEach( ->
    # Remove all listeners on format-container to reset toolbar
    html = $('#format-container').html()
    $('#format-container').html('').html(html)
  )

  toolbarTests.run('Apply button format',
    expected: '
one.comBold
'
    fn: (editor) ->
      editor.theme.addModule('toolbar', { container: 'format-container' })
      editor.root.focus()
      editor.setSelection(new ScribeRange(editor, 0, 7))
      $('#format-container .bold').trigger('click')
  )

  toolbarTests.run('Apply link format',
    expected: '
one.comBold
'
    fn: (editor) ->
      editor.theme.addModule('toolbar', { container: 'format-container' })
      editor.root.focus()
      editor.setSelection(new ScribeRange(editor, 0, 7))
      $('#format-container .link').trigger('click')
  )
)


require 'debugger'
require 'colorize'
require 'highline'
require 'selenium-webdriver'
require 'json'
require_relative 'lib/webdriver_adapter'

NUM_EDITS = 500

################################################################################
# JS Helpers
################################################################################
def execute_js(driver, src, args = nil)
  driver.switch_to.default_content
  result = driver.execute_script(src, *args)
  driver.switch_to.frame(driver.find_element(:tag_name, "iframe"))
  return result
end

def js_get_as_str(driver, ref)
  return execute_js driver, "return JSON.stringify(window.ScribeDriver['#{ref}'])"
end

def js_get(driver, ref)
  return execute_js driver, "return window.ScribeDriver['#{ref}']"
end

def js_set_scribe_delta(driver)
  return execute_js driver, "window.ScribeDriver.initializeScribe()"
end

def js_set_delta_replay(driver, delta, delta_ref)
  src = "return window.ScribeDriver.setDeltaReplay(arguments[0], arguments[1])"
  execute_js driver, src, [delta, delta_ref]
end

def js_set_current_delta(driver)
  src = "window.ScribeDriver.currentDelta = window.ScribeDriver.createRandomDelta()"
  execute_js driver, src
end

def js_get_cur_doc_delta_as_str(driver)
  return execute_js driver, "return JSON.stringify(editor.getDelta());"
end

def js_get_doc_delta_as_str(driver)
  return execute_js driver, "return JSON.stringify(window.ScribeDriver.docDelta);"
end

def js_get_expected_as_str(driver)
  return execute_js driver, "return JSON.stringify(window.ScribeDriver.docDelta.compose(window.ScribeDriver.currentDelta));"
end

def js_set_doc_delta(driver)
  execute_js driver, "window.ScribeDriver.docDelta = window.ScribeDriver.cleanup(editor.getDelta());"
end

################################################################################
# Replay file helpers
################################################################################
def read_deltas_from_file(file)
  deltas = []
  begin
    File.open(file) do |f|
      f.readlines.each do |line|
        deltas << line.chomp!
      end
    end
    return deltas
  rescue
    puts "Please provide a valid file name to replay a fuzzer run.".colorize(:red)
    abort
  end
end

def write_deltas_to_file(doc_delta, rand_delta)
  file_path = FileUtils.mkpath(File.join(File.dirname(File.expand_path(__FILE__)), "logs", "fuzzer", "fails"))
  file_path = File.join file_path.first, "#{Time.now.to_i.to_s}"
  File.open(file_path, 'w+') do |f|
    f.puts doc_delta
    f.puts rand_delta
  end
  puts "Fuzzer failed. Writing state to #{file_path} for replays.".colorize(:red)
end

def delete_fail_file(file)
  begin
    FileUtils.rm(file)
  rescue
    puts "Failed deleting file #{file}. Please ensure the path is valid.".colorize(:red)
    abort
  end
end

def initialize_scribe_from_replay_file(replay_file, driver, adapter, editor)
  doc_delta, rand_delta = read_deltas_from_file(replay_file)
  js_set_delta_replay(driver, doc_delta, 'docDelta')
  js_set_delta_replay(driver, rand_delta, 'currentDelta')
  doc_delta = js_get(driver, "docDelta")
  js_set_scribe_delta(driver)

  # Remove inexplicable highlighting that gets applied when setting delta and
  # reset cursor to 0th position
  editor.click()
  adapter.cursor_pos = doc_delta['endLength']
  adapter.move_cursor 0

  adapter.doc_length = doc_delta['endLength']
end

################################################################################
# WebDriver setup
################################################################################
unless ARGV.length == 1 or ARGV.length == 2
  puts "Usage: ruby _browserdriver_ _replay_file_".colorize(:blue)
end
browserdriver = ARGV[0].to_sym
replay_file = ARGV[1]
editor_url = "file://#{File.join(File.expand_path(__FILE__), '../../..', 'build/tests/webdriver/webdriver.html')}"
if browserdriver == :firefox
  profile = Selenium::WebDriver::Firefox::Profile.new
  profile.native_events = true
  driver = Selenium::WebDriver.for browserdriver, :profile => profile
elsif browserdriver == :chrome
  log_path = FileUtils.mkpath(File.join(File.dirname(File.expand_path(__FILE__)), "logs", "driver"))
  log_path = File.join log_path.first, "chromedriver_#{Time.now.to_i.to_s}.log"
  driver = Selenium::WebDriver.for browserdriver, :service_log_path => log_path
else
  driver = Selenium::WebDriver.for browserdriver
end
driver.manage.timeouts.implicit_wait = 10
driver.get editor_url
driver.switch_to.frame(driver.find_element(:tag_name, "iframe"))
editor = driver.find_element(:class, "editor")
adapter = WebdriverAdapter.new driver, editor
adapter.focus()

################################################################################
# Fuzzer logic
################################################################################
def check_consistency(driver, replay_file)
  driver.switch_to.default_content
  success = driver.execute_script "return window.ScribeDriver.checkConsistency();"
  if not success
    doc_delta = js_get_as_str(driver, "docDelta")
    rand_delta = js_get_as_str(driver, "currentDelta")
    expected_delta = js_get_expected_as_str(driver)
    actual_delta = js_get_cur_doc_delta_as_str(driver)
    write_deltas_to_file(doc_delta, rand_delta) unless replay_file
    doc_delta = JSON.pretty_generate(JSON.parse(doc_delta))
    rand_delta = JSON.pretty_generate(JSON.parse(rand_delta))
    expected_delta = JSON.pretty_generate(JSON.parse(expected_delta))
    puts "Inconsistent deltas:".red
    puts "#{'doc_delta: '.light_cyan + doc_delta},"
    puts "#{'rand_delta: '.light_cyan + rand_delta},"
    puts "#{'expected_delta: '.light_cyan + expected_delta},"
    puts "#{'actual: '.light_cyan + actual_delta}"
    abort
  elsif replay_file
    highline = HighLine.new
    delete = highline.agree "Congrats, it passed! Would you like to delete the fail file? (y/n)".colorize(:green)
    delete_fail_file(replay_file) if delete
  end
  driver.switch_to.frame(driver.find_element(:tag_name, "iframe"))
end

if replay_file
  initialize_scribe_from_replay_file(replay_file, driver, adapter, editor)
  random_delta = js_get(driver, "currentDelta")
  adapter.apply_delta(random_delta)
  check_consistency(driver, replay_file)
else
  js_set_doc_delta(driver)
  NUM_EDITS.times do |i|
    js_set_current_delta(driver)
    random_delta = js_get(driver, "currentDelta")
    puts i.to_s.colorize(:green) if i % 10 == 0
    adapter.apply_delta(random_delta)
    check_consistency(driver, nil)
    js_set_doc_delta(driver)
  end
end


.editor-wrapper
  border: 1px solid #ccc
  float: left
  margin-left: 3%
  margin-top: 15px
  width: 45.5%

.editor-container
  height: 400px


const width = args[1] && args[1] !== 'default' ? args[1] : '100%';

const height = args[2] && args[2] !== 'default' ? args[2] : '300';

return `<iframe src="${url}" width="${width}" height="${height}" frameborder="0" allowfullscreen></iframe>`;

return `<iframe src="${url}" width="${width}" height="${height}" frameborder="0" loading="lazy" allowfullscreen></iframe>`;

'use strict';

const cheerio = require('cheerio');

describe('vimeo', () => {
  const vimeo = require('../../../lib/plugins/tag/vimeo');

  it('id', () => {
    const $ = cheerio.load(vimeo(['foo']));

    $('.video-container').html().should.be.ok;
    $('iframe').attr('src').should.eql('//player.vimeo.com/video/foo');
    $('iframe').attr('frameborder').should.eql('0');
    $('iframe').attr('allowfullscreen').should.eql('');
  });
});


'use strict';

const urlFn = require('url');
const moment = require('moment');
const { encodeURL, htmlTag, stripHTML } = require('hexo-util');

function meta(name, content) {
  return `${htmlTag('meta', {
    name,
    content
  })}\n`;
}

function og(name, content) {
  return `${htmlTag('meta', {
    property: name,
    content
  })}\n`;
}

function openGraphHelper(options = {}) {

  const { config, page } = this;
  const { content } = page;
  let images = options.image || options.images || page.photos || [];
  let description = options.description || page.description || page.excerpt || content || config.description;
  const keywords = page.keywords || (page.tags && page.tags.length ? page.tags : undefined) || config.keywords;
  const title = options.title || page.title || config.title;
  const type = options.type || (this.is_post() ? 'article' : 'website');
  let url = options.url || this.url;
  const siteName = options.site_name || config.title;
  const twitterCard = options.twitter_card || 'summary';
  const date = options.date !== false ? options.date || page.date : false;
  const updated = options.updated !== false ? options.updated || page.updated : false;
  const language = options.language || page.lang || page.language || config.language;

  if (!Array.isArray(images)) images = [images];

  if (description) {
    description = stripHTML(description).substring(0, 200)
      .trim() // Remove prefixing/trailing spaces
      .replace(/</g, '<')
      .replace(/>/g, '>')
      .replace(/&/g, '&')
      .replace(/"/g, '"')
      .replace(/'/g, ''')
      .replace(/\n/g, ' '); // Replace new lines by spaces
  }

  if (!images.length && content) {
    images = images.slice();

    if (content.includes('<img')) {
      let img;
      const imgPattern = /<img [^>]*src=['"]([^'"]+)([^>]*>)/gi;
      while ((img = imgPattern.exec(content)) !== null) {
        images.push(img[1]);
      }
    }

  }

  let result = '';

  if (description) {
    result += meta('description', description);
  }

  if (keywords) {
    if (typeof keywords === 'string') {
      result += meta('keywords', keywords);
    } else if (keywords.length) {
      result += meta('keywords', keywords.map(tag => {
        return tag.name ? tag.name : tag;
      }).filter(keyword => !!keyword).join());
    }
  }

  result += og('og:type', type);
  result += og('og:title', title);

  if (url) {
    if (config.pretty_urls.trailing_index === false) {
      url = url.replace(/index\.html$/, '');
    }
    url = encodeURL(url);
  }
  result += og('og:url', url);

  result += og('og:site_name', siteName);
  if (description) {
    result += og('og:description', description);
  }

  if (language) {
    result += og('og:locale', language);
  }

  images = images.map(path => {
    if (!urlFn.parse(path).host) {
      // resolve `path`'s absolute path relative to current page's url
      // `path` can be both absolute (starts with `/`) or relative.
      return urlFn.resolve(url || config.url, path);
    }

    return path;
  });

  images.forEach(path => {
    result += og('og:image', path);
  });

  if (date) {
    if ((moment.isMoment(date) || moment.isDate(date)) && !isNaN(date.valueOf())) {
      result += og('article:published_time', date.toISOString());
    }
  }

  if (updated) {
    if ((moment.isMoment(updated) || moment.isDate(updated)) && !isNaN(updated.valueOf())) {
      result += og('article:modified_time', updated.toISOString());
    }
  }

  result += meta('twitter:card', twitterCard);

  if (images.length) {
    result += meta('twitter:image', images[0]);
  }

  if (options.twitter_id) {
    let twitterId = options.twitter_id;
    if (twitterId[0] !== '@') twitterId = `@${twitterId}`;

    result += meta('twitter:creator', twitterId);
  }

  if (options.twitter_site) {
    result += meta('twitter:site', options.twitter_site);
  }

  if (options.google_plus) {
    result += `${htmlTag('link', {rel: 'publisher', href: options.google_plus})}\n`;
  }

  if (options.fb_admins) {
    result += og('fb:admins', options.fb_admins);
  }

  if (options.fb_app_id) {
    result += og('fb:app_id', options.fb_app_id);
  }

  return result.trim();
}

module.exports = openGraphHelper;


'use strict';

const fs = require('hexo-fs');

function File(data) {
  this.source = data.source;
  this.path = data.path;
  this.params = data.params;
  this.type = data.type;
}

File.prototype.read = function(options, callback) {
  return fs.readFile(this.source, options).asCallback(callback);
};

File.prototype.readSync = function(options) {
  return fs.readFileSync(this.source, options);
};

File.prototype.stat = function(options, callback) {
  if (!callback && typeof options === 'function') {
    callback = options;
    options = {};
  }

  return fs.stat(this.source).asCallback(callback);
};

File.prototype.statSync = function(options) {
  return fs.statSync(this.source);
};

File.TYPE_CREATE = 'create';
File.TYPE_UPDATE = 'update';
File.TYPE_SKIP = 'skip';
File.TYPE_DELETE = 'delete';

module.exports = File;


'use strict';

const pathFn = require('path');
const moment = require('moment');
const Promise = require('bluebird');
const fs = require('hexo-fs');
const util = require('hexo-util');
const sinon = require('sinon');
const frontMatter = require('hexo-front-matter');
const fixture = require('../../fixtures/post_render');

describe('Post', () => {
  const Hexo = require('../../../lib/hexo');
  const hexo = new Hexo(pathFn.join(__dirname, 'post_test'));
  const post = hexo.post;
  const now = Date.now();
  let clock;

  before(() => {
    clock = sinon.useFakeTimers(now);

    return fs.mkdirs(hexo.base_dir, () => hexo.init()).then(() => // Load marked renderer for testing
      hexo.loadPlugin(require.resolve('hexo-renderer-marked'))).then(() => hexo.scaffold.set('post', [
      '---',
      'title: {{ title }}',
      'date: {{ date }}',
      'tags:',
      '---'
    ].join('\n'))).then(() => hexo.scaffold.set('draft', [
      '---',
      'title: {{ title }}',
      'tags:',
      '---'
    ].join('\n')));
  });

  after(() => {
    clock.restore();
    return fs.rmdir(hexo.base_dir);
  });

  it('create()', () => {
    const path = pathFn.join(hexo.source_dir, '_posts', 'Hello-World.md');
    const date = moment(now);
    const listener = sinon.spy();

    const content = [
      '---',
      'title: Hello World',
      'date: ' + date.format('YYYY-MM-DD HH:mm:ss'),
      'tags:',
      '---'
    ].join('\n') + '\n';

    hexo.once('new', listener);

    return post.create({
      title: 'Hello World'
    }).then(post => {
      post.path.should.eql(path);
      post.content.should.eql(content);
      listener.calledOnce.should.be.true;

      return fs.readFile(path);
    }).then(data => {
      data.should.eql(content);
      return fs.unlink(path);
    });
  });

  it('create() - slug', () => {
    const path = pathFn.join(hexo.source_dir, '_posts', 'foo.md');
    const date = moment(now);

    const content = [
      '---',
      'title: Hello World',
      'date: ' + date.format('YYYY-MM-DD HH:mm:ss'),
      'tags:',
      '---'
    ].join('\n') + '\n';

    return post.create({
      title: 'Hello World',
      slug: 'foo'
    }).then(post => {
      post.path.should.eql(path);
      post.content.should.eql(content);

      return fs.readFile(path);
    }).then(data => {
      data.should.eql(content);
      return fs.unlink(path);
    });
  });

  it('create() - filename_case', () => {
    hexo.config.filename_case = 1;

    const path = pathFn.join(hexo.source_dir, '_posts', 'hello-world.md');
    const date = moment(now);

    const content = [
      '---',
      'title: Hello World',
      'date: ' + date.format('YYYY-MM-DD HH:mm:ss'),
      'tags:',
      '---'
    ].join('\n') + '\n';

    return post.create({
      title: 'Hello World'
    }).then(post => {
      post.path.should.eql(path);
      post.content.should.eql(content);
      hexo.config.filename_case = 0;

      return fs.readFile(path);
    }).then(data => {
      data.should.eql(content);
      return fs.unlink(path);
    });
  });

  it('create() - layout', () => {
    const path = pathFn.join(hexo.source_dir, '_posts', 'Hello-World.md');
    const date = moment(now);

    const content = [
      '---',
      'layout: photo',
      'title: Hello World',
      'date: ' + date.format('YYYY-MM-DD HH:mm:ss'),
      'tags:',
      '---'
    ].join('\n') + '\n';

    return post.create({
      title: 'Hello World',
      layout: 'photo'
    }).then(post => {
      post.path.should.eql(path);
      post.content.should.eql(content);

      return fs.readFile(path);
    }).then(data => {
      data.should.eql(content);
      return fs.unlink(path);
    });
  });

  it('create() - extra data', () => {
    const path = pathFn.join(hexo.source_dir, '_posts', 'Hello-World.md');
    const date = moment(now);

    const content = [
      '---',
      'title: Hello World',
      'foo: bar',
      'date: ' + date.format('YYYY-MM-DD HH:mm:ss'),
      'tags:',
      '---'
    ].join('\n') + '\n';

    return post.create({
      title: 'Hello World',
      foo: 'bar'
    }).then(post => {
      post.path.should.eql(path);
      post.content.should.eql(content);

      return fs.readFile(path);
    }).then(data => {
      data.should.eql(content);
      return fs.unlink(path);
    });
  });

  it('create() - rename if target existed', () => {
    const path = pathFn.join(hexo.source_dir, '_posts', 'Hello-World-1.md');

    return post.create({
      title: 'Hello World'
    }).then(() => post.create({
      title: 'Hello World'
    })).then(post => {
      post.path.should.eql(path);
      return fs.exists(path);
    }).then(exist => {
      exist.should.be.true;

      return Promise.all([
        fs.unlink(path),
        fs.unlink(pathFn.join(hexo.source_dir, '_posts', 'Hello-World.md'))
      ]);
    });
  });

  it('create() - replace existing files', () => {
    const path = pathFn.join(hexo.source_dir, '_posts', 'Hello-World.md');

    return post.create({
      title: 'Hello World'
    }).then(() => post.create({
      title: 'Hello World'
    }, true)).then(post => {
      post.path.should.eql(path);
      return fs.unlink(path);
    });
  });

  it('create() - asset folder', () => {
    const path = pathFn.join(hexo.source_dir, '_posts', 'Hello-World');

    hexo.config.post_asset_folder = true;

    return post.create({
      title: 'Hello World'
    }).then(post => {
      hexo.config.post_asset_folder = false;
      return fs.stat(path);
    }).then(stats => {
      stats.isDirectory().should.be.true;
      return fs.unlink(path + '.md');
    });
  });

  it('create() - follow the separator style in the scaffold', () => {
    const scaffold = [
      '---',
      'title: {{ title }}',
      '---'
    ].join('\n');

    return hexo.scaffold.set('test', scaffold).then(() => post.create({
      title: 'Hello World',
      layout: 'test'
    })).then(post => {
      post.content.should.eql([
        '---',
        'title: Hello World',
        '---'
      ].join('\n') + '\n');

      return Promise.all([
        fs.unlink(post.path),
        hexo.scaffold.remove('test')
      ]);
    });
  });

  it('create() - JSON front-matter', () => {
    const scaffold = [
      '"title": {{ title }}',
      ';;;'
    ].join('\n');

    return hexo.scaffold.set('test', scaffold).then(() => post.create({
      title: 'Hello World',
      layout: 'test',
      lang: 'en'
    })).then(post => {
      post.content.should.eql([
        '"title": "Hello World",',
        '"lang": "en"',
        ';;;'
      ].join('\n') + '\n');

      return Promise.all([
        fs.unlink(post.path),
        hexo.scaffold.remove('test')
      ]);
    });
  });

  // https://github.com/hexojs/hexo/issues/1100
  it('create() - non-string title', () => {
    const path = pathFn.join(hexo.source_dir, '_posts', '12345.md');

    return post.create({
      title: 12345
    }).then(data => {
      data.path.should.eql(path);
      return fs.unlink(path);
    });
  });

  it('create() - escape title', () => post.create({
    title: 'Foo: Bar'
  }).then(data => {
    data.content.should.eql([
      // js-yaml use single-quotation for dumping since 3.3
      '---',
      'title: \'Foo: Bar\'',
      'date: ' + moment(now).format('YYYY-MM-DD HH:mm:ss'),
      'tags:',
      '---'
    ].join('\n') + '\n');
    return fs.unlink(data.path);
  }));

  it('create() - with content', () => {
    const path = pathFn.join(hexo.source_dir, '_posts', 'Hello-World.md');
    const date = moment(now);

    const content = [
      '---',
      'title: Hello World',
      'date: ' + date.format('YYYY-MM-DD HH:mm:ss'),
      'tags:',
      '---',
      '',
      'Hello hexo'
    ].join('\n');

    return post.create({
      title: 'Hello World',
      content: 'Hello hexo'
    }).then(post => {
      post.path.should.eql(path);
      post.content.should.eql(content);

      return fs.readFile(path);
    }).then(data => {
      data.should.eql(content);
      return fs.unlink(path);
    });
  });

  it('create() - with callback', () => {
    const path = pathFn.join(hexo.source_dir, '_posts', 'Hello-World.md');
    const date = moment(now);

    const content = [
      '---',
      'title: Hello World',
      'date: ' + date.format('YYYY-MM-DD HH:mm:ss'),
      'tags:',
      '---'
    ].join('\n') + '\n';

    const callback = sinon.spy(post => {
      post.path.should.eql(path);
      post.content.should.eql(content);
    });

    return post.create({
      title: 'Hello World'
    }, callback).then(post => {
      callback.calledOnce.should.be.true;
      return fs.readFile(path);
    }).then(data => {
      data.should.eql(content);
      return fs.unlink(path);
    });
  });

  it('publish()', () => {
    let draftPath = '';
    const path = pathFn.join(hexo.source_dir, '_posts', 'Hello-World.md');
    const date = moment(now);

    const content = [
      '---',
      'title: Hello World',
      'date: ' + date.format('YYYY-MM-DD HH:mm:ss'),
      'tags:',
      '---'
    ].join('\n') + '\n';

    return post.create({
      title: 'Hello World',
      layout: 'draft'
    }).then(data => {
      draftPath = data.path;

      return post.publish({
        slug: 'Hello-World'
      });
    }).then(post => {
      post.path.should.eql(path);
      post.content.should.eql(content);

      return Promise.all([
        fs.exists(draftPath),
        fs.readFile(path)
      ]);
    }).spread((exist, data) => {
      exist.should.be.false;
      data.should.eql(content);

      return fs.unlink(path);
    });
  });

  it('publish() - layout', () => {
    const path = pathFn.join(hexo.source_dir, '_posts', 'Hello-World.md');
    const date = moment(now);

    const content = [
      '---',
      'layout: photo',
      'title: Hello World',
      'date: ' + date.format('YYYY-MM-DD HH:mm:ss'),
      'tags:',
      '---'
    ].join('\n') + '\n';

    return post.create({
      title: 'Hello World',
      layout: 'draft'
    }).then(data => post.publish({
      slug: 'Hello-World',
      layout: 'photo'
    })).then(post => {
      post.path.should.eql(path);
      post.content.should.eql(content);

      return fs.readFile(path);
    }).then(data => {
      data.should.eql(content);

      return fs.unlink(path);
    });
  });

  it('publish() - rename if target existed', () => {
    const paths = [pathFn.join(hexo.source_dir, '_posts', 'Hello-World-1.md')];

    return Promise.all([
      post.create({title: 'Hello World', layout: 'draft'}),
      post.create({title: 'Hello World'})
    ]).then(data => {
      paths.push(data[1].path);

      return post.publish({
        slug: 'Hello-World'
      });
    }).then(data => {
      data.path.should.eql(paths[0]);
      return paths;
    }).map(item => fs.unlink(item));
  });

  it('publish() - replace existing files', () => {
    const path = pathFn.join(hexo.source_dir, '_posts', 'Hello-World.md');

    return Promise.all([
      post.create({title: 'Hello World', layout: 'draft'}),
      post.create({title: 'Hello World'})
    ]).then(data => post.publish({
      slug: 'Hello-World'
    }, true)).then(data => {
      data.path.should.eql(path);
      return fs.unlink(path);
    });
  });

  it('publish() - asset folder', () => {
    const assetDir = pathFn.join(hexo.source_dir, '_drafts', 'Hello-World');
    const newAssetDir = pathFn.join(hexo.source_dir, '_posts', 'Hello-World');
    hexo.config.post_asset_folder = true;

    return post.create({
      title: 'Hello World',
      layout: 'draft'
    }).then(data => // Put some files into the asset folder
      Promise.all([
        fs.writeFile(pathFn.join(assetDir, 'a.txt'), 'a'),
        fs.writeFile(pathFn.join(assetDir, 'b.txt'), 'b')
      ])).then(() => post.publish({
      slug: 'Hello-World'
    })).then(post => Promise.all([
      fs.exists(assetDir),
      fs.listDir(newAssetDir),
      fs.unlink(post.path)
    ])).spread((exist, files) => {
      hexo.config.post_asset_folder = false;
      exist.should.be.false;
      files.should.have.members(['a.txt', 'b.txt']);
      return fs.rmdir(newAssetDir);
    });
  });

  // https://github.com/hexojs/hexo/issues/1100
  it('publish() - non-string title', () => {
    const path = pathFn.join(hexo.source_dir, '_posts', '12345.md');

    return post.create({
      title: 12345,
      layout: 'draft'
    }).then(data => post.publish({
      slug: 12345
    })).then(data => {
      data.path.should.eql(path);
      return fs.unlink(path);
    });
  });

  it('publish() - with callback', () => {
    let draftPath = '';
    const path = pathFn.join(hexo.source_dir, '_posts', 'Hello-World.md');
    const date = moment(now);

    const content = [
      '---',
      'title: Hello World',
      'date: ' + date.format('YYYY-MM-DD HH:mm:ss'),
      'tags:',
      '---'
    ].join('\n') + '\n';

    const callback = sinon.spy(post => {
      post.path.should.eql(path);
      post.content.should.eql(content);
    });

    return post.create({
      title: 'Hello World',
      layout: 'draft'
    }).then(data => {
      draftPath = data.path;

      return post.publish({
        slug: 'Hello-World'
      }, callback);
    }).then(post => {
      callback.calledOnce.should.be.true;

      return Promise.all([
        fs.exists(draftPath),
        fs.readFile(path)
      ]);
    }).spread((exist, data) => {
      exist.should.be.false;
      data.should.eql(content);

      return fs.unlink(path);
    });
  });

  // https://github.com/hexojs/hexo/issues/1139
  it('publish() - preserve non-null data in drafts', () => post.create({
    title: 'foo',
    layout: 'draft',
    tags: ['tag', 'test']
  }).then(data => post.publish({
    slug: 'foo'
  })).then(data => {
    const meta = frontMatter(data.content);
    meta.tags.should.eql(['tag', 'test']);
    return fs.unlink(data.path);
  }));

  it('render()', () => {
    // TODO: validate data
    const beforeHook = sinon.spy();
    const afterHook = sinon.spy();

    hexo.extend.filter.register('before_post_render', beforeHook);
    hexo.extend.filter.register('after_post_render', afterHook);

    return post.render(null, {
      content: fixture.content,
      engine: 'markdown'
    }).then(data => {
      data.content.trim().should.eql(fixture.expected);
      beforeHook.calledOnce.should.be.true;
      afterHook.calledOnce.should.be.true;
    });
  });

  it('render() - callback', done => {
    post.render(null, {
      content: fixture.content,
      engine: 'markdown'
    }, err => {
      done(err);
    });
  });

  it('render() - file', () => {
    const content = '**file test**';
    const path = pathFn.join(hexo.base_dir, 'render_test.md');

    return fs.writeFile(path, content).then(() => post.render(path)).then(data => {
      data.content.trim().should.eql('
file test
');
      return fs.unlink(path);
    });
  });

  it('render() - toString', () => {
    const content = 'foo: 1';

    return post.render(null, {
      content,
      engine: 'yaml'
    }).then(data => {
      data.content.should.eql('{"foo":1}');
    });
  });

  it('render() - skip render phase if it\'s swig file', () => {
    const content = [
      '{% quote Hello World %}',
      'quote content',
      '{% endquote %}'
    ].join('\n');

    return post.render(null, {
      content,
      engine: 'swig'
    }).then(data => {
      data.content.trim().should.eql([
        '

quote content
\n',
        'Hello World
'
      ].join(''));
    });
  });

  it('render() - escaping swig blocks with similar names', () => {
    const code = 'alert("Hello world")';
    const highlighted = util.highlight(code);

    const content = [
      '{% codeblock %}',
      code,
      '{% endcodeblock %}',
      '',
      '{% code %}',
      code,
      '{% endcode %}'
    ].join('\n');

    return post.render(null, {
      content
    }).then(data => {
      data.content.trim().should.eql([
        highlighted,
        '',
        highlighted
      ].join('\n'));
    });
  });

  it('render() - recover escaped swig blocks which is html escaped', () => {
    const content = '`{% raw %}{{ test }}{% endraw %}`';

    return post.render(null, {
      content,
      engine: 'markdown'
    }).then(data => {
      data.content.trim().should.eql('
{{ test }}
');
    });
  });

  it('render() - recover escaped swig blocks which is html escaped before post_render', () => {
    const content = '`{% raw %}{{ test }}{% endraw %}`';

    const filter = sinon.spy(result => {
      result.trim().should.eql('
{{ test }}
');
    });

    hexo.extend.filter.register('after_render:html', filter);

    return post.render(null, {
      content,
      engine: 'markdown'
    }).then(data => {
      filter.calledOnce.should.be.true;
      hexo.extend.filter.unregister('after_render:html', filter);
    });
  });

  it('render() - callback - not path and file', callback => {
    post.render(null, {}, (err, result) => {
      try {
        err.should.be.exist;
        err.should.be.instanceof(Error);
        err.should.be.have.property('message', 'No input file or string!');
        should.not.exist(result);
      } catch (e) {
        callback(e);
        return;
      }
      callback();
    });
  });

  // test for PR #3573
  it('render() - (disableNunjucks === true)', () => {
    const renderer = hexo.render.renderer.get('markdown');
    renderer.disableNunjucks = true;

    return post.render(null, {
      content: fixture.content,
      engine: 'markdown'
    }).then(data => {
      data.content.trim().should.eql(fixture.expected_disable_nunjucks);
    }).then(data => {
      renderer.disableNunjucks = false;
    });
  });

  // test for PR #3573
  it('render() - (disableNunjucks === false)', () => {
    const renderer = hexo.render.renderer.get('markdown');
    renderer.disableNunjucks = false;

    return post.render(null, {
      content: fixture.content,
      engine: 'markdown'
    }).then(data => {
      data.content.trim().should.eql(fixture.expected);
    }).then(data => {
      renderer.disableNunjucks = false;
    });
  });

  // test for PR #2321
  it('render() - allow backtick code block in "blockquote" tag plugin', () => {
    const code = 'alert("Hello world")';
    const highlighted = util.highlight(code);

    const content = [
      '{% blockquote %}',
      '```',
      code,
      '```',
      '{% endblockquote %}'
    ].join('\n');

    return post.render(null, {
      content
    }).then(data => {
      data.content.trim().should.eql([
        '
' + highlighted + '
'
      ].join('\n'));
    });
  });

  // test for Issue #2969
  it('render() - backtick cocde block in blockquote', () => {
    const code = 'alert("Hello world")';
    const highlighted = util.highlight(code);
    const quotedContent = [
      'This is a code-block',
      '',
      '```',
      code,
      '```'
    ];

    const content = [
      'Hello',
      '',
      ...quotedContent.map(s => '> ' + s)
    ].join('\n');

    return post.render(null, {
      content,
      engine: 'markdown'
    }).then(data => {
      data.content.trim().should.eql([
        '
Hello
',
        '
',
        '
This is a code-block
',
        highlighted + '
'
      ].join('\n'));
    });
  });

  // test derived from Issue #2969
  it('render() - "lang=dos" backtick cocde block in blockquote', () => {
    const code = '> dir';
    const highlighted = util.highlight(code);
    const quotedContent = [
      'This is a code-block',
      '',
      '```',
      code,
      '```'
    ];

    const content = [
      'Hello',
      '',
      ...quotedContent.map(s => '> ' + s)
    ].join('\n');

    return post.render(null, {
      content,
      engine: 'markdown'
    }).then(data => {
      data.content.trim().should.eql([
        '
Hello
',
        '
',
        '
This is a code-block
',
        highlighted + '
'
      ].join('\n'));
    });
  });

  // test for Issue #3767
  it('render() - backtick cocde block (followed by a paragraph) in blockquote', () => {
    const code = 'alert("Hello world")';
    const highlighted = util.highlight(code);
    const quotedContent = [
      'This is a code-block',
      '',
      '```',
      code,
      '```',
      '',
      'This is a following paragraph'
    ];

    const content = [
      'Hello',
      '',
      ...quotedContent.map(s => '> ' + s)
    ].join('\n');

    return post.render(null, {
      content,
      engine: 'markdown'
    }).then(data => {
      data.content.trim().should.eql([
        '
Hello
',
        '
',
        '
This is a code-block
',
        highlighted,
        '',
        '
This is a following paragraph
',
        '
'
      ].join('\n'));
    });
  });

});


const skin = args[2] && args[2] !== 'default' ? args[2] : 'light';

const width = args[3] && args[3] !== 'default' ? args[3] : '100%';

const height = args[4] && args[4] !== 'default' ? args[4] : '300';

return `<iframe scrolling="no" width="${width}" height="${height}" src="//jsfiddle.net/${id}/embedded/${tabs}/${skin}" frameborder="0" allowfullscreen></iframe>`;

keep keep keep keep replace keep keep keep

return `<iframe scrolling="no" width="${width}" height="${height}" src="//jsfiddle.net/${id}/embedded/${tabs}/${skin}" frameborder="0" loading="lazy" allowfullscreen></iframe>`;

'use strict';

const cheerio = require('cheerio');

describe('iframe', () => {
  const iframe = require('../../../lib/plugins/tag/iframe');

  it('url', () => {
    const $ = cheerio.load(iframe(['https://zespia.tw']));

    $('iframe').attr('src').should.eql('https://zespia.tw');
    $('iframe').attr('width').should.eql('100%');
    $('iframe').attr('height').should.eql('300');
    $('iframe').attr('frameborder').should.eql('0');
    $('iframe').attr('allowfullscreen').should.eql('');
  });

  it('width', () => {
    const $ = cheerio.load(iframe(['https://zespia.tw', '500']));

    $('iframe').attr('src').should.eql('https://zespia.tw');
    $('iframe').attr('width').should.eql('500');
    $('iframe').attr('height').should.eql('300');
    $('iframe').attr('frameborder').should.eql('0');
    $('iframe').attr('allowfullscreen').should.eql('');
  });

  it('height', () => {
    const $ = cheerio.load(iframe(['https://zespia.tw', '500', '600']));

    $('iframe').attr('src').should.eql('https://zespia.tw');
    $('iframe').attr('width').should.eql('500');
    $('iframe').attr('height').should.eql('600');
    $('iframe').attr('frameborder').should.eql('0');
    $('iframe').attr('allowfullscreen').should.eql('');
  });
});


'use strict';

function faviconTagHelper(path) {
  return `<link rel="shortcut icon" href="${this.url_for(path)}">`;
}

module.exports = faviconTagHelper;


'use strict';

const pathFn = require('path');
const fs = require('hexo-fs');
const Promise = require('bluebird');

describe('Theme', () => {
  const Hexo = require('../../../lib/hexo');
  const hexo = new Hexo(pathFn.join(__dirname, 'theme_test'), {silent: true});
  const themeDir = pathFn.join(hexo.base_dir, 'themes', 'test');

  before(() => Promise.all([
    fs.mkdirs(themeDir),
    fs.writeFile(hexo.config_path, 'theme: test')
  ]).then(() => hexo.init()));

  after(() => fs.rmdir(hexo.base_dir));

  it('getView()', () => {
    hexo.theme.setView('test.swig', '');

    // With extension name
    hexo.theme.getView('test.swig').should.have.property('path', 'test.swig');

    // Without extension name
    hexo.theme.getView('test').should.have.property('path', 'test.swig');

    // not exist
    should.not.exist(hexo.theme.getView('abc.swig'));

    hexo.theme.removeView('test.swig');
  });

  it('getView() - escape backslashes', () => {
    hexo.theme.setView('foo/bar.swig', '');

    hexo.theme.getView('foo\\bar.swig').should.have.property('path', 'foo/bar.swig');

    hexo.theme.removeView('foo/bar.swig');
  });

  it('setView()', () => {
    hexo.theme.setView('test.swig', '');

    const view = hexo.theme.getView('test.swig');
    view.path.should.eql('test.swig');

    hexo.theme.removeView('test.swig');
  });

  it('removeView()', () => {
    hexo.theme.setView('test.swig', '');
    hexo.theme.removeView('test.swig');

    should.not.exist(hexo.theme.getView('test.swig'));
  });
});


'use strict';

module.exports = ctx => {
  const { generator } = ctx.extend;

  generator.register('asset', require('./asset'));
  generator.register('page', require('./page'));
  generator.register('post', require('./post'));
};


keep keep keep keep keep replace keep keep keep

return `<div class="video-container"><iframe src="//player.vimeo.com/video/${id}" frameborder="0" loading="lazy" allowfullscreen></iframe></div>`;

'use strict';

/**
* Vimeo tag
*
* Syntax:
*   {% vimeo video_id %}
*/

function vimeoTag(args, content) {
  const id = args[0];

  return `<div class="video-container"><iframe src="//player.vimeo.com/video/${id}" frameborder="0" allowfullscreen></iframe></div>`;
}

module.exports = vimeoTag;


'use strict';

const { Pattern } = require('hexo-util');
const { extname } = require('path');

module.exports = ctx => ({
  pattern: new Pattern('_data/*path'),

  process: function dataProcessor(file) {
    const Data = ctx.model('Data');
    const { path } = file.params;
    const id = path.substring(0, path.length - extname(path).length);
    const doc = Data.findById(id);

    if (file.type === 'skip' && doc) {
      return;
    }

    if (file.type === 'delete') {
      if (doc) {
        return doc.remove();
      }

      return;
    }

    return file.render().then(result => {
      if (result == null) return;

      return Data.save({
        _id: id,
        data: result
      });
    });
  }
});


'use strict';

const merge = require('lodash/merge');
const { sep, resolve, join, parse } = require('path');
const tildify = require('tildify');
const Theme = require('../theme');
const Source = require('./source');
const fs = require('hexo-fs');
const chalk = require('chalk');

module.exports = ctx => {
  if (!ctx.env.init) return;

  const baseDir = ctx.base_dir;
  let configPath = ctx.config_path;

  return fs.exists(configPath).then(exist => {
    return exist ? configPath : findConfigPath(configPath);
  }).then(path => {
    if (!path) return;

    configPath = path;
    return ctx.render.render({path});
  }).then(config => {
    if (!config || typeof config !== 'object') return;

    ctx.log.debug('Config loaded: %s', chalk.magenta(tildify(configPath)));

    config = merge(ctx.config, config);
    ctx.config_path = configPath;

    config.root = config.root.replace(/\/*$/, '/');
    config.url = config.url.replace(/\/+$/, '');

    ctx.public_dir = resolve(baseDir, config.public_dir) + sep;
    ctx.source_dir = resolve(baseDir, config.source_dir) + sep;
    ctx.source = new Source(ctx);

    if (!config.theme) return;

    config.theme = config.theme.toString();
    ctx.theme_dir = join(baseDir, 'themes', config.theme) + sep;
    ctx.theme_script_dir = join(ctx.theme_dir, 'scripts') + sep;
    ctx.theme = new Theme(ctx);
  });
};

function findConfigPath(path) {
  const { dir, name } = parse(path);

  return fs.readdir(dir).then(files => {
    const item = files.find(item => item.startsWith(name));
    if (item != null) return join(dir, item);
  });
}


'use strict';

const sinon = require('sinon');

describe('Cache', () => {
  const Hexo = require('../../../lib/hexo');
  const hexo = new Hexo();
  const Cache = hexo.model('Cache');

  it('_id - required', () => {
    const errorCallback = sinon.spy(err => {
      err.should.have.property('message', 'ID is not defined');
    });

    return Cache.insert({}).catch(errorCallback).finally(() => {
      errorCallback.calledOnce.should.be.true;
    });
  });
});


return `<div class="video-container"><iframe src="//www.youtube.com/embed/${id}" frameborder="0" allowfullscreen></iframe></div>`;

return `<div class="video-container"><iframe src="//www.youtube.com/embed/${id}" frameborder="0" loading="lazy" allowfullscreen></iframe></div>`;


'use strict';

describe('Processor', () => {
  const Processor = require('../../../lib/extend/processor');

  it('register()', () => {
    const p = new Processor();

    // pattern, fn
    p.register('test', () => {});

    p.list()[0].should.exist;

    // fn
    p.register(() => {});

    p.list()[1].should.exist;

    // no fn
    try {
      p.register();
    } catch (err) {
      err.should.be
        .instanceOf(TypeError)
        .property('message', 'fn must be a function');
    }
  });

  it('list()', () => {
    const p = new Processor();

    p.register('test', () => {});

    p.list().length.should.eql(1);
  });
});


'use strict';

require('chai').use(require('chai-as-promised'));

describe('Hexo', () => {
  require('./scripts/box');
  require('./scripts/console');
  require('./scripts/extend');
  require('./scripts/filters');
  require('./scripts/generators');
  require('./scripts/helpers');
  require('./scripts/hexo');
  require('./scripts/models');
  require('./scripts/processors');
  require('./scripts/renderers');
  require('./scripts/tags');
  require('./scripts/theme');
  require('./scripts/theme_processors');
});


'use strict';

const defaults = require('lodash/defaults');
const { Permalink, slugize } = require('hexo-util');
const { basename } = require('path');
let permalink;

function postPermalinkFilter(data) {
  const { config } = this;
  const meta = {
    id: data.id || data._id,
    title: data.slug,
    name: typeof data.slug === 'string' ? basename(data.slug) : '',
    post_title: slugize(data.title, {transform: 1}),
    year: data.date.format('YYYY'),
    month: data.date.format('MM'),
    day: data.date.format('DD'),
    i_month: data.date.format('M'),
    i_day: data.date.format('D')
  };

  if (!permalink || permalink.rule !== config.permalink) {
    permalink = new Permalink(config.permalink);
  }

  const { categories } = data;

  if (categories.length) {
    meta.category = categories.last().slug;
  } else {
    meta.category = config.default_category;
  }

  const keys = Object.keys(data);

  for (let i = 0, len = keys.length; i < len; i++) {
    const key = keys[i];
    if (Object.prototype.hasOwnProperty.call(meta, key)) continue;

    // Use Object.getOwnPropertyDescriptor to copy getters to avoid "Maximum call
    // stack size exceeded" error
    Object.defineProperty(meta, key, Object.getOwnPropertyDescriptor(data, key));
  }

  return permalink.stringify(defaults(meta, config.permalink_defaults));
}

module.exports = postPermalinkFilter;


const $ = cheerio.load(iframe(['https://zespia.tw', '500']));


'use strict';

describe('Generator', () => {
  const Generator = require('../../../lib/extend/generator');

  it('register()', () => {
    const g = new Generator();

    // name, fn
    g.register('test', () => {});

    g.get('test').should.exist;

    // fn
    g.register(() => {});

    g.get('generator-0').should.exist;

    // no fn
    try {
      g.register('test');
    } catch (err) {
      err.should.be
        .instanceOf(TypeError)
        .property('message', 'fn must be a function');
    }
  });

  it('register() - promisify', () => {
    const g = new Generator();

    g.register('test', (locals, render, callback) => {
      callback(null, 'foo');
    });

    g.get('test')({}, {}).then(result => {
      result.should.eql('foo');
    });
  });

  it('get()', () => {
    const g = new Generator();

    g.register('test', () => {});

    g.get('test').should.exist;
  });

  it('list()', () => {
    const g = new Generator();

    g.register('test', () => {});

    g.list().should.have.keys(['test']);
  });
});


'use strict';

const { encodeURL, escapeHTML } = require('hexo-util');
const { resolve } = require('url');

/**
 * Post link tag
 *
 * Syntax:
 *   {% post_link slug [title] [escape] %}
 */
module.exports = ctx => {
  const Post = ctx.model('Post');

  return function postLinkTag(args) {
    const slug = args.shift();
    if (!slug) return;

    let escape = args[args.length - 1];
    if (escape === 'true' || escape === 'false') {
      args.pop();
    } else {
      escape = 'true';
    }

    const post = Post.findOne({slug});
    if (!post) return;

    let title = args.length ? args.join(' ') : post.title;
    const attrTitle = escapeHTML(title);
    if (escape === 'true') title = attrTitle;

    const link = encodeURL(resolve(ctx.config.root, post.path));

    return `<a href="${link}" title="${attrTitle}">${title}</a>`;
  };
};


'use strict';

const fs = require('hexo-fs');
const { basename, extname, join } = require('path');
const stripIndent = require('strip-indent');
const { highlight } = require('hexo-util');

const rCaptionTitleFile = /(.*)?(?:\s+|^)(\/*\S+)/;
const rLang = /\s*lang:(\w+)/i;
const rFrom = /\s*from:(\d+)/i;
const rTo = /\s*to:(\d+)/i;

/**
* Include code tag
*
* Syntax:
*   {% include_code [title] [lang:language] path/to/file %}
*/

module.exports = ctx => function includeCodeTag(args) {
  const config = ctx.config.highlight || {};
  let codeDir = ctx.config.code_dir;
  let arg = args.join(' ');

  // Add trailing slash to codeDir
  if (codeDir[codeDir.length - 1] !== '/') codeDir += '/';

  let lang = '';
  arg = arg.replace(rLang, (match, _lang) => {
    lang = _lang;
    return '';
  });
  let from = 0;
  arg = arg.replace(rFrom, (match, _from) => {
    from = _from - 1;
    return '';
  });
  let to = Number.MAX_VALUE;
  arg = arg.replace(rTo, (match, _to) => {
    to = _to;
    return '';
  });

  const match = arg.match(rCaptionTitleFile);

  // Exit if path is not defined
  if (!match) return;

  const path = match[2];

  // If the title is not defined, use file name instead
  const title = match[1] || basename(path);

  // If the language is not defined, use file extension instead
  lang = lang || extname(path).substring(1);

  const src = join(ctx.source_dir, codeDir, path);

  const caption = `<span>${title}</span><a href="${ctx.config.root}${codeDir}${path}">view raw</a>`;

  const options = {
    lang,
    caption,
    gutter: config.line_number,
    hljs: config.hljs,
    tab: config.tab_replace
  };

  return fs.exists(src).then(exist => {
    if (exist) return fs.readFile(src);
  }).then(code => {
    if (!code) return;

    code = stripIndent(code);
    const lines = code.split('\n');
    code = lines.slice(from, to).join('\n').trim();

    if (!config.enable) {
      return `<pre><code>${code}</code></pre>`;
    }

    return highlight(code, options);
  });
};


'use strict';

/**
* jsFiddle tag
*
* Syntax:
*   {% jsfiddle shorttag [tabs] [skin] [height] [width] %}
*/

function jsfiddleTag(args, content) {
  const id = args[0];
  const tabs = args[1] && args[1] !== 'default' ? args[1] : 'js,resources,html,css,result';
  const skin = args[2] && args[2] !== 'default' ? args[2] : 'light';
  const width = args[3] && args[3] !== 'default' ? args[3] : '100%';
  const height = args[4] && args[4] !== 'default' ? args[4] : '300';

  return `<iframe scrolling="no" width="${width}" height="${height}" src="//jsfiddle.net/${id}/embedded/${tabs}/${skin}" frameborder="0" allowfullscreen></iframe>`;
}

module.exports = jsfiddleTag;


'use strict';

function Helper() {
  this.store = {};
}

Helper.prototype.list = function() {
  return this.store;
};

Helper.prototype.get = function(name) {
  return this.store[name];
};

Helper.prototype.register = function(name, fn) {
  if (!name) throw new TypeError('name is required');
  if (typeof fn !== 'function') throw new TypeError('fn must be a function');

  this.store[name] = fn;
};

module.exports = Helper;


'use strict';

const rewire = require('rewire');
const sinon = require('sinon');

describe('debug', () => {
  const debug = require('../../../lib/plugins/helper/debug');
  const debugModule = rewire('../../../lib/plugins/helper/debug');
  const inspect = require('util').inspect;

  it('inspect simple object', () => {
    const obj = { foo: 'bar' };
    debug.inspectObject(obj).should.eql(inspect(obj));
  });

  it('inspect circular object', () => {
    const obj = { foo: 'bar' };
    obj.circular = obj;
    debug.inspectObject(obj).should.eql(inspect(obj));
  });

  it('inspect deep object', () => {
    const obj = { baz: { thud: 'narf', dur: { foo: 'bar', baz: { bang: 'zoom' } } } };
    debug.inspectObject(obj, {depth: 2}).should.not.eql(inspect(obj, {depth: 5}));
    debug.inspectObject(obj, {depth: 5}).should.eql(inspect(obj, {depth: 5}));
  });

  it('log should print to console', () => {
    const spy = sinon.spy();

    debugModule.__with__({
      console: {
        log: spy
      }
    })(() => {
      debugModule.log('Hello %s from debug.log()!', 'World');
    });

    spy.args[0].should.eql(['Hello %s from debug.log()!', 'World']);
  });
});


'use strict';

const cheerio = require('cheerio');

describe('gist', () => {
  const gist = require('../../../lib/plugins/tag/gist');

  it('id', () => {
    const $ = cheerio.load(gist(['foo']));
    $('script').attr('src').should.eql('//gist.github.com/foo.js');
  });

  it('file', () => {
    const $ = cheerio.load(gist(['foo', 'bar']));
    $('script').attr('src').should.eql('//gist.github.com/foo.js?file=bar');
  });
});


'use strict';

const cheerio = require('cheerio');

describe('youtube', () => {
  const youtube = require('../../../lib/plugins/tag/youtube');

  it('id', () => {
    const $ = cheerio.load(youtube(['foo']));

    $('.video-container').html().should.be.ok;
    $('iframe').attr('src').should.eql('//www.youtube.com/embed/foo');
    $('iframe').attr('frameborder').should.eql('0');
    $('iframe').attr('allowfullscreen').should.eql('');
  });
});


'use strict';

const sinon = require('sinon');
const pathFn = require('path');

describe('Asset', () => {
  const Hexo = require('../../../lib/hexo');
  const hexo = new Hexo();
  const Asset = hexo.model('Asset');

  it('default values', () => Asset.insert({
    _id: 'foo',
    path: 'bar'
  }).then(data => {
    data.modified.should.be.true;
    return Asset.removeById(data._id);
  }));

  it('_id - required', () => {
    const errorCallback = sinon.spy(err => {
      err.should.have.property('message', 'ID is not defined');
    });

    return Asset.insert({}).catch(errorCallback).finally(() => {
      errorCallback.calledOnce.should.be.true;
    });
  });

  it('path - required', () => {
    const errorCallback = sinon.spy(err => {
      err.should.have.property('message', '`path` is required!');
    });

    return Asset.insert({
      _id: 'foo'
    }).catch(errorCallback).finally(() => {
      errorCallback.calledOnce.should.be.true;
    });
  });

  it('source - virtual', () => Asset.insert({
    _id: 'foo',
    path: 'bar'
  }).then(data => {
    data.source.should.eql(pathFn.join(hexo.base_dir, data._id));
    return Asset.removeById(data._id);
  }));
});


'use strict';

function hexoMetaGeneratorInject(data) {
  const { config } = this;
  if (!config.meta_generator ||
  data.match(/<meta\s+name=['|"]?generator['|"]?/i)) return;

  const hexoGeneratorTag = `<meta name="generator" content="Hexo ${this.version}">`;

  return data.replace(/<head>(?!<\/head>).+?<\/head>/s, (str) => str.replace('</head>', `${hexoGeneratorTag}</head>`));
}

module.exports = hexoMetaGeneratorInject;


'use strict';

function Locals() {
  this.cache = {};
  this.getters = {};
}

Locals.prototype.get = function(name) {
  if (typeof name !== 'string') throw new TypeError('name must be a string!');

  let cache = this.cache[name];

  if (cache == null) {
    const getter = this.getters[name];
    if (!getter) return;

    cache = getter();
    this.cache[name] = getter();
  }

  return cache;
};

Locals.prototype.set = function(name, value) {
  if (typeof name !== 'string') throw new TypeError('name must be a string!');
  if (value == null) throw new TypeError('value is required!');

  const getter = typeof value === 'function' ? value : () => value;

  this.getters[name] = getter;
  this.cache[name] = null;

  return this;
};

Locals.prototype.remove = function(name) {
  if (typeof name !== 'string') throw new TypeError('name must be a string!');

  this.getters[name] = null;
  this.cache[name] = null;

  return this;
};

Locals.prototype.invalidate = function() {
  this.cache = {};

  return this;
};

Locals.prototype.toObject = function() {
  const result = {};
  const keys = Object.keys(this.getters);

  for (let i = 0, len = keys.length; i < len; i++) {
    const key = keys[i];
    const item = this.get(key);

    if (item != null) result[key] = item;
  }

  return result;
};

module.exports = Locals;


'use strict';

const cheerio = require('cheerio');

describe('iframe', () => {
  const iframe = require('../../../lib/plugins/tag/iframe');

  it('url', () => {
    const $ = cheerio.load(iframe(['https://zespia.tw']));

    $('iframe').attr('src').should.eql('https://zespia.tw');
    $('iframe').attr('width').should.eql('100%');
    $('iframe').attr('height').should.eql('300');
    $('iframe').attr('frameborder').should.eql('0');
    $('iframe').attr('allowfullscreen').should.eql('');
  });

  it('width', () => {
    const $ = cheerio.load(iframe(['https://zespia.tw', '500']));

    $('iframe').attr('src').should.eql('https://zespia.tw');
    $('iframe').attr('width').should.eql('500');
    $('iframe').attr('height').should.eql('300');
    $('iframe').attr('frameborder').should.eql('0');
    $('iframe').attr('allowfullscreen').should.eql('');
  });

  it('height', () => {
    const $ = cheerio.load(iframe(['https://zespia.tw', '500', '600']));

    $('iframe').attr('src').should.eql('https://zespia.tw');
    $('iframe').attr('width').should.eql('500');
    $('iframe').attr('height').should.eql('600');
    $('iframe').attr('frameborder').should.eql('0');
    $('iframe').attr('allowfullscreen').should.eql('');
  });
});