Merge pull request #2 from weexteam/sourcemap

supported sourcemap
diff --git a/src/index.js b/src/index.js
index fbec04e..b3d4145 100644
--- a/src/index.js
+++ b/src/index.js
@@ -4,12 +4,14 @@
   parseScript,
   parseStyle,
   parseTemplate,
-  parseWeexFile
+  parseWeex
 } from './parser'
+import { getFilenameByPath } from './util'
 import * as config from './config'
 import * as legacy from './legacy'
+import { ScriptMap } from './map'
 
-function partedLoader (type, loader, params, source) {
+function partedLoader (type, loader, params, source, map) {
   let promise
   switch (type) {
     case 'js':
@@ -29,7 +31,8 @@
       break
     case 'we':
     default:
-      promise = parseWeexFile(loader, params, source)
+      map.enable()
+      promise = parseWeex(loader, params, source, map)
       break
   }
   return promise
@@ -45,15 +48,23 @@
     resourcePath: this.resourcePath
   }
   const type = params.loaderQuery.type || 'we'
-  const promise = partedLoader(type, this, params, source)
+  const { resourcePath } = params
+  const filename = getFilenameByPath(resourcePath)
+  const map = new ScriptMap(filename, source)
+
+  const promise = partedLoader(type, this, params, source, map)
 
   promise.then(result => {
+    if (map.enabled) {
+      map.parse()
+    }
     if (type === 'style' || type === 'css' ||
       type === 'html' || type === 'tpl' || type === 'template') {
       result = 'module.exports=' + result
     }
-    callback(null, result)
+    callback(null, result, map.toJSON())
   }).catch(err => {
+    // console.error(err.stack)
     this.emitError(err.toString())
     callback(err.toString(), '')
   })
diff --git a/src/map.js b/src/map.js
new file mode 100644
index 0000000..b859690
--- /dev/null
+++ b/src/map.js
@@ -0,0 +1,109 @@
+import { SourceMapGenerator } from 'source-map'
+
+export class ScriptMap {
+  constructor (filename, content) {
+    this.filename = filename
+    this.content = content
+    const generator = new SourceMapGenerator()
+    generator.setSourceContent(filename, content)
+    this.generator = generator
+    this.history = []
+    this.elements = {}
+    this.enabled = false
+  }
+
+  enable () {
+    this.enabled = true
+  }
+
+  start () {
+    if (!this.enabled) { return }
+    this.current = { elements: [], scripts: [] }
+  }
+
+  end () {
+    if (!this.enabled) { return }
+    const current = this.current
+    this.current = {}
+
+    // re-order the elements and scripts into history
+    const length = current.elements.length
+    if (length > 0) {
+      const children = this.history.splice(-length, length)
+      current.children = children
+    }
+
+    current.elements.concat(current.scripts).forEach(item => {
+      current.name = item.name
+      delete item.name
+    })
+
+    current.elements.forEach((info, index) => {
+      current.children[index].length = info.length
+      current.children[index].line = info.line
+    })
+
+    delete current.elements
+    this.history.push(current)
+  }
+
+  addElement (name, index, line, length) {
+    if (!this.enabled) { return }
+    this.current.elements.push({ name, index, line, length })
+  }
+
+  addScript (name, info, externalOffset) {
+    if (!this.enabled) { return }
+    this.current.scripts.push({ name, info, externalOffset })
+  }
+
+  setElementPosition (name, line, column) {
+    if (!this.enabled) { return }
+    this.elements[name] = { line, column }
+  }
+
+  parse (target, startLine) {
+    if (!this.enabled) { return }
+    target = target || this.history[0]
+    if (!target) { return }
+    startLine = startLine || 0
+
+    const { name, line, scripts, children } = target
+    const elInfo = this.elements[name] || {};
+
+    (scripts || []).forEach(script => {
+      const { info, externalOffset } = script
+      const { original, generated } = info
+      const scriptLength = info.length
+      this.add(
+        original.line + (elInfo.line || 1) - 1,
+        scriptLength,
+        generated.line + startLine + (line || 1) - 1 + externalOffset
+      )
+    });
+
+    (children || []).forEach(child => {
+      this.parse(child, startLine + (line || 1) - 1)
+    })
+
+    this.json = true
+  }
+
+  add (originalLine, length, generatedLine) {
+    if (!this.enabled) { return }
+    const option = {
+      source: this.filename,
+      original: { line: originalLine, column: 1 },
+      generated: { line: generatedLine, column: 1 }
+    }
+    for (let i = 0; i < length; i++) {
+      option.original.line = originalLine + i
+      option.generated.line = generatedLine + i
+      this.generator.addMapping(option)
+    }
+  }
+
+  toJSON () {
+    return this.json ? this.generator.toJSON() : null
+  }
+}
diff --git a/src/parser.js b/src/parser.js
index ac1dd76..03b9a80 100644
--- a/src/parser.js
+++ b/src/parser.js
@@ -18,14 +18,14 @@
   appendToWarn
 } from './util'
 
