Writing Webpack Loader

Example guide to writing a simple Webpack loader

In this blog post I will show how to write a Webpack loader. We will load Markdown files, will find a JavaScript code block inside and will return it to be processed by the rest of the pipeline.

Imagine we have a Markdown text file, and it has JavaScript code blocks like this:

1
2
3
4
5
# example
This file has a JS code block
const add = (a, b) => a + b
console.log('2 + 3 =', add(2, 3))
End of the file

Can we extract the JavaScript code block from the Markdown file and bundle it into an executable JavaScript resource to run on a web page? Let's try writing a Webpack loader to do so.

🧭 You can find the full source code at bahmutov/markdown-webpack-loader-example

Initial setup

Tip: if you have never used Webpack, read Webpack Concepts, Using webpack and even Web Packing the Internet.

Let's first install Webpack and its CLI

1
2
3
$ npm i -D webpack webpack-cli
+ [email protected]
+ [email protected]

Let's create webpack.config.js file that bundles the example.md file into dist/bundle.js

webpack.config.js
1
2
3
4
5
6
7
8
9
const path = require('path')

module.exports = {
entry: './example.md',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
},
}

We can run Webpack using NPM script build

package.json
1
2
3
4
5
{
"scripts": {
"build": "webpack"
}
}

At first, the build fails

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ npm run build

> [email protected] build /Users/gleb/git/markdown-webpack-loader-example
> webpack

[webpack-cli] Compilation finished
assets by status 297 bytes [cached] 1 asset
./example.md 132 bytes [built] [code generated] [1 error]

ERROR in ./example.md 1:0
Module parse failed: Unexpected character '#' (1:0)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
> # example
| > This file has a JS code block
|

webpack 5.3.0 compiled with 1 error in 163 ms

Webpack does not "know" how to load .md files. Let's tell Webpack to load them "as is".

1
2
$ npm i -D raw-loader
+ [email protected]
webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module.exports = {
entry: './example.md',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
},
module: {
rules: [
{
test: /\.md$/,
use: {
loader: 'raw-loader',
options: {},
},
},
],
},
}

Tip: you can find the list of common Webpack loaders here

The build process runs and ... outputs an empty bundle file.

1
2
3
4
5
6
7
8
9
10
11
$ npm run build

> [email protected] build /Users/gleb/git/markdown-webpack-loader-example
> webpack

[webpack-cli] Compilation finished
asset bundle.js 0 bytes [emitted] [minimized] (name: main)
./example.md 159 bytes [built] [code generated]
webpack 5.3.0 compiled successfully in 164 ms

$ cat dist/bundle.js

The loader does try to export an ES6 module by default, and since the markdown has no exports, there is nothing generated. We can output at least the wrapper code if we use CommonJS modules.

webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
module: {
rules: [
{
test: /\.md$/,
use: {
loader: 'raw-loader',
options: {
esModule: false,
},
},
},
],
},

The build output will have the require boilerplate code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ npm run build

> [email protected] build /Users/gleb/git/markdown-webpack-loader-example
> webpack

[webpack-cli] Compilation finished
asset bundle.js 296 bytes [emitted] [minimized] (name: main)
./example.md 161 bytes [built] [code generated]
webpack 5.3.0 compiled successfully in 185 ms

$ cat dist/bundle.js
(()=>{var n={221:n=>{n.exports="# example\n> This file has a JS code block\n\n```js\nconst add = (a, b) => a + b\nconsole.log('2 + 3 =', add(2, 3))\n```\n\nEnd of the file\n"}},e={};
!function o(r){if(e[r])return e[r].exports;var s=e[r]={exports:{}};
return n[r](s,s.exports,o),s.exports}(221)})();

Notice that the source code has the Markdown file as a plain string - that's how raw-loader works.

Markdown to HTML

Let's convert Markdown to HTML using Webpack and markdown-loader. We need to chain markdown-loader with html-loader.

1
2
3
$ npm i -D markdown-loader html-loader
+ [email protected]
+ [email protected]

