From aa8a67f235c97ed9a88e0c90d2804e7685d9a662 Mon Sep 17 00:00:00 2001 From: ManBearTM Date: Mon, 28 Mar 2022 14:02:49 +0200 Subject: [PATCH 1/7] Add loader support for ReScript files --- src/index.ts | 89 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2ea49bb..44a67e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; @@ -46,9 +47,14 @@ async function launchReScript(watch: boolean) { } export default function createReScriptPlugin(): Plugin { + let root: string; + let usingLoader = false; 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'; @@ -73,23 +79,82 @@ 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('.bs.js')) 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(/\.bs\.js$/, '.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('./lib/es6', relativePath) + .replace(/\.res$/, '.bs.js'); + + // 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('.bs.js')) { + const lib = path.resolve('./lib/es6'); + const relativePath = path.relative(lib, file); + if (relativePath.startsWith('..')) return; + const resFile = relativePath.replace(/\.bs\.js$/, '.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; }, }; } From e5c54a1ea5b1159f1d7f353e316fc84bf9f66dcd Mon Sep 17 00:00:00 2001 From: ManBearTM Date: Tue, 29 Mar 2022 12:00:40 +0200 Subject: [PATCH 2/7] Update README.md --- README.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/README.md b/README.md index 083e4ad..bf5b014 100644 --- a/README.md +++ b/README.md @@ -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. @@ -33,6 +39,60 @@ 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 this setting without the loader, it becomes impossible to import local files using relative paths. +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` file are not copied into this directory. By using the loader it no longer fails since the bundler will resolve +the files relative to the `.res` file instead. + +### Configuration + +When using Vite in 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/App.res', + }, + }, +}); +``` + +For HTML entry points, it must be imported using inline JS: + +```html + + + + + + + Vite App + + +
+ + + +``` + [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 From fef28b2175b1b8061eadd2b59cbf43ea64372ffd Mon Sep 17 00:00:00 2001 From: ManBearTM Date: Tue, 29 Mar 2022 12:10:04 +0200 Subject: [PATCH 3/7] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bf5b014..6390d51 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Take for example the following code: ``` 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` file are not copied into this directory. By using the loader it no longer fails since the bundler will resolve +but the `.css` and `.js` files are not copied into this directory. By using the loader it no longer fails since the bundler will resolve the files relative to the `.res` file instead. ### Configuration From 3176a7f858c00b35c72dd9a51c02a9465e91e019 Mon Sep 17 00:00:00 2001 From: ManBearTM Date: Tue, 26 Apr 2022 12:30:29 +0200 Subject: [PATCH 4/7] Update README --- README.md | 49 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6390d51..e483b70 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ export default defineConfig({ 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 this setting without the loader, it becomes impossible to import local files using relative paths. +When using `"in-source": false` (without the loader), importing local files using relative paths is troublesome. Take for example the following code: ```res @@ -52,27 +52,32 @@ Take for example the following code: ``` 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 using the loader it no longer fails since the bundler will resolve -the files relative to the `.res` file instead. +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 -When using Vite in library mode, just use the `.res` as entry point: +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 -import { defineConfig } from 'vite'; -import createReScriptPlugin from '@jihchi/vite-plugin-rescript'; - export default defineConfig({ - plugins: [createReScriptPlugin()], - build: { - lib: { - entry: 'src/App.res', - }, - }, + 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._ + +### Setup + For HTML entry points, it must be imported using inline JS: ```html @@ -87,12 +92,28 @@ For HTML entry points, it must be imported using inline JS:
``` +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 From edca5695596496bca5efb956259fb315c8636a0c Mon Sep 17 00:00:00 2001 From: ManBearTM Date: Tue, 26 Apr 2022 13:13:22 +0200 Subject: [PATCH 5/7] Add loader config + fix watch mode --- src/index.ts | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/index.ts b/src/index.ts index 44a67e7..f209b38 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,9 +46,22 @@ 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', @@ -90,7 +103,7 @@ export default function createReScriptPlugin(): Plugin { if (source.endsWith('.res')) usingLoader = true; if (options.isEntry || !importer) return null; if (!importer.endsWith('.res')) return null; - if (!source.endsWith('.bs.js')) return null; + if (!source.endsWith(suffix)) return null; if (path.isAbsolute(source)) return null; // This is the directory name of the ReScript file @@ -105,7 +118,7 @@ export default function createReScriptPlugin(): Plugin { } // Only replace the last occurrence - const resFile = source.replace(/\.bs\.js$/, '.res'); + const resFile = source.replace(suffixRegex, '.res'); const id = path.join(dirname, resFile); // Enable other plugins to resolve the file @@ -130,8 +143,11 @@ export default function createReScriptPlugin(): Plugin { // Find the path to the generated js file const relativePath = path.relative(root, id); const filePath = path - .resolve('./lib/es6', relativePath) - .replace(/\.res$/, '.bs.js'); + .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') }; @@ -140,11 +156,11 @@ export default function createReScriptPlugin(): Plugin { // 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('.bs.js')) { - const lib = path.resolve('./lib/es6'); + if (usingLoader && file.endsWith(suffix)) { + const lib = path.resolve(output); const relativePath = path.relative(lib, file); if (relativePath.startsWith('..')) return; - const resFile = relativePath.replace(/\.bs\.js$/, '.res'); + const resFile = relativePath.replace(suffixRegex, '.res'); const id = path.join(root, resFile); const moduleNode = server.moduleGraph.getModuleById(id); if (moduleNode) return [moduleNode]; From cbded31146d8fa3b8b2c2fca9cbfbc394cbf132e Mon Sep 17 00:00:00 2001 From: ManBearTM Date: Tue, 26 Apr 2022 13:18:49 +0200 Subject: [PATCH 6/7] Add note for es6-global caveat --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index e483b70..25ae221 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,9 @@ export default defineConfig({ _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: From 99845e77c7ffedcaa8f6e910fccd470f673021a0 Mon Sep 17 00:00:00 2001 From: ManBearTM Date: Wed, 27 Apr 2022 11:17:52 +0200 Subject: [PATCH 7/7] Fix build watch triggering recompilation too soon --- src/index.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index f209b38..2366f95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -81,7 +81,14 @@ export default function createReScriptPlugin(config?: Config): 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