Skip to content

Commit

Permalink
Merge pull request #5 from ManBearTM/main
Browse files Browse the repository at this point in the history
Add support for loading ReScript files
  • Loading branch information
jihchi authored May 1, 2022
2 parents 22f8321 + 99845e7 commit 5cb3f50
Show file tree
Hide file tree
Showing 2 changed files with 186 additions and 14 deletions.
84 changes: 84 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
![npm download per month][npm-download-shield]
[![npm license][npm-licence-shield]](./LICENSE)

Integrate ReScript with Vite by:

- Starting ReScript compilation automatically
- Showing HMR overlay for ReScript compiler errors
- Importing `.res` files directly (see [Using Loader](#using-loader))

## Getting Started

> If you are looking for a template to quickly start a project using Vite, ReScript and React, take a look at [vitejs-template-react-rescript](https://github.com/jihchi/vitejs-template-react-rescript), the template depends on this plugin.
Expand Down Expand Up @@ -33,6 +39,84 @@ export default defineConfig({
});
```

## Using Loader

The plugin comes with support for loading `.res` files directly. This is optional and in most cases not necessary,
but can be useful in combination with ["in-source": false](https://rescript-lang.org/docs/manual/latest/build-configuration#package-specs).
When using `"in-source": false` (without the loader), importing local files using relative paths is troublesome.
Take for example the following code:

```res
%%raw(`import "./local.css"`)
@module("./local.js") external runSomeJs: unit => unit = "default"
```

The bundler will fail when reaching this file, since the imports are resolved relative to the generated JS file (which resides in `lib`),
but the `.css` and `.js` files are not copied into this directory. By utilizing the loader it no longer fails since the bundler will
resolve the files relative to the `.res` file instead.

### Configuration

The loader is configured to support `lib/es6` output with `.bs.js` suffix by default. This can be
changed by providing an options object to the plugin:

```js
export default defineConfig({
plugins: [
createReScriptPlugin({
loader: {
output: './lib/js',
suffix: '.mjs',
},
}),
],
});
```

_Note: It is recommended to use `.bs.js` suffix since the loader cannot otherwise distinguish
between imports of regular JS files and those that were generated by the ReScript compiler._

_Note: Using es6-global module format may cause issues with imports of ReScript node modules,
since the paths to the node_modules will be generated as relative to the `lib` folder._

### Setup

For HTML entry points, it must be imported using inline JS:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="root"></div>
<script type="module">
import '/src/Main.res';
</script>
</body>
</html>
```

If using Vite with library mode, just use the `.res` as entry point:

```js
import { defineConfig } from 'vite';
import createReScriptPlugin from '@jihchi/vite-plugin-rescript';

export default defineConfig({
plugins: [createReScriptPlugin()],
build: {
lib: {
entry: 'src/Main.res',
},
},
});
```

[workflows-ci-shield]: https://github.com/jihchi/vite-plugin-rescript/actions/workflows/main.yml/badge.svg
[workflows-ci-url]: https://github.com/jihchi/vite-plugin-rescript/actions/workflows/main.yml
[npm-package-shield]: https://img.shields.io/npm/v/@jihchi/vite-plugin-rescript
Expand Down
116 changes: 102 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as path from 'path';
import { readFile, existsSync } from 'fs';
import { existsSync } from 'fs';
import * as fs from 'fs/promises';
import { Plugin } from 'vite';
import execa from 'execa';
import npmRunPath from 'npm-run-path';
Expand Down Expand Up @@ -45,10 +46,28 @@ async function launchReScript(watch: boolean) {
}
}

export default function createReScriptPlugin(): Plugin {
interface Config {
loader?: {
output?: string;
suffix?: string;
};
}

export default function createReScriptPlugin(config?: Config): Plugin {
let root: string;
let usingLoader = false;

// Retrieve loader config
const output = config?.loader?.output ?? './lib/es6';
const suffix = config?.loader?.suffix ?? '.bs.js';
const suffixRegex = new RegExp(`${suffix.replace('.', '\\.')}$`);

return {
name: '@jihchi/vite-plugin-rescript',
enforce: 'pre',
async configResolved(resolvedConfig) {
root = resolvedConfig.root;

const { build, command, mode } = resolvedConfig;
const needReScript =
(command === 'serve' && mode === 'development') || command === 'build';
Expand All @@ -62,7 +81,14 @@ export default function createReScriptPlugin(): Plugin {
);
}
},
config: () => ({
config: userConfig => ({
build: {
// If the build watcher is enabled (adding watch config would automatically enable it),
// exclude rescript files since recompilation should be based on the generated JS files.
watch: userConfig.build?.watch
? { exclude: ['**/*.res', '**/*.resi'] }
: null,
},
server: {
watch: {
// Ignore rescript files when watching since they may occasionally trigger hot update
Expand All @@ -73,23 +99,85 @@ export default function createReScriptPlugin(): Plugin {
configureServer(server) {
// Manually find and parse log file after server start since
// initial compilation does not trigger handleHotUpdate.
readFile(
path.resolve('./lib/bs/.compiler.log'),
(readFileError, data) => {
if (!readFileError && data) {
const log = data.toString();
const err = parseCompilerLog(log);
if (err) server.ws.send({ type: 'error', err });
}
}
);
fs.readFile(path.resolve('./lib/bs/.compiler.log'), 'utf8').then(data => {
const log = data.toString();
const err = parseCompilerLog(log);
if (err) server.ws.send({ type: 'error', err });
});
},
// Hook that resolves `.bs.js` imports to their `.res` counterpart
async resolveId(source, importer, options: any) {
if (source.endsWith('.res')) usingLoader = true;
if (options.isEntry || !importer) return null;
if (!importer.endsWith('.res')) return null;
if (!source.endsWith(suffix)) return null;
if (path.isAbsolute(source)) return null;

// This is the directory name of the ReScript file
const dirname = path.dirname(importer);

try {
// Check if the source is a node module or an existing file
require.resolve(source, { paths: [dirname] });
return null;
} catch (err) {
// empty catch
}

// Only replace the last occurrence
const resFile = source.replace(suffixRegex, '.res');
const id = path.join(dirname, resFile);

// Enable other plugins to resolve the file
const resolution = await this.resolve(resFile, importer, {
skipSelf: true,
...options,
});

// The file either doesn't exist or was marked as external
if (!resolution || resolution.external) return resolution;

// Another plugin resolved the file
if (resolution.id !== resFile) return resolution;

// Set the id to the absolute path of the ReScript file
return { ...resolution, id };
},
// Hook that loads the generated `.bs.js` file from `lib/es6` for ReScript files
async load(id) {
if (!id.endsWith('.res')) return null;

// Find the path to the generated js file
const relativePath = path.relative(root, id);
const filePath = path
.resolve(output, relativePath)
.replace(/\.res$/, suffix);

// Add the generated file to the watch module graph
this.addWatchFile(filePath);

// Read the file content and return the code
return { code: await fs.readFile(filePath, 'utf8') };
},
async handleHotUpdate({ file, read, server }) {
if (file.endsWith('.compiler.log')) {
// HMR is not automatically triggered when using the ReScript file loader.
// This waits for the generated `.bs.js` files to be generated, then finds
// their associated `.res` files and marks them as files to be reloaded.
if (usingLoader && file.endsWith(suffix)) {
const lib = path.resolve(output);
const relativePath = path.relative(lib, file);
if (relativePath.startsWith('..')) return;
const resFile = relativePath.replace(suffixRegex, '.res');
const id = path.join(root, resFile);
const moduleNode = server.moduleGraph.getModuleById(id);
if (moduleNode) return [moduleNode];
} else if (file.endsWith('.compiler.log')) {
const log = await read();
const err = parseCompilerLog(log);
if (err) server.ws.send({ type: 'error', err });
}

return;
},
};
}

0 comments on commit 5cb3f50

Please sign in to comment.