Skip to content

Commit

Permalink
add import map support
Browse files Browse the repository at this point in the history
  • Loading branch information
dburles committed Jul 6, 2024
1 parent b4a3ec8 commit 4e4ef7d
Show file tree
Hide file tree
Showing 18 changed files with 555 additions and 136 deletions.
193 changes: 193 additions & 0 deletions createResolveLinkRelations.mjs
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;
}
}
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

import test from "node:test";
import assert from "node:assert/strict";
import resolveLinkRelations from "./resolveLinkRelations.mjs";
import createResolveLinkRelations from "./createResolveLinkRelations.mjs";

test("resolveLinkRelations", async (t) => {
test("createResolveLinkRelations", async (t) => {
await t.test("works", async () => {
const resolveLinkRelations = createResolveLinkRelations();
const resolvedModules = await resolveLinkRelations({
appPath: "test-fixtures",
url: "/a.mjs",
Expand Down Expand Up @@ -40,6 +41,7 @@ test("resolveLinkRelations", async (t) => {
});

await t.test("can't reach outside of appPath", async () => {
const resolveLinkRelations = createResolveLinkRelations();
const resolvedModules = await resolveLinkRelations({
appPath: "test-fixtures",
url: "../../a.mjs",
Expand All @@ -49,6 +51,7 @@ test("resolveLinkRelations", async (t) => {
});

await t.test("module without imports", async () => {
const resolveLinkRelations = createResolveLinkRelations();
const resolvedModules = await resolveLinkRelations({
appPath: "test-fixtures",
url: "/d.mjs",
Expand All @@ -58,11 +61,43 @@ test("resolveLinkRelations", async (t) => {
});

await t.test("module doesn't exist", async () => {
const resolveLinkRelations = createResolveLinkRelations();
const resolvedModules = await resolveLinkRelations({
appPath: "test-fixtures",
url: "/does-not-exist.mjs",
});

assert.equal(resolvedModules, undefined);
});

await t.test("resolve import maps", async (tt) => {
await tt.test("basic", async () => {
const resolveLinkRelations = createResolveLinkRelations({
importMap: '{ "imports": { "g": "./g.mjs" } }',
});
const resolvedModules = await resolveLinkRelations({
appPath: "test-fixtures",
url: "/e.mjs",
});

assert.ok(Array.isArray(resolvedModules));

assert.ok(resolvedModules.includes("/g.mjs"));
});

await tt.test("ignores external urls", async () => {
const resolveLinkRelations = createResolveLinkRelations({
importMap:
'{ "imports": { "z": "/z.mjs", "foo": "https://foo.com/bar" } }',
});
const resolvedModules = await resolveLinkRelations({
appPath: "test-fixtures",
url: "/x.mjs",
});

assert.ok(Array.isArray(resolvedModules));

assert.ok(resolvedModules.includes("/z.mjs"));
});
});
});
12 changes: 5 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "modulepreload-link-relations",
"version": "2.0.0",
"version": "3.0.0-rc.1",
"description": "Utility for generating modulepreload link relations based on a JavaScript module import graph.",
"repository": {
"type": "git",
Expand All @@ -18,16 +18,14 @@
"node": ">= 18"
},
"files": [
"createResolveLinkRelations.mjs",
"formatLinkHeaderRelation.mjs",
"formatLinkHeaderRelations.mjs",
"resolveImports.mjs",
"resolveImportsCached.mjs",
"resolveLinkRelations.mjs"
"formatLinkHeaderRelations.mjs"
],
"exports": {
"./createResolveLinkRelations.mjs": "./createResolveLinkRelations.mjs",
"./formatLinkHeaderRelations.mjs": "./formatLinkHeaderRelations.mjs",
"./package.json": "./package.json",
"./resolveLinkRelations.mjs": "./resolveLinkRelations.mjs"
"./package.json": "./package.json"
},
"scripts": {
"eslint": "eslint .",
Expand Down
12 changes: 9 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ A utility for generating [modulepreload](https://developer.mozilla.org/en-US/doc

It can be used for HTTP server middleware and generating [\<link\>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link) elements in static HTML.

Supports import maps.

## Install

```sh
Expand All @@ -14,17 +16,21 @@ npm i modulepreload-link-relations

This package exports two functions:

- `resolveLinkRelations`
- Returns an array of modules that can be preloaded for a module. An in-memory cache persists the resulting module import graph.
- `createResolveLinkRelations`
- Returns a function that returns an array of modules that can be preloaded for a module. An in-memory cache persists the resulting module import graph.
- `formatLinkHeaderRelations`
- A formatter that can be used to generate link relations for an HTTP [Link](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link) entity-header.

## Example

```js
import resolveLinkRelations from "modulepreload-link-relations/resolveLinkRelations.mjs";
import createResolveLinkRelations from "modulepreload-link-relations/createResolveLinkRelations.mjs";
import formatLinkHeaderRelations from "modulepreload-link-relations/formatLinkHeaderRelations.mjs";

const resolveLinkRelations = createResolveLinkRelations({
// Optionally provide an import map:
// importMap: importMapString,
});
const linkRelations = await resolveLinkRelations({
// The application path.
appPath: "./app",
Expand Down
13 changes: 13 additions & 0 deletions resolve-import-map/isSpecial.mjs
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);
}
Loading

0 comments on commit 4e4ef7d

Please sign in to comment.