Repository: vuejs/vue-loader
Pull request #499 (2016-11-25 21:02:25)

Add support for custom blocks in .vue files
This builds on https://github.com/vuejs/vue/pull/4157 adding support in vue-loader for custom blocks.  If a custom block is found in a .vue file, the loader for it will be looked up in the webpack vue configuration block just like a language is for script/style/template.

I think this improvement could probably cover the requests in some other feature requests, such as #457, #423, #413, #223, and having the ability in a UI kit project to specify (e.g.) Sass in a .vue file for a GUI component that is meant to be extracted and concatenated, and compiled into CSS by the application that pulls in and uses the UI kit.

This is a pretty simplistic implementation-- if there are issues that you think should be addressed in order to merge this, please let me know.

yyx990803: Thanks for the PR! A few issues that need to be fixed, and ideally we need a test case for this too.
asselin: Thanks for the feedback!  I fixed the 2 issues and added them to the PR.  I'll work on adding some tests and updating the documentation one night this week.
asselin: Hmmm.  I think I messed up my rebase... I'll try fixing that so the patch is cleaner...
asselin: FYI, I haven't forgotten about building unit tests for this PR; I've just been slammed with a number of things demanding my time right now.  I still intend to finish this up, targeting the week of Dec 26-30.
asselin: Unit tests added, please let me know if there are any other changes required.
iamkether: @asselin this is looking great. I have one question though, would there be a way to 'ignore' certain custom blocks in different Webpack builds? Perhaps this is just me missing something in the documentation, or maybe it's something that needs to be added but, as an example, I wouldn't necessarily want to have vue-loader parsing documentation or unit-tests when I'm in the middle of development. 

I guess maybe with Webpack's caching system this might not make a huge performance issue, but even when thinking about CI systems, it would be nice to be able to have some way to "ignore" certain custom blocks.
asselin: @iamkether If you want to leave some part of the .vue file out of the generated code, you can specify to use the null-loader in the webpack config file (for example, omit unit tests from debug & release builds).  vue-loader will still process that section of the .vue file, but passing it to the null loader will effectively make it cease processing there.
asselin: @yyx990803 or @kazupon: This PR is ready for a 2nd review (and merge, hopefully).
dokether: @asselin ah, okay, that makes sense, thank you.
iamkether: @asselin ah, okay, that makes sense, thank you.
asselin: @kazupon I made updates to the documentation, and see my comment about the test not being necessary.  If I didn't make the changes you were looking for on the doc, please let me know.  Thanks!
kazupon: @asselin Thanks! LGTM!

/ping @yyx990803
Please check just in case.
asselin: @yyx990803 Is this good to merge now?  Any other cleanup needed?

FYI, I have a follow-on PR that I'm going to submit that adds support for adding attribute values from custom blocks to the webpack command as query parms.

Thanks!
yyx990803: @asselin sorry for dragging on, just busy with other stuff and don't want to rush-review this. Will try to finalize this week or next.
asselin: Rebased in on current master, and added a fix for extract-text-plugin 2.0 rc
asselin: @yyx990803 I see that you're spending some time on vue-loader; any chance this PR could be merged soon?

Patch:
diff --git a/docs/en/README.md b/docs/en/README.md
index 2bb3116..21e3008 100644
--- a/docs/en/README.md
+++ b/docs/en/README.md
@@ -10,6 +10,7 @@ There are many cool features provided by `vue-loader`:
 
 - ES2015 enabled by default;
 - Allows using other Webpack loaders for each part of a Vue component, for example SASS for `<style>` and Jade for `<template>`;
+- Allows custom sections in a .vue file that can have custom loader chains applied to them
 - Treat static assets referenced in `<style>` and `<template>` as module dependencies and handle them with Webpack loaders;
 - Can simulate scoped CSS for each component;
 - Supports component hot-reloading during development.