-export function parseWeexFile (loader, params, source, deps, elementName) {
+export function parseWeex (loader, params, source, map, deps, elementName) {
   return new Promise(
     // separate source into <element>s, <template>, <style>s and <script>s
     separateBlocks(source, deps || []))
     // pre-parse non-javascript parts
-    .then(preParseBlocks(loader, params, elementName))
+    .then(preParseBlocks(loader, params, map))
     // join blocks together and parse as javascript finally
-    .then(parseBlocks(loader, params, elementName))
+    .then(parseBlocks(loader, params, map, elementName))
 }
 
 function separateBlocks (source, deps) {
@@ -42,7 +42,7 @@
   }
 }
 
-function preParseBlocks (loader, params) {
+function preParseBlocks (loader, params, map) {
   return (blocks) => {
     const { deps, elements, template, styles, scripts, config, data } = blocks
     const promises = [
@@ -60,7 +60,9 @@
       const elPromises = []
       Object.keys(elements).forEach(key => {
         const el = elements[key]
-        elPromises.push(parseWeexFile(loader, params, el.content, deps, el.name))
+        // record original positions of each <element>
+        map.setElementPosition(el.name, el.line, el.column)
+        elPromises.push(parseWeex(loader, params, el.content, map, deps, el.name))
       })
       promises[0] = Promise.all(elPromises)
     }
@@ -80,7 +82,7 @@
   }
 }
 
-function parseBlocks (loader, params, elementName) {
+function parseBlocks (loader, params, map, elementName) {
   return (results) => {
     const elements = results[0] || []
     const template = results[1]
@@ -94,9 +96,26 @@
     let config = {}
     let data
 
+    const mapOffset = { basic: 0, subs: [] }
+
     if (scripts) {
-      content += scripts.reduce((pre, cur) => {
-        return pre + '\n;' + cur.content
+      // record original and generated position of each <script>
+      // the generated content is begin with empty string
+      // so later the template, styles and elements will be appended/prepended
+      // and mapOffset.basic will record lines of prepended *required* content
+      content += scripts.reduce((prev, next, i) => {
+        // length of previous content
+        const line = prev.split(/\r?\n/g).length + 1
+        const column = 1
+        const oriLine = next.line
+        const oriColumn = next.column
+        mapOffset.subs.push({
+          original: { line: oriLine, column: oriColumn },
+          generated: { line, column },
+          // length of next content
+          length: next.content.split(/\r?\n/g).length
+        })
+        return prev + '\n;' + next.content
       }, '')
     }
 
@@ -105,31 +124,39 @@
       requireContent += deps.map(dep =>
         depHasRequired(content, dep) ? 'require("' + dep + '");' : ''
       ).join('\n')
-      content = requireContent + '\n' + content
+      if (requireContent) {
+        // length of implicitly requires
+        mapOffset.basic = requireContent.split(/\r?\n/g).length
+        content = requireContent + '\n' + content
+      }
     }
 
     if (template) {
+      // append template content, not impact sourcemap
       content += '\n;module.exports.template = module.exports.template || {}' +
         '\n;Object.assign(module.exports.template, ' + template + ')'
     }
 
     if (style) {
+      // append style content, not impact sourcemap
       content += '\n;module.exports.style = module.exports.style || {}' +
         '\n;Object.assign(module.exports.style, ' + style + ')'
     }
 
+    // prepare entry config
     if (configResult) {
       config = new Function('return ' + configResult.content.replace(/\n/g, ''))()
     }
     config.transformerVersion = transformerVersion
     config = JSON.stringify(config, null, 2)
 
+    // prepare entry data
     if (dataResult) {
       data = new Function('return ' + dataResult.content.replace(/\n/g, ''))()
       data = JSON.stringify(data, null, 2)
     }
 
-    return parseScript(loader, params, content, { config, data, elementName, elements })
+    return parseScript(loader, params, content, { config, data, elementName, elements, map, mapOffset })
   }
 }
 
@@ -175,7 +202,7 @@
 }
 
 export function parseScript (loader, params, source, env) {
-  const { config, data, elementName, elements } = env
+  const { config, data, elementName, elements, map, mapOffset } = env
 
   // the entry component has a special resource query and not a sub element tag
   const isEntry = params.resourceQuery.entry === true && !elementName
@@ -185,10 +212,22 @@
     ? md5(source)
     : (elementName || params.resourceQuery.name || getNameByPath(params.resourcePath))
 
+  // join with elements deps
+  // 2 more lines between each element and the end
+  map && map.start()
+  const prefix = (elements || []).reduce((prev, next, index) => {
+    const prevLength = prev.split(/\r?\n/g).length
+    const nextLength = next.split(/\r?\n/g).length
+    // record generated positions of each <element>
+    map && map.addElement(name, index, prevLength, nextLength)
+    return prev + next + ';\n\n'
+  }, '')
+
   // fix data option from an object to a function
   let target = scripter.fix(source)
 
   // wrap with __weex_define__(name, [], (r, e, m) {...})
+  // 1 more line at start, 1 more line at end
   target = target
       .replace(MODULE_EXPORTS_REG, '__weex_module__.exports')
       .replace(REQUIRE_REG, '__weex_require__($1$2$1)')
@@ -196,16 +235,24 @@
       'function(__weex_require__, __weex_exports__, __weex_module__)' +
       '{\n' + target + '\n})'
 
+  // record mapOffset into sourcemap
+  if (mapOffset) {
+    // length of generated prefix (elements) and basic (implicitly requires)
+    const preLines = prefix.split(/\r?\n/g).length + mapOffset.basic
+    mapOffset.subs.forEach(info => {
+      map.addScript(elementName || name, info, preLines)
+    })
+  }
+  map && map.end()
+
   // append __weex_bootstrap__ for entry component
+  // not impact sourcemap
   if (isEntry) {
     target += '\n;__weex_bootstrap__("@weex-component/' + name + '", ' +
         String(config) + ',' +
         String(data) + ')'
   }
 
-  // join with elements deps
-  target = (elements || []).concat(target).join(';\n\n')
-
-  return Promise.resolve(target)
+  return Promise.resolve(prefix + target)
 }
 
diff --git a/src/util.js b/src/util.js
index 3417388..d631779 100644
--- a/src/util.js
+++ b/src/util.js
@@ -10,6 +10,10 @@
   return path.basename(filepath).replace(/\..*$/, '')
 }
 
+export function getFilenameByPath (filepath) {
+  return path.relative('.', filepath)
+}
+
 export const FUNC_START = '#####FUN_S#####'
 export const FUNC_START_REG = new RegExp('["\']' + FUNC_START, 'g')
 export const FUNC_END = '#####FUN_E#####'
diff --git a/test/a.js b/test/a.js
index b8f9f49..574e715 100644
--- a/test/a.js
+++ b/test/a.js
@@ -10,4 +10,4 @@
 }
 
 module.exports.style = require('./a.less');
