UNPKG

@oazmi/esbuild-generic-loader

Version:

a utility library for building generic file loading plugins for esbuild

204 lines (203 loc) 12 kB
/** a generic esbuild loader. * * ## How the loader works: * * To put it simply, a subclass of {@link GenericLoader} performs the following steps in order: * * 1. {@link GenericLoader.extractDeps} parses the dependencies of the provided `content`. * 2. {@link GenericLoader.parseToJs} creates a javascript-code that dynamically imports the dependencies, and exports the original `content`. * 3. [**you**](https://en.wikipedia.org/wiki/human) pass the javacscript-code to `esbuild` for bundling and transformation of the import statements. * 4. {@link GenericLoader.unparseFromJs} parses the resulting output javascript-code and extracts the new path names of the dependencies. * 5. {@link GenericLoader.injectDeps} merges back the parsed dependencies to the original `content`. * * ## Loader usage example: * * Here is how you would typically use a subclass of the {@link GenericLoader}: * * - instantiate a {@link GenericLoader} instance with optional config (which currently does nothing). * * ```ts * // make sure that you have extended `GenericLoader` and redefined the abstract methods * class MyLoader extends GenericLoader {} * * const my_file_loader = new MyLoader({ * path: "D:/my/project/my_file.xyz", * }) * ``` * * - convert the contents of the file you wish to bundle to equivalent javascript code using the {@link GenericLoader.parseToJs} method. * * ```ts * const js_content = await my_file_loader.parseToJs() * ``` * * - pass the js content to your esbuild plugin's `onLoad` result, or use it as an entrypoint via `stdin`. * * ```ts * const build_result = await esbuild.build({ * absWorkingDir: "D:/my/project/", * splitting: true, // required, so that the bundled `js_content` imports the referenced dependency files, instead of having them injected. * format: "esm", // required for the `splitting` option to work * bundle: true, // required, otherwise all links/dependencies will be treated as "external" and won't be transformed. * outdir: "./out/", // required, for multiple output files * write: false, // required, because the bundled content needs to exist in-memory for us to transform/unparse it back to its original form. * minify: true, // optiotnal, useful for treeshaking. * chunkNames: "[ext]/[name]-[hash]", // optional, useful for specifying the structure of the output directory * assetNames: "assets/[name]-[hash]", // optional, useful for specifying the structure of the output directory * plugins: [...denoPlugins()], // optional, use the Deno esbuild plugin to resolve "http://", "file://", "jsr:", and "npm:" imports. * stdin: { * contents: js_content, * loader: "ts", * resolveDir: "D:/my/project/", * sourcefile: "D:/my/project/my_file.xyz", * }, * }) * ``` * * - once the build is complete, convert back the bundled entrypoint from javascript to your file's format using the {@link GenericLoader.unparseFromJs} method. * * ```ts * const js_content_bundled = build_result.outputFiles[0].text // assuming that the first output file corresponds to your entrypoint * const my_file_bundled = await my_file_loader.unparseFromJs(js_content_bundled) * ``` * * - merge back the string contents of `my_file_bundled` to `build_results.outputFiles`, * and then write the outputs to the filesystem using the {@link "fs"!writeOutputFiles} utility function. * * ```ts * const { hash, path } = outputs.outputFiles[0] * build_result.outputFiles[0] = { text: my_file_bundled, hash, path } * await writeOutputFiles(outputs.outputFiles) * ``` * * @module */ import "./_dnt.polyfills.js"; import { DEBUG, escapeLiteralStringForRegex, json_stringify, zipArrays, zipIteratorsMapperFactory } from "./deps.js"; const defaultGenericLoaderConfig = { meta: true }, escape_regex_for_string_raw = /(\$)|(\`)|(\<\/script\>)/g, imports_beginning_marker = "globalThis.start_of_imports()", imports_ending_marker = "globalThis.end_of_imports()", import_statements_block_regex = new RegExp(escapeLiteralStringForRegex(imports_beginning_marker) + "[\,\;]*" // if esbuild minification is enabled, then either a ";" or a "," delimiter will be placed between statements instead of a new line. + `(?<importStatements>.*?)` + escapeLiteralStringForRegex(imports_ending_marker), "gs"), import_statement_regex = new RegExp("await\\s+import\\(\\s*\"(?<importPath>.*?)\"\\s*\\)[\,\;]*", "g"), deps_list_to_js_fn = zipIteratorsMapperFactory(([import_key, import_path]) => { return ` importKeys.push(${json_stringify(import_key)}) await import(${json_stringify(import_path)})`; }); /** the base class for creating custom loaders for any file type that is natively unsupported by `esbuild`. * - each loader _class_ handles one type of new file type. * - each loader _instance_ handles **one file**, and can be used only **once**, so that it does not hog onto resources. */ export class GenericLoader { config; meta = { imports: [] }; constructor(config) { this.config = { ...defaultGenericLoaderConfig, ...config }; } /** an overloadable method that should return a javascript-code string that exports the provided {@link content} parameter in the form of `export const content = ...`. * * by default, the baseclass {@link GenericLoader} escapes all characters of the `content` parameter, * so that the string is perfectly preserved after the virtual module's evaluation. <br> * this is achieved by using `String.raw` and escaping all dollarsigns ("$") and backticks ("\\`") with template expressions. * however, such a thing may not be desirable, and you may want the evaluation of the template expressions within your `content`, rather than suppressing it. * or you may wish to introduce additional functions to the script so that it evaluates the output content through a series of transformations. <br> * in such cases, you would want to overload this method to suit your transformations needs. * but make sure to always `export` variable named `content`. * * another very important escaping transformation that must take place is for the `"</script>"` closing tag. * this is because esbuild explicitly transforms all strings containing this literal into `"<\\/script>"`, * which is equivalent to the original string and a non-issue, unless `String.raw` is used, where the underlying string becomes deformed. * the reason why esbuild does this explicitly for the `"</script>"` tag is because if someone were to copy and paste their bundled js code into an html's script block, * then the original form of the `"</script>" string would close the script block pre-maturely, leaking the contents ahead of it and turning the html code illegible. <br> * check out the following github issue comment for more info about this transformation: * [github.com/evanw/esbuild/issues/2267#issuecomment-1149396846](https://github.com/evanw/esbuild/issues/2267#issuecomment-1149396846) */ async contentExportJs(content) { content = content.replaceAll(escape_regex_for_string_raw, "${\"$&\"}"); return `export const content = String.raw\`` + content + `\`\n`; } /** this method parses the provided {@link raw_content} parameter, * extracts its dependencies by calling the {@link extractDeps} method, * and then converts it to an equivalent javascript code that can be consumed and analyzed by `esbuild`. * * the generated javascript code looks like the following: * ```js * export const importKeys = [] * globalThis.start_of_imports() * importKeys.push("key_1") * await import("path_1") * // ... * importKeys.push("key_N") * await import("path_N") * globalThis.end_of_imports() * export const content = `${ORIGINAL_RAW_CONTENT}` * ``` */ async parseToJs(raw_content) { const { content, importKeys, importPaths } = await this.extractDeps(raw_content), deps_js_string = [...deps_list_to_js_fn(importKeys, importPaths)].join(""), content_export_js = await this.contentExportJs(content); if (DEBUG.META) { const meta_imports = this.meta.imports; for (const [key, path] of zipArrays(importKeys, importPaths)) { meta_imports.push({ key, in: path, out: "" }); } } return ` export const importKeys = [] ${imports_beginning_marker} ${deps_js_string} ${imports_ending_marker} ${content_export_js}`; } /** this method unparses the esbuild-bundled javascript code generated by {@link parseToJs}, * and analyzes the transformed paths of the imported dependencies, * and then injects back the transformed paths back to the original raw contents through the {@link insertDeps} method. */ async unparseFromJs(js_content) { const importPaths = []; const js_content_without_imports = js_content.replaceAll(import_statements_block_regex, (_full_match, ...args) => { // here, `_full_match` is: // `${imports_beginning_marker}\n${deps_js_string}\n${imports_ending_marker}` const [named_groups, _full_string, _offset, ..._unused_groups] = args.toReversed(), marked_import_statements = named_groups.importStatements; // now, `marked_import_statements` is: // `\n${deps_js_string}\n` const ordered_import_key_statements = marked_import_statements.replaceAll(import_statement_regex, (_full_match, ...args) => { // here, `_full_match` is: // `await import(${JSON.stringify(bundled_import_path)})` const [named_groups, _full_string, _offset, ..._unused_groups] = args.toReversed(), bundled_import_path = named_groups.importPath; importPaths.push(bundled_import_path); // since we will be dynamically running/evaluating our `js_content` as a module, in order to retrieve the original raw content, // we will have to strip away these dynamic imports, since they are a dependency of the raw content, but not a requirement to _load_ the raw content. return ""; }); /** all what remains now is: * ```js * export const importKeys = [] * importKeys.push("key_1") * // ... * importKeys.push("key_N") * export const content = `${ORIGINAL_RAW_CONTENT}` * ``` */ return ordered_import_key_statements; }); const js_blob = new Blob([js_content_without_imports], { type: "text/javascript" }), js_blob_url = URL.createObjectURL(js_blob), // now we dynamically load our bundled js script that contains the raw contents (`content`), // and the ordered list of uniqie keys associated with each import path (`importKeys`) { content, importKeys } = await import(js_blob_url), { meta: { imports: metaImports }, config: { meta: metaEnabled } } = this, number_of_imports = importKeys.length; if (DEBUG.ASSERT && (number_of_imports !== importPaths.length || (DEBUG.META && metaEnabled && number_of_imports !== metaImports.length))) { throw new Error("encountered a mismatch between number of imported dependencies, and number of keys assigned to dependencies"); } if (DEBUG.META && metaEnabled) { for (const [key, path, import_entry] of zipArrays(importKeys, importPaths, metaImports)) { if (DEBUG.ASSERT && (json_stringify(key) !== json_stringify(import_entry.key))) { throw new Error("encountered a mismatch between the original key and the key obtained from evaluating javascript module"); } import_entry.out = path; } } return this.insertDeps({ importKeys, importPaths, content, }); } }