Building a TypeScript-Compatible Webpack Loader: A PlantUML Mind Map Example

September 13, 2023

Introduction

In the historical book "The Pragmatic Programmer", there has always been an entire chapter dedicated to the importance of the ability to process text files of various formats quickly and efficiently (see "The Basic Tools: Text Manipulation"). The ability to use different text formats in various situations has always been essential and likely will continue to be.

For example, while JSON notation is super useful in many contexts, in some use cases, such as creating mind maps, another format might be much better. For example, PlantUML's mind maps might be a more preferable option for storing serialised trees.

That's what we will implement - store this tree-structured data as text in PlanUML format and use it in a JS app.

Quick links

  • Custom webpack loader in npm
  • Source code in GitHub

Vision

Although parsing files at runtime might be a good idea, another option might be even more beneficial. If we can integrate loading with the building process, we will be able to ensure that the data required for running the app is a part of the bundle and doesn't require passing non-common extension files separately.

In this tutorial, we will create a webpack loader that will be taking care of parsing PlantUML mind map file, converting the content to a standard JS object and providing the object to any JS module that imports it by file name.

Solution

We need to do 2 things to achieve the goal: create a loader as an injectable package and integrate this loader with the building process of the project that will be actually importing files with .puml extension.

As a bonus, we will setup basic TypeScript support that will allow importing the text file content with type definitions provided.

Create a custom loader

Project setup

  1. Create a folder:
