@oazmi/esbuild-plugin-deno
Version:
A portable non-invasive suite of esbuild plugins for loading http, jsr, npm, and import-maps. Supports Deno, Node, Bun, Web. Alternate to @luca/esbuild-deno-loader , while compatible with other plugins and native esbuild resolvers (e.g. css loader).
321 lines (320 loc) • 17.5 kB
JavaScript
/** contains utility functions for import-maps.
*
* @module
*/
import { ensureEndSlash, ensureStartDotSlash, isAbsolutePath, joinPaths, normalizePath, object_keys, replacePrefix, replaceSuffix } from "../deps.js";
/** resolve a potential `path_alias` to its absolutely referenced path from an `import_map`.
* if the input `path_alias` is not a part of the provided `import_map`, then `undefined` will be returned.
*
* for further reading on the specifics of what constitutes an import-map, see the documentation of {@link ImportMap}.
*
* @example
* ```ts
* import { assertEquals, assertThrows } from "jsr:@std/assert"
*
* // aliasing our function for brevity
* const fn = resolvePathFromImportMap
*
* const my_import_map = {
* // non-directory specifiers (i.e. not used for prefixing)
* "square" : "./module/shapes/square.js",
* "circle" : "https://example.com/shapes/circle.js",
* "http://shape.com/square.js" : "https://example.com/shapes/square.js",
* "./shapes/circle" : "/modules/shapes/circle/",
*
* // directory specifiers (i.e. used for matching prefixing of path alias)
* "shapes/" : "./module/shapes/",
* "other-shapes/" : "https://example.com/modules/shapes/",
* "other-shapes/triangle/" : "file:///C:/Users/illuminati/",
* "../modules/shapes/" : "/modules/shapes/",
*
* // incorrect import map value.
* // resolving this key will throw an error because the value should end in a trailing slash,
* // since the key also ends in a trailing slash.
* "modules/css/" : "/modules/shapes/css",
* }
*
* assertEquals(
* fn("square", my_import_map),
* "./module/shapes/square.js",
* )
*
* assertEquals(
* fn("circle", my_import_map),
* "https://example.com/shapes/circle.js",
* )
*
* // the following is not resolved because the `"circle"` key in `my_import_map` does not end with a trailing slash,
* // which is a requirement for matching prefix directories.
* assertEquals(
* fn("circle/bold-circle.js", my_import_map),
* undefined,
* )
*
* assertEquals(
* fn("http://shape.com/square.js", my_import_map),
* "https://example.com/shapes/square.js",
* )
*
* // even though there is no exact match of the non-normalized input path here,
* // once it is normalized inside of the function, it matches the same key as the previous test's.
* assertEquals(
* fn("http://shape.com/lib/../square.js", my_import_map),
* "https://example.com/shapes/square.js",
* )
*
* // even relative imports can be thought as path aliases, so long as there is a key for it in `my_import_map`.
* // moreover, it is permissible for an import-map value to end with a trailing slash, even when its associated key does not.
* assertEquals(
* fn("./shapes/circle", my_import_map),
* "/modules/shapes/circle/",
* )
*
* assertEquals(
* fn("shapes/", my_import_map),
* "./module/shapes/",
* )
*
* assertEquals(
* fn("shapes/rectangle.ts", my_import_map),
* "./module/shapes/rectangle.ts",
* )
*
* assertEquals(
* fn("other-shapes", my_import_map),
* undefined,
* )
*
* assertEquals(
* fn("other-shapes/doritos.html", my_import_map),
* "https://example.com/modules/shapes/doritos.html",
* )
*
* // the path alias is matched with the longest key first,
* // which is why it resolves to a path different from the prior test's key.
* assertEquals(
* fn("other-shapes/triangle/doritos.html", my_import_map),
* "file:///C:/Users/illuminati/doritos.html",
* )
*
* // the value of the key "modules/css/" is invalid (non-specs compliant),
* // since it does not end with a trailing slash, whereas the key does end with one.
* assertThrows(() => {
* fn("modules/css/cicle.css", my_import_map)
* })
* ```
*/
export const resolvePathFromImportMap = (path_alias, import_map) => {
// first of all, we normalize the `path_alias` to remove any intermediate directory traversal symbols, such as "./" and "../". required by the specification.
// TODO: below, should I strictly stick to using `normalizePosixPath` instead of `normalizePath`? since the later will not be able to match import-map keys that use backslash directory separators (windows).
path_alias = normalizePath(path_alias);
// now, we look for an exact match of the `path_alias`. if one is not found, then we do a "longest common directory" match instead.
const exact_match = import_map[path_alias];
if (exact_match) {
return exact_match;
}
// finding the longest `import_map` key that matches the input `path_alias` as a prefix.
// however, we will only match keys that end with a trailing slash ("/"), because that's what the specification permits when mapping prefixes.
// for details see "https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap#mapping_path_prefixes"
const sorted_import_map_keys = object_keys(import_map)
.filter((key) => key.endsWith("/"))
.toSorted((a, b) => (b.length - a.length));
for (const key of sorted_import_map_keys) {
if (path_alias.startsWith(key)) {
const value = import_map[key];
if (!value.endsWith("/")) {
throw new Error(`the value ("${value}") of the matched import-map key ("${key}") for the path alias "${path_alias}" MUST end with a trailing slash ("/") to be specification compliant.`);
}
return path_alias.replace(key, value);
}
}
// if not matches within the import map are found, then return `undefined`
return undefined;
};
/** use this compare-function to sort {@link ImportMapSortedEntries | import-map entries} by the key-alias's length, in decreasing order.
*
* @example
* ```ts
* import type { ImportMapSortedEntries } from "./typedefs.ts"
* import { assertEquals } from "jsr:@std/assert"
*
* const my_import_map_entries: ImportMapSortedEntries = [
* ["./hello/", "https://example.com/hello-greetings/"],
* ["./hello/world/", "https://example.com/hello-world/"],
* ["./hello/abc/", "https://example.com/hello-abc/"],
* ["./hello", "https://example.com/"],
* ]
*
* assertEquals(my_import_map_entries.toSorted(compareImportMapEntriesByLength), [
* ["./hello/world/", "https://example.com/hello-world/"],
* ["./hello/abc/", "https://example.com/hello-abc/"],
* ["./hello/", "https://example.com/hello-greetings/"],
* ["./hello", "https://example.com/"],
* ])
* ```
*/
export const compareImportMapEntriesByLength = ([alias_a, path_a], [alias_b, path_b]) => {
return alias_b.length - alias_a.length;
};
const defaultResolvePathFromImportMapEntriesConfig = {
baseAliasDir: "",
basePathDir: "",
sort: true,
errorCheck: true,
};
/** resolve a `path_alias` to its absolutely referenced path from an import map entries.
* if the input `path_alias` is not a part of the provided `import_map_entries`, then `undefined` will be returned.
*
* for further reading on the specifics of what constitutes an import-map, see the documentation of {@link ImportMap}.
*
* > [!tip]
* > why use this function when {@link resolvePathFromImportMap} exists?
* >
* > this function has the potential to be faster if you provide pre-sorted `import_map_entries`,
* > in addition to also providing configuration options for relative paths.
*
* to understand how configure the behavior of the resolver differently, using the optional `config` parameter,
* check out the documentation comments of {@link ResolvePathFromImportMapEntriesConfig}.
* but for a summary, here is the breakdown:
* - `config.baseAliasDir`: if defined (non-empty string),
* then the given base directory path will be prepended to all relative path-aliases of your import map entries.
* however, this will **not** affect any path-aliases that do not begin with a `"./"` (i.e. non-relative path-aliases).
* - `config.basePathDir`: if defined (non-empty string),
* then the given base directory path will be prepended to the resolved matching path-value
* **if** it is a relative path (which is checked via {@link isAbsolutePath}).
* - `config.sort`: specify `true` (default behavior) if your input `import_map_entries` is not already sorted alias-length-wise.
* it is necessary for the `import_map_entries` to be sorted for the import-map specification-compliant match to be selected.
* so, if your import map entries are pre-sorted, you can save time by setting this option to `false`.
* - `config.errorCheck`: if a path-value match is found for some `path_alias`, however the alias-path of the entry is directory,
* but the corresponding path-value is not a directory (i.e. does not end in "/"), then an error will be thrown.
*
* @example
* ```ts
* import { assertEquals, assertThrows } from "jsr:@std/assert"
*
* // out export map's entries
* const my_export_map_entries = [
* [".", "./src/mod.ts"],
* ["./", "./src/dir/"],
* ["./hello/abc/", "./src/hello-abc/xyz/"],
* ["./hello/world/", "./src/hello-world/"],
* ["./hello", "./src/js/script.ts"],
* ["./hello/", "./src/hello-greetings/"],
* ["./alias/dir/", "./incorrect/non-dir/path"],
* ["postcss", "./node_modules/postcss/main.js"],
* ["esbuild", "https://registry.npmjs.org/esbuild"],
* ["kitchensink/", "https://jsr.io/@oazmi/kitchensink/0.9.6/src/"],
* ] as Array<[alias: string, path: string]>
*
* // aliasing our functions, types, and objects for brevity
* type Config = Partial<ResolvePathFromImportMapEntriesConfig>
* const
* eq = assertEquals,
* err = assertThrows,
* fn = (path_alias: string, config?: Config) => resolvePathFromImportMapEntries(path_alias, my_export_map_entries, config),
* config_1: Config = { baseAliasDir: "jsr:@my-scope/my-lib" },
* config_2: Config = { baseAliasDir: "jsr:@my-other/library" },
* config_3: Config = { baseAliasDir: "jsr:@my-scope/my-lib/", basePathDir: "http://example.com" },
* config_4: Config = { basePathDir: "@scope/lib" }
*
* eq(fn("jsr:@my-scope/my-lib", config_1), "./src/mod.ts")
* eq(fn("jsr:@my-scope/my-lib/", config_1), "./src/dir/")
* eq(fn("jsr:@my-scope/my-lib/www/robot.txt", config_1), "./src/dir/www/robot.txt")
* eq(fn("jsr:@my-scope/my-lib/hello", config_1), "./src/js/script.ts")
* eq(fn("jsr:@my-scope/my-lib/hello/js/script.ts", config_1), "./src/hello-greetings/js/script.ts")
* eq(fn("jsr:@my-scope/my-lib/hello/world/", config_1), "./src/hello-world/")
* eq(fn("jsr:@my-scope/my-lib/hello/world/mod.ts", config_1), "./src/hello-world/mod.ts")
* eq(fn("jsr:@my-scope/my-lib/hello/abc/", config_1), "./src/hello-abc/xyz/")
* eq(fn("jsr:@my-scope/my-lib/hello/abc/mod.ts", config_1), "./src/hello-abc/xyz/mod.ts")
* eq(fn("jsr:@my-scope/my-lib/postcss", config_1), "./src/dir/postcss") // captured by the `"./"` alias
* eq(fn("postcss", config_1), "./node_modules/postcss/main.js")
* eq(fn("esbuild", config_1), "https://registry.npmjs.org/esbuild")
* eq(fn("kitchensink/pathman.ts", config_1), "https://jsr.io/@oazmi/kitchensink/0.9.6/src/pathman.ts")
*
* err(() => fn("jsr:@my-scope/my-lib/alias/dir/", config_1))
* err(() => fn("jsr:@my-scope/my-lib/alias/dir/xyz.txt", config_1))
*
* eq(fn("jsr:@my-scope/my-lib", config_2), undefined)
*
* eq(fn("jsr:@my-scope/my-lib", config_3), "http://example.com/src/mod.ts")
* eq(fn("jsr:@my-scope/my-lib/", config_3), "http://example.com/src/dir/")
* eq(fn("jsr:@my-scope/my-lib/www/robot.txt", config_3), "http://example.com/src/dir/www/robot.txt")
* eq(fn("jsr:@my-scope/my-lib/hello", config_3), "http://example.com/src/js/script.ts")
* eq(fn("jsr:@my-scope/my-lib/hello/js/script.ts", config_3), "http://example.com/src/hello-greetings/js/script.ts")
* eq(fn("jsr:@my-scope/my-lib/hello/world/", config_3), "http://example.com/src/hello-world/")
* eq(fn("jsr:@my-scope/my-lib/hello/world/mod.ts", config_3), "http://example.com/src/hello-world/mod.ts")
* eq(fn("jsr:@my-scope/my-lib/hello/abc/", config_3), "http://example.com/src/hello-abc/xyz/")
* eq(fn("jsr:@my-scope/my-lib/hello/abc/mod.ts", config_3), "http://example.com/src/hello-abc/xyz/mod.ts")
* eq(fn("jsr:@my-scope/my-lib/postcss", config_3), "http://example.com/src/dir/postcss")
* eq(fn("postcss", config_3), "http://example.com/node_modules/postcss/main.js")
* eq(fn("esbuild", config_3), "https://registry.npmjs.org/esbuild")
* eq(fn("kitchensink/pathman.ts", config_3), "https://jsr.io/@oazmi/kitchensink/0.9.6/src/pathman.ts")
*
* eq(fn(".", config_4), "@scope/lib/src/mod.ts")
* eq(fn("./", config_4), "@scope/lib/src/dir/")
* eq(fn("./www/robot.txt", config_4), "@scope/lib/src/dir/www/robot.txt")
* eq(fn("./hello", config_4), "@scope/lib/src/js/script.ts")
* eq(fn("./hello/js/script.ts", config_4), "@scope/lib/src/hello-greetings/js/script.ts")
* eq(fn("./hello/world/", config_4), "@scope/lib/src/hello-world/")
* eq(fn("./hello/world/mod.ts", config_4), "@scope/lib/src/hello-world/mod.ts")
* eq(fn("./hello/abc/", config_4), "@scope/lib/src/hello-abc/xyz/")
* eq(fn("./hello/abc/mod.ts", config_4), "@scope/lib/src/hello-abc/xyz/mod.ts")
* eq(fn("./postcss", config_4), "@scope/lib/src/dir/postcss")
* eq(fn("postcss", config_4), "@scope/lib/node_modules/postcss/main.js")
* eq(fn("esbuild", config_4), "https://registry.npmjs.org/esbuild")
* eq(fn("kitchensink/pathman.ts", config_4), "https://jsr.io/@oazmi/kitchensink/0.9.6/src/pathman.ts")
*
* eq(fn("", config_4), "@scope/lib/src/mod.ts")
* eq(fn("/", config_4), undefined)
* eq(fn("www/robot.txt", config_4), undefined)
* eq(fn("hello", config_4), undefined)
* eq(fn("hello/js/script.ts", config_4), undefined)
* eq(fn("hello/world/", config_4), undefined)
* eq(fn("hello/world/mod.ts", config_4), undefined)
* eq(fn("hello/abc/", config_4), undefined)
* eq(fn("hello/abc/mod.ts", config_4), undefined)
* eq(fn("postcss", config_4), "@scope/lib/node_modules/postcss/main.js")
* eq(fn("esbuild", config_4), "https://registry.npmjs.org/esbuild")
* eq(fn("kitchensink/pathman.ts", config_4), "https://jsr.io/@oazmi/kitchensink/0.9.6/src/pathman.ts")
* ```
*/
export const resolvePathFromImportMapEntries = (path_alias, import_map_entries, config) => {
let { baseAliasDir, basePathDir, errorCheck, sort } = { ...defaultResolvePathFromImportMapEntriesConfig, ...config };
// ensuring there is no trailing slashes, because they will be added later on.
baseAliasDir = replaceSuffix(baseAliasDir, "/") ?? baseAliasDir;
basePathDir = replaceSuffix(basePathDir, "/") ?? basePathDir;
// normalize the path alias, and strip away the `baseAliasDir` substring from it.
const unprefixed_path_alias = replacePrefix(normalizePath(path_alias), baseAliasDir), relative_path_alias = (unprefixed_path_alias === ""
|| (baseAliasDir !== "" && unprefixed_path_alias?.startsWith("/")))
? "." + unprefixed_path_alias
: unprefixed_path_alias;
// if removing the `baseAliasDir` prefix had failed, then no matching relative-alias has been found.
// so instead, we try matching absolutely named aliases by setting `baseAliasDir` to `""` this time.
if (relative_path_alias === undefined) {
return resolvePathFromImportMapEntries(path_alias, import_map_entries, { baseAliasDir: "", basePathDir, errorCheck, sort });
}
// sort the entries in descending-length-order of the alias keys, if the user's `sort` config option is `true` (which is the default)
if (sort) {
import_map_entries = import_map_entries.toSorted(compareImportMapEntriesByLength);
}
// finding the longest matching `alias` in our descending-length-order sorted export map entries.
for (const [alias, path] of import_map_entries) {
const residual_subpath = replacePrefix(relative_path_alias, alias);
if (residual_subpath !== undefined) {
if (errorCheck && alias.endsWith("/") && !path.endsWith("/")) {
throw new Error(`the value ("${path}") of the matched import-map key ("${alias}") for the path alias "${path_alias}" MUST end with a trailing slash ("/") to be specification compliant.`);
}
const base_path = (basePathDir === "" || isAbsolutePath(path))
? path
: joinPaths(ensureEndSlash(basePathDir), path);
// if there was an exact match (i.e. `relative_path_alias === alias`), then return the corresponding `path`.
if (residual_subpath === "") {
return base_path;
}
// if the alias is for a directory, then return the corresponding `path` + `residual_subpath`.
if (alias.endsWith("/")) {
return joinPaths(base_path, ensureStartDotSlash(residual_subpath));
}
}
}
};