We can use a different Webpack config file to keep the bundle commands separate for this example:

webpack.markdown-loader.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const path = require('path')

module.exports = {
entry: './example.md',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
},
module: {
rules: [
{
test: /\.md$/,
use: [
{
loader: 'html-loader',
options: {},
},
{
loader: 'markdown-loader',
options: {},
},
],
},
],
},
}

Webpack loaders are applied last to first. Thus our Markdown file first will be processed by the markdown-loader, then the output will be passed through the html-loader. Let's run the Webpack using NPM script

package.json
1
2
3
4
5
6
{
"scripts": {
"build": "webpack",
"build:markdown-loader": "webpack --config webpack.markdown-loader.js"
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ npm run build:markdown-loader

> [email protected] build:markdown-loader /Users/gleb/git/markdown-webpack-loader-example
> webpack --config webpack.markdown-loader.js

[webpack-cli] Compilation finished
asset bundle.js 390 bytes [emitted] [minimized] (name: main)
./example.md 297 bytes [built] [code generated]
webpack 5.3.0 compiled successfully in 329 ms

$ cat dist/bundle.js
(()=>{var e={978:e=>{e.exports='<h1 id="example">example</h1> <blockquote> <p>
This file has a JS code block</p> </blockquote> <pre><code class="language-js">
const add = (a, b) =&gt; a + b\nconsole.log(&#39;2 + 3 =&#39;, add(2, 3))</code></pre>
<p>End of the file</p> '}},o={};!function a(p){if(o[p])return o[p].exports;
var r=o[p]={exports:{}};return e[p](r,r.exports,a),r.exports}(978)})();

Note the produced bundle has the Markdown tags converted into HTML tags, and then into a JavaScript code string.

Import statements

So far we were transforming the Markdown file directly as the entry point. But it is more likely that we will have HTML or JavaScript as the entry point. That file will import the Markdown file (and we want to extract the JavaScript blocks from the Markdown file to export in the future).

Let's write index.js to print the Markdown text

index.js
1
2
3
import mdText from './example.md'
console.log('imported example.md')
console.log(mdText)

We will bundle index.js using another separate Webpack config file

webpack.index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const path = require('path')

module.exports = {
entry: './index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
},
module: {
rules: [
{
test: /\.md$/,
use: {
loader: 'raw-loader',
options: {},
},
},
],
},
}

We can bundle the file and see that Webpack was smart enough to optimize the import and directly substitute the Markdown text into the JavaScript code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ npm run build:index

> [email protected] build:index /Users/gleb/git/markdown-webpack-loader-example
> webpack --config webpack.index.js

[webpack-cli] Compilation finished
asset bundle.js 215 bytes [compared for emit] [minimized] (name: main)
orphan modules 159 bytes [orphan] 1 module
./index.js + 1 modules 248 bytes [built] [code generated]
webpack 5.3.0 compiled successfully in 187 ms

$ cat dist/bundle.js
(()=>{"use strict";console.log("imported example.md"),
console.log("# example\n> This file has a JS code block\n\n```js\nconst add = (a, b) => a + b\nconsole.log('2 + 3 =', add(2, 3))\n```\n\nEnd of the file\n")})();

We can even run the bundle directly from Node

1
2
3
4
5
6
7
8
9
10
$ node ./dist/bundle.js
imported example.md
# example
> This file has a JS code block

const add = (a, b) => a + b
console.log('2 + 3 =', add(2, 3))

End of the file

Nice!

Per-import loader

If we generally do not bundle Markdown files, we might avoid writing the special loader rules for .md files in the Webpack config file. Instead we can specify the loaders to use directly in the import statement. This is called inline configuration.

The Webpack config in this case is very bare

webpack.plain.js
1
2
3
4
5
6
7
8
9
10
11
const path = require('path')

// this Webpack config file does not specify
// how to load Markdown files
module.exports = {
entry: './plain.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
},
}

The plain.js that wants to import example.md specifies the loader name

1
2
3
4
5
// specify Webpack loaders using inline notation
// https://webpack.js.org/concepts/loaders/
import mdText from 'raw-loader!./example.md'
console.log('imported example.md')
console.log(mdText)

The build and the result is the same.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ npm run build:plain

> [email protected] build:plain /Users/gleb/git/markdown-webpack-loader-example
> webpack --config webpack.plain.js

[webpack-cli] Compilation finished
asset bundle.js 215 bytes [compared for emit] [minimized] (name: main)
orphan modules 159 bytes [orphan] 1 module
./plain.js + 1 modules 352 bytes [built] [code generated]
webpack 5.3.0 compiled successfully in 188 ms

$ node ./dist/bundle.js
imported example.md
# example
> This file has a JS code block

const add = (a, b) => a + b
console.log('2 + 3 =', add(2, 3))

End of the file

Our Markdown loader

Let's write our own loader that would find the JavaScript blocks in the Markdown text and returns them. For now let's say we return just the JavaScript code block as a string. We can start the loader using the loader example from the Webpack documentation.

block-loader.js
1
2
3
4
5
6
7
8
9
import { getOptions } from 'loader-utils'

export default function(source) {
const options = getOptions(this);

// Apply some transformations to the source...

return `export default ${ JSON.stringify(source) }`;
}

where loader-utils is something the Webpack team provides

1
2
$ npm i -D loader-utils
+ [email protected]

Let's use this loader

block.js
1
2
3
4
5
// load the JavaScript block as text from the specified file
// using a custom local loader from a local file
import jsBlockText from './block-loader!./example.md'
console.log('imported js block text')
console.log(jsBlockText)

We can bundle this file ... and it fails.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ npm run build:block

> [email protected] build:block /Users/gleb/git/markdown-webpack-loader-example
> webpack --config webpack.block.js

[webpack-cli] Compilation finished
assets by status 1.75 KiB [cached] 1 asset
runtime modules 657 bytes 3 modules
built modules 266 bytes [built]
./block.js 227 bytes [built] [code generated]
./block-loader.js!./example.md 39 bytes [not cacheable] [built] [code generated] [1 error]

ERROR in ./example.md (./block-loader.js!./example.md)
Module build failed (from ./block-loader.js):
/Users/gleb/git/markdown-webpack-loader-example/block-loader.js:1
(function (exports, require, module, __filename, __dirname) { import { getOptions } from 'loader-utils'
^^^^^^

SyntaxError: Cannot use import statement outside a module
at new Script (vm.js:84:7)

Hmm, yeah. We need to make sure the loaders are transpiled into CommonJS first. Let's change its source to use require and module.exports

block-loader.js
1
2
3
4
5
6
7
8
9
const { getOptions } = require('loader-utils')

module.exports = function (source) {
const options = getOptions(this)

// Apply some transformations to the source...

return `export default ${JSON.stringify(source)}`
}

The file gets bundled.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ npm run build:block

> [email protected] build:block /Users/gleb/git/markdown-webpack-loader-example
> webpack --config webpack.block.js

[webpack-cli] Compilation finished
asset bundle.js 218 bytes [emitted] [minimized] (name: main)
orphan modules 158 bytes [orphan] 1 module
./block.js + 1 modules 385 bytes [built] [code generated]
webpack 5.3.0 compiled successfully in 198 ms

$ node ./dist/bundle.js
imported js block text
# example
> This file has a JS code block

const add = (a, b) => a + b
console.log('2 + 3 =', add(2, 3))

End of the file

But it does not transform the source code yet. Let's extract the code block and return it. Let's install a utility module to parse Markdown source into an abstract syntax tree (AST).

1
2
$ npm i -D @textlint/markdown-to-ast
+ @textlint/[email protected]
block-loader.js
1
2
3
4
5
6
7
8
9
10
11
12
const { getOptions } = require('loader-utils')
const { parse } = require('@textlint/markdown-to-ast')

module.exports = function (source) {
const options = getOptions(this)

const ast = parse(source)
console.log('markdown ast')
console.log(ast)

return `export default ${JSON.stringify(source)}`
}

The printed tree object shows all parts of the Markdown document

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
$ npm run build:block

> [email protected] build:block /Users/gleb/git/markdown-webpack-loader-example
> webpack --config webpack.block.js

markdown ast
{
type: 'Document',
children: [
{
type: 'Header',
depth: 1,
children: [Array],
loc: [Object],
range: [Array],
raw: '# example'
},
{
type: 'BlockQuote',
children: [Array],
loc: [Object],
range: [Array],
raw: '> This file has a JS code block'
},
{
type: 'CodeBlock',
lang: 'js',
value: "const add = (a, b) => a + b\nconsole.log('2 + 3 =', add(2, 3))",
loc: [Object],
range: [Array],
raw: '```js\n' +
'const add = (a, b) => a + b\n' +
"console.log('2 + 3 =', add(2, 3))\n" +
'```'
},
{
type: 'Paragraph',
children: [Array],
loc: [Object],
range: [Array],
raw: 'End of the file'
}
],
loc: { start: { line: 1, column: 0 }, end: { line: 10, column: 0 } },
range: [ 0, 132 ],
raw: '# example\n' +
'> This file has a JS code block\n' +
'\n' +
'```js\n' +
'const add = (a, b) => a + b\n' +
"console.log('2 + 3 =', add(2, 3))\n" +
'```\n' +
'\n' +
'End of the file\n'
}
[webpack-cli] Compilation finished
asset bundle.js 218 bytes [compared for emit] [minimized] (name: main)
orphan modules 158 bytes [orphan] 1 module
./block.js + 1 modules 385 bytes [built] [code generated]
webpack 5.3.0 compiled successfully in 246 ms

So the top level code blocks are inside ast.children list with type: 'CodeBlock'. Let's find and return it.

block-loader.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { parse } = require('@textlint/markdown-to-ast')

module.exports = function (source) {
const ast = parse(source)
console.log('markdown ast')
console.log(ast)
const codeBlock = ast.children.find(
(node) => node.type === 'CodeBlock' && node.lang === 'js',
)
if (!codeBlock) {
throw new Error('Could not find code block')
}

return `export default ${JSON.stringify(codeBlock.value)}`
}

Now we see just the JavaScript source code

1
2
3
4
$ node ./dist/bundle.js
imported js block text
const add = (a, b) => a + b
console.log('2 + 3 =', add(2, 3))

Bundling extracted JavaScript code

We have extracted the JavaScript code block from the Markdown example.md file. But what if we want to bundle this JavaScript? What if we want the Markdown files to be pretty much the same as JavaScript source files?

Let me change the example.md file to have the following JS code block

1
2
3
const add = (a, b) => a + b
console.log('computing 2 + 3')
export default add(2, 3)

Right now the bundled code has the code block as a string. But we want to bundle the extracted code block, just like regular source code. First, let's update our custom loader to return the raw code block text.

block-loader.js
1
2
3
...
// return `export default ${JSON.stringify(codeBlock.value)}`
return codeBlock.value

Next, let's add babel-loader to our project. This loader allows bundling and transforming JavaScript code (and most projects already use it).

1
2
3
$ npm i -D babel-loader @babel/core
+ [email protected]
+ @babel/[email protected]

And now let's chain our loaders - first our custom loader should extract the JavaScript code block from the Markdown file, then the JavaScript code should be processed. We can chain loaders inline using ! syntax.

block.js
1
2
3
import jsBlockValue from 'babel-loader!./block-loader!./example.md'
console.log('imported js block value')
console.log(jsBlockValue)

Run the bundler - and see how the code from the Markdown has been evaluated (and shortened)

dist/bundle.js
1
2
(()=>{"use strict";console.log("computing 2 + 3");
console.log("imported js block value"),console.log(5)})();
1
2
3
4
node ./dist/bundle.js
computing 2 + 3
imported js block value
5

Tip: for compile-time code, you can use val-loader that executes the given JavaScript code and returns the result.