diff --git a/docs/en/configurations/advanced.md b/docs/en/configurations/advanced.md
index 1f6667a..6f82efe 100644
--- a/docs/en/configurations/advanced.md
+++ b/docs/en/configurations/advanced.md
@@ -41,10 +41,13 @@ module.exports = {
       {
         test: /\.vue$/,
         loader: 'vue-loader',
-        // vue-loader options goes here
         options: {
           loaders: {
-            // ...
+            // load all <script> without "lang" attribute with coffee-loader
+            js: 'coffee',
+            // load <template> directly as HTML string, without piping it
+            // through vue-html-loader first
+            html: 'raw'
           }
         }
       }
diff --git a/docs/en/start/spec.md b/docs/en/start/spec.md
index 7e83a24..2c4dd84 100644
--- a/docs/en/start/spec.md
+++ b/docs/en/start/spec.md
@@ -1,6 +1,6 @@
 # Vue Component Spec
 
-A `*.vue` file is a custom file format that uses HTML-like syntax to describe a Vue component. Each `*.vue` file consists of three types of top-level language blocks: `<template>`, `<script>` and `<style>`:
+A `*.vue` file is a custom file format that uses HTML-like syntax to describe a Vue component. Each `*.vue` file consists of three types of top-level language blocks: `<template>`, `<script>`, and `<style>`, and optionally additional custom blocks:
 
 ``` html
 <template>
@@ -22,6 +22,10 @@ export default {
   color: red;
 }
 </style>
+
+<custom1>
+  This could be e.g. documentation for the component.
+</custom1>
 ```
 
 `vue-loader` will parse the file, extract each language block, pipe them through other loaders if necessary, and finally assemble them back into a CommonJS module whose `module.exports` is a Vue.js component options object.
@@ -66,6 +70,66 @@ More details can be found in [Using Pre-Processors](../configurations/pre-proces
 
 - By default, contents will be extracted and dynamically inserted into the document's `<head>` as an actual `<style>` tag using `style-loader`. It's also possible to [configure Webpack so that all styles in all components are extracted into a single CSS file](../configurations/extract-css.md).
 
+### Custom Blocks
+
+Additional custom blocks can be included in a `*.vue` file for any project specific needs. `vue-loader` will use the tag name to look up which webpack loaders should be applied to the contents of the section. The webpack loaders should be specified in the `loaders` hash of the `vue` section of the webpack configuration in the same way that languages are specified for the standard sections of the file. See [Advanced Loader Configuration](../configurations/advanced.md).
+
+Example:
+
+#### component.vue
+``` html
+<unit-test>
+  describe('example', function () {
+    it('basic', function (done) {
+      done();
+    })
+  })
+</unit-test>
+
+<template>
+  <h2 class="red">{{msg}}</h2>
+</template>
+
+<script>
+export default {
+  data () {
+    return {
+      msg: 'Hello from Component A!'
+    }
+  }
+}
+</script>
+
+<style>
+comp-a h2 {
+  color: #f00;
+}
+</style>
+```
+
+#### webpack.config.js
+
+``` js
+// Webpack 2.x (^2.1.0-beta.25)
+module.exports = {
+  module: {
+    rules: [
+      {
+        test: /\.vue$/,
+        loader: 'vue',
+        // vue-loader options go here
+        options: {
+          loaders: {
+            unit-test: 'buble-loader',
+          }
+        }
+      }
+    ]
+  }
+}
+```
+
+
 ### Src Imports
 
 If you prefer splitting up your `*.vue` components into multiple files, you can use the `src` attribute to import an external file for a language block:
@@ -83,6 +147,13 @@ Beware that `src` imports follow the same path resolution rules to CommonJS `req
 <style src="todomvc-app-css/index.css">
 ```
 
+`src` imports also work with custom blocks, e.g.:
+
+``` html
+<unit-test src="./unit-test.js">
+</unit-test>
+```
+
 ### Syntax Highlighting
 
 Currently there is syntax highlighting support for [Sublime Text](https://github.com/vuejs/vue-syntax-highlight), [Atom](https://atom.io/packages/language-vue), [Vim](https://github.com/posva/vim-vue), [Visual Studio Code](https://marketplace.visualstudio.com/items/liuji-jim.vue), [Brackets](https://github.com/pandao/brackets-vue), and [JetBrains products](https://plugins.jetbrains.com/plugin/8057) (WebStorm, PhpStorm, etc). Contributions for other editors/IDEs are highly appreciated! If you are not using any pre-processors in Vue components, you can also get by treating `*.vue` files as HTML in your editor.
diff --git a/lib/loader.js b/lib/loader.js
index 4d08081..5f8feb5 100644
--- a/lib/loader.js
+++ b/lib/loader.js
@@ -3,6 +3,7 @@ var parse = require('./parser')
 var genId = require('./gen-id')
 var normalize = require('./normalize')
 var loaderUtils = require('loader-utils')
+var querystring = require('querystring')
 
 // internal lib loaders
 var selectorPath = normalize.lib('selector')
@@ -128,6 +129,13 @@ module.exports = function (content) {
     })
   }
 
+  function buildCustomBlockLoaderString (attrs) {
+    var noSrcAttrs = Object.assign({}, attrs)
+    delete noSrcAttrs.src
+    var qs = querystring.stringify(noSrcAttrs)
+    return qs ? '?' + qs : qs
+  }
+
   // stringify an Array of loader objects
   function stringifyLoaders (loaders) {
     return loaders.map(function (obj) {
@@ -174,6 +182,12 @@ module.exports = function (content) {
           return loader + '!' + rewriter + ensureBang(ensureLoader(lang))
         case 'script':
           return injectString + ensureBang(ensureLoader(lang))
+        default:
+          loader = loaders[type]
+          if (Array.isArray(loader)) {
+            loader = stringifyLoaders(loader)
+          }
+          return ensureBang(loader + buildCustomBlockLoaderString(part.attrs))
       }
     }
   }
@@ -191,7 +205,7 @@ module.exports = function (content) {
 
   function getSelectorString (type, index) {
     return selectorPath +
-      '?type=' + type +
+      '?type=' + ((type === 'script' || type === 'template' || type === 'styles') ? type : 'customBlocks') +
       '&index=' + index + '!'
   }
 
@@ -249,6 +263,32 @@ module.exports = function (content) {
     output += '\n'
   }
 
+    // add requires for customBlocks
+  if (parts.customBlocks && parts.customBlocks.length) {
+    var addedPrefix = false
+
+    parts.customBlocks.forEach(function (customBlock, i) {
+      if (!loaders[customBlock.type]) {
+        loaderContext.emitWarning('Loader for custom block type "' + customBlock.type + '" not found in webpack configuration')
+      } else {
+        // require customBlock
+        customBlock.src = customBlock.attrs.src
+        var requireString = customBlock.src
+          ? getRequireForImport(customBlock.type, customBlock)
+          : getRequire(customBlock.type, customBlock, i)
+
+        if (!addedPrefix) {
+          output += '\n/* customBlocks */\n'
+          addedPrefix = true
+        }
+
+        output += requireString + '\n'
+      }
+    })
+
+    output += '\n'
+  }
+
   // we require the component normalizer function, and call it like so:
   // normalizeComponent(
   //   scriptExports,
diff --git a/package.json b/package.json
index 3503c2b..51be4be 100644
--- a/package.json
+++ b/package.json
@@ -67,8 +67,11 @@
     "mocha": "^3.2.0",
     "node-libs-browser": "^2.0.0",
     "normalize-newline": "^3.0.0",
+    "null-loader": "^0.1.1",
     "postcss": "^5.0.21",
     "pug": "^2.0.0-beta6",
+    "raw-loader": "^0.5.1",
+    "skeleton-loader": "0.0.5",
     "stylus": "^0.54.5",
     "stylus-loader": "^2.0.0",
     "sugarss": "^0.2.0",


Test patch:
diff --git a/test/fixtures/custom-import.vue b/test/fixtures/custom-import.vue
new file mode 100644
index 0000000..34eed79
--- /dev/null
+++ b/test/fixtures/custom-import.vue
@@ -0,0 +1,22 @@
+<unit-test src="./unit-test.js">
+</unit-test>
+
+<template>
+  <h2 class="red">{{msg}}</h2>
+</template>
+
+<script>
+export default {
+  data () {
+    return {
+      msg: 'Hello from Component A!'
+    }
+  }
+}
+</script>
+
+<style>
+comp-a h2 {
+  color: #f00;
+}
+</style>
diff --git a/test/fixtures/custom-language.vue b/test/fixtures/custom-language.vue
new file mode 100644
index 0000000..3684d13
--- /dev/null
+++ b/test/fixtures/custom-language.vue
@@ -0,0 +1,31 @@
+<unit-test>
+  describe('example', function () {
+    it('basic', function (done) {
+      done();
+    })
+  })
+</unit-test>
+
+<documentation>
+  This is example documentation for a component.
+</documentation>
+
+<template>
+  <h2 class="red">{{msg}}</h2>
+</template>
+
+<script>
+export default {
+  data () {
+    return {
+      msg: 'Hello from Component A!'
+    }
+  }
+}
+</script>
+
+<style>
+comp-a h2 {
+  color: #f00;
+}
+</style>
diff --git a/test/fixtures/custom-options.vue b/test/fixtures/custom-options.vue
new file mode 100644
index 0000000..c8249bb
--- /dev/null
+++ b/test/fixtures/custom-options.vue
@@ -0,0 +1,7 @@
+<unit-test foo="bar" opt2="value2">
+  describe('example', function () {
+    it('basic', function (done) {
+      done();
+    })
+  })
+</unit-test>
diff --git a/test/fixtures/unit-test.js b/test/fixtures/unit-test.js
new file mode 100644
index 0000000..4223578
--- /dev/null
+++ b/test/fixtures/unit-test.js
@@ -0,0 +1,5 @@
+  describe('example', function () {
+    it('basic', function (done) {
+      done();
+    })
+  })
diff --git a/test/test.js b/test/test.js
index 2e7a8ed..69e7165 100644
--- a/test/test.js
+++ b/test/test.js
@@ -57,12 +57,12 @@ function bundle (options, cb) {
       })
     }
     expect(stats.compilation.errors).to.be.empty
-    cb(mfs.readFileSync('/test.build.js').toString())
+    cb(mfs.readFileSync('/test.build.js').toString(), stats.compilation.warnings)
   })
 }
 
 function test (options, assert) {
-  bundle(options, function (code) {
+  bundle(options, function (code, warnings) {
     jsdom.env({
       html: '<!DOCTYPE html><html><head></head><body></body></html>',
       src: [code],
@@ -226,11 +226,10 @@ describe('vue-loader', function () {
   })
 
   it('source map', function (done) {
-    var config = Object.assign({}, globalConfig, {
+    bundle({
       entry: './test/fixtures/basic.vue',
       devtool: '#source-map'
-    })
-    bundle(config, function (code) {
+    }, function (code, warnings) {
       var map = mfs.readFileSync('/test.build.js.map').toString()
       var smc = new SourceMapConsumer(JSON.parse(map))
       var line
@@ -266,7 +265,7 @@ describe('vue-loader', function () {
   })
 
   it('extract CSS', function (done) {
-    bundle(Object.assign({}, globalConfig, {
+    bundle({
       entry: './test/fixtures/extract-css.vue',
       vue: {
         loaders: {
@@ -277,7 +276,7 @@ describe('vue-loader', function () {
       plugins: [
         new ExtractTextPlugin('test.output.css')
       ]
-    }), function () {
+    }, function (code, warnings) {
       var css = mfs.readFileSync('/test.output.css').toString()
       css = normalizeNewline(css)
       expect(css).to.contain('h1 {\n  color: #f00;\n}\n\nh2 {\n  color: green;\n}')
@@ -488,7 +487,7 @@ describe('vue-loader', function () {
       output: Object.assign({}, globalConfig.output, {
         libraryTarget: 'commonjs2'
       })
-    }, function (code) {
+    }, function (code, warnings) {
       // http://stackoverflow.com/questions/17581830/load-node-js-module-from-string-in-memory
       function requireFromString(src, filename) {
         var Module = module.constructor;
@@ -570,4 +569,124 @@ describe('vue-loader', function () {
       })
     })
   })
+
+  it('extract custom blocks to a separate file', function (done) {
+    bundle({
+      entry: './test/fixtures/custom-language.vue',
+      vue: {
+        loaders: {
+          'documentation': ExtractTextPlugin.extract('raw-loader')
+        }
+      },
+      plugins: [
+        new ExtractTextPlugin('doc.md')
+      ]
+    }, function (code, warnings) {
+      var unitTest = mfs.readFileSync('/doc.md').toString()
+      unitTest = normalizeNewline(unitTest)
+      expect(unitTest).to.contain('This is example documentation for a component.')
+      done()
+    })
+  })
+
+  it('add custom blocks to the webpack output', function (done) {
+    bundle({
+      entry: './test/fixtures/custom-language.vue',
+      vue: {
+        loaders: {
+          'unit-test': 'babel-loader'
+        }
+      }
+    }, function (code, warnings) {
+      expect(code).to.contain('describe(\'example\', function () {\n  it(\'basic\', function (done) {\n    done();\n  });\n})')
+      done()
+    })
+  })
+
+  it('custom blocks work with src imports', function (done) {
+    bundle({
+      entry: './test/fixtures/custom-import.vue',
+      vue: {
+        loaders: {
+          'unit-test': 'babel-loader'
+        }
+      }
+    }, function (code, warnings) {
+      expect(code).to.contain('describe(\'example\', function () {\n  it(\'basic\', function (done) {\n    done();\n  });\n})')
+      done()
+    })
+  })
+
+  it('custom blocks can be removed from the output', function (done) {
+    bundle({
+      entry: './test/fixtures/custom-language.vue',
+      vue: {
+        loaders: {
+          'unit-test': 'null-loader'
+        }
+      }
+    }, function (code, warnings) {
+      expect(code).not.to.contain('describe(\'example\', function () {\n  it(\'basic\', function (done) {\n    done();\n  });\n})')
+      done()
+    })
+  })
+
+  it('no warnings when loaders are specified for all custom blocks', function (done) {
+    bundle({
+      entry: './test/fixtures/custom-language.vue',
+      vue: {
+        loaders: {
+          'documentation': 'null-loader',
+          'unit-test': 'null-loader'
+        }
+      }
+    }, function (code, warnings) {
+      expect(warnings.length).to.equal(0)
+      done()
+    })
+  })
+
+  it('warning should be raised when a loader is not specified for a custom block', function (done) {
+    bundle({
+      entry: './test/fixtures/custom-language.vue',
+      vue: {
+        loaders: {
+          'documentation': 'null-loader'
+        }
+      }
+    }, function (code, warnings) {
+      expect(warnings.length).to.equal(1)
+      expect(warnings[0].message).to.equal('Loader for custom block type "unit-test" not found in webpack configuration')
+      done()
+    })
+  })
+
+  it('passes attributes as options to the loader', function (done) {
+    bundle({
+      entry: './test/fixtures/custom-options.vue',
+      vue: {
+        loaders: {
+          'unit-test': 'babel-loader!skeleton-loader'
+        }
+      },
+      plugins: [
+        new webpack.LoaderOptionsPlugin({
+          options: {
+            skeletonLoader: {
+              procedure: function(content, sourceMap, callback, options) {
+                expect(options.foo).to.equal('bar');
+                expect(options.opt2).to.equal('value2');
+
+                // Return the content to output.
+                return content;
+              }
+            }
+          }
+        })
+      ]
+    }, function (code, warnings) {
+      expect(code).to.contain('describe(\'example\', function () {\n  it(\'basic\', function (done) {\n    done();\n  });\n})')
+      done()
+    })
+  })
 })