mkdir plantuml-mindmap-loader && cd plantuml-mindmap-loader
  1. Initialise an npm project (you don't need to change default suggestions as some will be manually updated later in package.json):
npm init
  1. Install dev dependencies:
npm install --save-dev @types/webpack typescript
  1. Add webpack as a peer dependency to the project:

In package.json:

{
	...
	"peerDependencies": {
		"webpack": "^4.0.0 || ^5.0.0"
	}
	...
}

Note: It's a webpack loader, hence, webpack is always installed in a "parent" project.

  1. Update main and types properties in package.json to paths that we will be using for the results of building:
{
	...
	"main": "lib/index.js",
	"types": "index.d.ts",
	...
}
  1. Create tsconfig.json in the root directory of the project with following content:
{
	"compilerOptions": {
		"target": "es2016",
		"module": "commonjs",
		"allowJs": false,
		"outDir": "./lib",
		"esModuleInterop": true,
		"strict": true
	}
}

Note: If you want to use Jest for testing, consider adding the below config to skip building test files, like this:

{
	...
	"include": [
		"**/*"
	],
	"exclude": [
		"**/*.spec.ts",
		"coverage/**/*"
	]
	...
}
  1. Create the entry point for the loader - index.ts - with the following content:
import { validate } from 'schema-utils';
import { LoaderContext } from 'webpack';

import { parseMindMap } from './src/parseMindMap';

interface LoaderProperties {
	test: string;
}

const schema = {
	type: 'object' as 'object', // `as` is for making the type match `JSONSchema7`
	properties: {
		test: {
			type: 'string' as 'string', // `as` is for making the type match `JSONSchema7`
		},
	},
};

// Should match the definition in index.d.ts
export interface PlantUMLMindMapNode {
	id: number; // Order number of the line in the file
	label: string;
	tags: string[];
	children: PlantUMLMindMapNode[];
}

export default function (this: LoaderContext<LoaderProperties>, source: string): string {

	// 1. Get options that can be provided to the loader
	// via webpack loader configuration.
	// Also, validate these options.
	const options = this.getOptions();
	validate(schema, options, {
		name: 'plantuml-mindmap-loader',
		baseDataPath: 'options',
	});

	// 2. Do the core thing of your loader.
	// In our case, we take `source` which is the content of a file being imported
	// and convert it to a JS object, recreating the tree structure
	// defined by PlantUML mind map.
	const result = parseMindMap(source);

	// 3. We return a string that is a valid JS code
	// that will be injected as a result of file loading.
	// In our case, we simply stringify the JSON object.
	return `export default ${JSON.stringify(result)};`;
}

Note: The key thing is the content of the default exported function. Here, we have 3 building blocks. Take a look at the comments in the code for information.

  1. The functionality of converting PlantUML mind map string should be placed in ./src/parseMindMap.ts. Since, it's not the core subject of this article, let me only share the link to public GitHub repository where actual content can be found.
  2. Create types for this plugin in index.d.ts so that the consumers of the loader can use it:
declare module '*.mindmap.puml' {

	// Should match the definition in index.ts
	export interface PlantUMLMindMapNode {
		id: number; // Number of the line in the file
		label: string;
		tags: string[];
		children: PlantUMLMindMapNode[];
	}
	const content: PlantUMLMindMapNode;
	export default content;
}

That should be enough for now. If you're interested in how it might be covered with unit-tests, take a look at the GitHub repo.

Integrate it with your target project

This part is pretty straightforward. All you need to do is a few things:

  1. Install the plugin as a dev dependency (it's needed for building only)
npm install --save-dev ../packages/plantuml-mindmap-loader

Note: This installation is from a local directory. For installing from npm or from another repository, change the package path.

  1. Update webpack.config.js to include this loader:
module.exports = {
	...
	module: {
		rules: [
			...
			{
				test: /\.mindmap.puml$/,
				use: 'plantuml-mindmap-loader'
			},
			...
		]
	},
	...
}

Note 1: The test value includes RegEx that specifies for files of what extension should the loader be used. In our case, we ask webpack to process any encountered **.mindmap.puml with this new loader.

Note 2: The test value should match the module definition in index.d.ts file of the loader. That will ensure that every import is covered with an accompanying type set from module definition.

  1. If the types from the plugin aren't visible for your IDE or webpack complains about missing types for your new imports, update tsconfig.json in the target project to include the type definition like this:
{
	...
	"include": [
		"src/**/*",
		"node_modules/plantuml-mindmap-loader/index.d.ts"
	]
	...
}
  1. Import your custom format file into your code and work with it as if it was a JS object:
import testMindMap from "./test.mindmap.puml";

export function printMindMap() {
	console.log(JSON.stringify(navigationMindMap, null, 2));
}

Results

If everything is done right, for such content of test.mindmap.puml:

@startmindmap

* Root #tag1 #tag2
** Node 1 #tag3
*** Node 1.1
**** Node 1.1.1
** Node 2
*** Node 2.1

@endmindmap

you will get that output in your target project:

{
  "id": 3,
  "label": "Root",
  "tags": [
    "#tag1",
    "#tag2"
  ],
  "children": [
    {
      "id": 4,
      "label": "Node 1",
      "tags": [
        "#tag3"
      ],
      "children": [
        {
          "id": 5,
          "label": "Node 1.1",
          "tags": [],
          "children": [
            {
              "id": 6,
              "label": "Node 1.1.1",
              "tags": [],
              "children": []
            }
          ]
        }
      ]
    },
    {
      "id": 7,
      "label": "Node 2",
      "tags": [],
      "children": [
        {
          "id": 8,
          "label": "Node 2.1",
          "tags": [],
          "children": []
        }
      ]
    }
  ]
}

Result & Beyond

By using webpack loaders in this manner, we can work with any text format in our JS projects. This approach isolates the specifics of parsing distinct text formats to the place that is the most suitable for that: the build process.

Also, the validation of content embedded in a loader helps ensure that the static assets of this type are compatible with the latest version of your code.

But it has at least one significant drawback that worth a mention:

  • With that approach, you can work with static assets only. If a file should be located in a shared storage and on purpose fetched and processed in runtime, a different approach should be chosen.

Nevertheless, this approach is helpful in many scenarios. That makes almost any static asset functional.

Share other samples of inventive use of custom webpack loaders!

Conclusion

I hope this can serve as a helpful guide when you'll be creating your own custom loaders. It's a powerful tool and it deserves more frequent and inventive use in your projects.

The loader I created is also published to npm.

In future posts, I'll share more info about why I want to make PlantUML mind maps first-class citizens of my codebase. Adding these details here would inflate the article... There will be a day and there will be another story.

Follow me so you don't miss these updates.