-module.exports.template = require('./a.tpl');
\ No newline at end of file
+module.exports.template = require('./a.tpl');
diff --git a/test/expect/a.we b/test/expect/a.we
index 2ea3b36..2565076 100644
--- a/test/expect/a.we
+++ b/test/expect/a.we
@@ -24,4 +24,4 @@
         text: 'Hello ' + c.name
     }
 }
-</script>
\ No newline at end of file
+</script>
diff --git a/test/expect/sourcemap.we b/test/expect/sourcemap.we
new file mode 100644
index 0000000..f798f7f
--- /dev/null
+++ b/test/expect/sourcemap.we
@@ -0,0 +1,26 @@
+<element name="foo">
+  <template>
+    <text>Hello</text>
+  </template>
+  <script>
+    console.log(6)
+    console.log(7)
+    console.log(8)
+    console.log(9)
+    console.log(0)
+  </script>
+</element>
+
+<template>
+  <div>
+    <foo></foo>
+  </div>
+</template>
+
+<script>
+  console.log(1)
+  console.log(2)
+  require('../lib/3rd.js')
+  console.log(4)
+  console.log(5)
+</script>
diff --git a/test/lib/3rd.js b/test/lib/3rd.js
index e132ec8..cc00e41 100644
--- a/test/lib/3rd.js
+++ b/test/lib/3rd.js
@@ -1 +1,4 @@
 'Hello 3rd-party JavaScript'
+'Hello 3rd-party JavaScript 2'
+'Hello 3rd-party JavaScript 3'
+'Hello 3rd-party JavaScript 4'
diff --git a/test/test.js b/test/test.js
index ab14972..e2a616d 100644
--- a/test/test.js
+++ b/test/test.js
@@ -7,6 +7,9 @@
 var expect = chai.expect;
 chai.use(sinonChai);
 
+var webpack = require('webpack')
+var SourceMapConsumer = require('source-map').SourceMapConsumer
+
 require('./lib/jsfm');
 var createInstance = global.createInstance;
 var getRoot = global.getRoot;
