-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
18 changed files
with
555 additions
and
136 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,193 @@ | ||
// @ts-check | ||
|
||
import path from "node:path"; | ||
import { access, readFile } from "node:fs/promises"; | ||
import { parse } from "es-module-lexer"; | ||
import parseFromString from "./resolve-import-map/parseFromString.mjs"; | ||
import resolveImportMap from "./resolve-import-map/resolveImportMap.mjs"; | ||
|
||
/** | ||
* @typedef {Map<string, Array<string>>} Cache A cache for resolved imports. | ||
*/ | ||
|
||
// The import map parser requries a base url. We don't require one for our purposes, | ||
// but it allows us to use the parser without modifying the source. One quirk is that it will try map | ||
// this url to files locally if it's specified, but no one should do that. | ||
const DUMMY_HOSTNAME = "example.com"; | ||
|
||
/** | ||
* Reads a file if possible. | ||
* @param {string} filePath The path to the file. | ||
* @returns The file contents, or otherwise `undefined`. | ||
*/ | ||
async function tryReadFile(filePath) { | ||
try { | ||
return await readFile(filePath, "utf-8"); | ||
} catch (error) { | ||
// Do nothing. | ||
} | ||
} | ||
|
||
/** | ||
* Checks if a file exists. | ||
* @param {string} filePath The path to the file. | ||
* @returns Does the file exist. | ||
*/ | ||
async function exists(filePath) { | ||
try { | ||
await access(filePath); | ||
return true; | ||
} catch (error) { | ||
return false; | ||
} | ||
} | ||
|
||
/** | ||
* Recursively parses and resolves a module's imports. | ||
* @param {string} module The path to the module. | ||
* @param {object} options Options. | ||
* @param {string} options.url The module URL to resolve. | ||
* @param {object} [options.parsedImportMap] A parsed import map. | ||
* @param {boolean} [root] Whether the module is the root module. | ||
* @returns An array containing paths to modules that can be preloaded. | ||
*/ | ||
async function resolveImports(module, { url, parsedImportMap }, root = true) { | ||
/** @type {Array<string>} */ | ||
let modules = []; | ||
|
||
const source = await tryReadFile(module); | ||
|
||
if (source === undefined) { | ||
return modules; | ||
} | ||
|
||
const [imports] = parse(source); | ||
|
||
await Promise.all( | ||
imports.map(async ({ n: specifier, d }) => { | ||
const dynamic = d > -1; | ||
if (specifier && !dynamic) { | ||
let importMapResolved = null; | ||
|
||
// If an import map is supplied, everything resolves through it. | ||
if (parsedImportMap) { | ||
importMapResolved = resolveImportMap( | ||
specifier, | ||
parsedImportMap, | ||
new URL(url, `https://${DUMMY_HOSTNAME}`), | ||
); | ||
} | ||
|
||
let resolvedModule; | ||
|
||
// Are we resolving with an import map? | ||
if (importMapResolved !== null) { | ||
// It will match if it's a local module. | ||
if (importMapResolved.hostname === DUMMY_HOSTNAME) { | ||
resolvedModule = path.resolve( | ||
path.dirname(module), | ||
`.${importMapResolved.pathname}`, | ||
); | ||
} | ||
} else { | ||
resolvedModule = path.resolve(path.dirname(module), specifier); | ||
} | ||
|
||
// If the module has resolved to a local file (and it exists), then it's preloadable. | ||
if (resolvedModule && (await exists(resolvedModule))) { | ||
if (!root) { | ||
modules.push(resolvedModule); | ||
} | ||
|
||
const graph = await resolveImports( | ||
resolvedModule, | ||
{ parsedImportMap, url }, | ||
false, | ||
); | ||
|
||
if (graph.length > 0) { | ||
graph.forEach((module) => modules.push(module)); | ||
} | ||
} | ||
} | ||
}), | ||
); | ||
|
||
return modules; | ||
} | ||
|
||
/** | ||
* Resolves the imports for a given module and caches the result. | ||
* @param {string} module The path to the module. | ||
* @param {object} options Options. | ||
* @param {Cache} options.cache Resolved imports cache. | ||
* @param {string} options.url The module URL to resolve. | ||
* @param {object} [options.parsedImportMap] A parsed import map. | ||
* @returns An array containing paths to modules that can be preloaded, or otherwise `undefined`. | ||
*/ | ||
async function resolveImportsCached(module, { cache, url, parsedImportMap }) { | ||
const paths = cache.get(module); | ||
|
||
if (paths !== undefined) { | ||
return paths; | ||
} else { | ||
const graph = await resolveImports(module, { parsedImportMap, url }); | ||
|
||
if (graph.length > 0) { | ||
cache.set(module, graph); | ||
return graph; | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Creates a function that resolves the link relations for a given module URL. | ||
* @param {object} [options] Options. | ||
* @param {string} [options.importMap] An import map. | ||
* @param {Cache} [options.cache] Specify a cache for resolved imports. | ||
* @returns A function that resolves the link relations for a given module URL. | ||
*/ | ||
export default function createResolveLinkRelations({ | ||
importMap: importMapString, | ||
cache = new Map(), | ||
} = {}) { | ||
/** | ||
* Resolves link relations for a given URL. | ||
* @param {object} options Options. | ||
* @param {string} options.appPath The path to the application root from where files can be read. | ||
* @param {string} options.url The module URL to resolve. | ||
* @param {string} [options.importMap] An import map. | ||
* @returns An array containing relative paths to modules that can be preloaded, or otherwise `undefined`. | ||
*/ | ||
return async function resolveLinkRelations({ appPath, url }) { | ||
let parsedImportMap; | ||
|
||
if (importMapString !== undefined) { | ||
parsedImportMap = parseFromString( | ||
importMapString, | ||
`https://${DUMMY_HOSTNAME}`, | ||
); | ||
} | ||
|
||
const rootPath = path.resolve(appPath); | ||
const resolvedFile = path.join(rootPath, url); | ||
|
||
if (resolvedFile.startsWith(rootPath)) { | ||
const modules = await resolveImportsCached(resolvedFile, { | ||
cache, | ||
url, | ||
parsedImportMap, | ||
}); | ||
|
||
if (Array.isArray(modules) && modules.length > 0) { | ||
const resolvedModules = modules.map((module) => { | ||
return "/" + path.relative(rootPath, module); | ||
}); | ||
|
||
if (resolvedModules.length > 0) { | ||
return resolvedModules; | ||
} | ||
} | ||
} | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
// https://url.spec.whatwg.org/#special-scheme | ||
const specialProtocols = new Set([ | ||
"ftp:", | ||
"file:", | ||
"http:", | ||
"https:", | ||
"ws:", | ||
"wss:", | ||
]); | ||
|
||
export default function isSpecial(url) { | ||
return specialProtocols.has(url.protocol); | ||
} |
Oops, something went wrong.