@@ -25,10 +28,10 @@
     var name = 'a.js';
 
     var actualCodePath = path.resolve(__dirname, 'actual', name);
-    var actualCodeContent = fs.readFileSync(actualCodePath);
+    var actualCodeContent = fs.readFileSync(actualCodePath, { encoding: 'utf8' });
 
     var expectCodePath = path.resolve(__dirname, 'expect', name);
-    var expectCodeContent = fs.readFileSync(expectCodePath);
+    var expectCodeContent = fs.readFileSync(expectCodePath, { encoding: 'utf8' });
 
 
     var actualResult = createInstance('actual/' + name, actualCodeContent);
@@ -44,10 +47,10 @@
     var name = 'b.js';
 
     var actualCodePath = path.resolve(__dirname, 'actual', name);
-    var actualCodeContent = fs.readFileSync(actualCodePath);
+    var actualCodeContent = fs.readFileSync(actualCodePath, { encoding: 'utf8' });
 
     var expectCodePath = path.resolve(__dirname, 'expect', name);
-    var expectCodeContent = fs.readFileSync(expectCodePath);
+    var expectCodeContent = fs.readFileSync(expectCodePath, { encoding: 'utf8' });
 
 
     var actualResult = createInstance('actual/' + name, actualCodeContent);
@@ -63,10 +66,10 @@
     var name = 'z.js';
 
     var actualCodePath = path.resolve(__dirname, 'actual', name);
-    var actualCodeContent = fs.readFileSync(actualCodePath);
+    var actualCodeContent = fs.readFileSync(actualCodePath, { encoding: 'utf8' });
 
     var expectCodePath = path.resolve(__dirname, 'expect', name);
-    var expectCodeContent = fs.readFileSync(expectCodePath);
+    var expectCodeContent = fs.readFileSync(expectCodePath, { encoding: 'utf8' });
 
 
     var actualResult = createInstance('actual/' + name, actualCodeContent);
@@ -77,4 +80,53 @@
 
     expect(actualJson).eql(expectJson);
   });
+
+  it('support source map', function() {
+    var name = 'sourcemap'
+
+    var mapPath = path.resolve(__dirname, 'actual', name + '.js.map');
+    var map = fs.readFileSync(mapPath, { encoding: 'utf8' });
+    var smc = new SourceMapConsumer(JSON.parse(map))
+
+    var oriPath = path.resolve(__dirname, 'expect', name + '.we');
+    var ori = fs.readFileSync(oriPath, { encoding: 'utf8' });
+
+    var genPath = path.resolve(__dirname, 'actual', name + '.js');
+    var gen = fs.readFileSync(genPath, { encoding: 'utf8' });
+
+    function matchPos(code, regexp) {
+      var line, col
+      code.split(/\r?\n/g).some(function (l, i) {
+        if (regexp.test(l)) {
+          line = i + 1
+          col = l.length
+          return true
+        }
+      })
+      return { line: line, col: col }
+    }
+
+    function checkPos(regexp) {
+      var genPos = matchPos(gen, regexp)
+      var oriPos = matchPos(ori, regexp)
+
+      var pos = smc.originalPositionFor({
+        line: genPos.line,
+        column: genPos.col
+      })
+
+      expect(pos.source.indexOf('sourcemap.we') > -1)
+      expect(pos.line).to.equal(oriPos.line)
+    }
+
+    checkPos(/console\.log\(1\)/)
+    checkPos(/console\.log\(2\)/)
+    checkPos(/console\.log\(4\)/)
+    checkPos(/console\.log\(5\)/)
+    checkPos(/console\.log\(6\)/)
+    checkPos(/console\.log\(7\)/)
+    checkPos(/console\.log\(8\)/)
+    checkPos(/console\.log\(9\)/)
+    checkPos(/console\.log\(0\)/)
+  })
 })
diff --git a/test/webpack.config.js b/test/webpack.config.js
index 4833d07..68792f3 100644
--- a/test/webpack.config.js
+++ b/test/webpack.config.js
@@ -2,6 +2,7 @@
 
 module.exports = {
   entry: {
+    sourcemap: path.resolve(__dirname, 'expect/sourcemap.we?entry=true'),
     a: path.resolve(__dirname, 'a.js?entry=true'),
     b: path.resolve(__dirname, 'expect/b.we?entry=true'),
     z: path.resolve(__dirname, 'expect/z.we?entry=true')
@@ -10,6 +11,7 @@
     path: path.resolve(__dirname, 'actual'),
     filename: '[name].js'
   },
+  devtool: 'source-map',
   module: {
     loaders: [
       {