diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b512c09
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+node_modules
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bb5d03b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,41 @@
+# modulepreload
+
+Inject modulepreload tags into HTML to remove the waterfall problem in loading web modules.
+
+```sh
+$ npm install --save-dev modulepreload
+$ npx modulepreload inject -w index.html
+```
+
+Before injection:
+```html
+
+
+
+ Example
+
+
+
+
+
+```
+
+After injection:
+
+```html
+
+
+
+ Example
+
+
+
+
+
+
+
+```
\ No newline at end of file
diff --git a/cli.js b/cli.js
new file mode 100755
index 0000000..0b84132
--- /dev/null
+++ b/cli.js
@@ -0,0 +1,25 @@
+#!/usr/bin/env node
+
+import cac from 'cac'
+import { inject } from './src/inject.js'
+
+const cli = cac('module_preload')
+
+cli.option('--root ', 'Root')
+cli.option('--no-fetch', 'No fetch')
+cli.option('--w', 'Write')
+cli.option('--o ', 'Out')
+
+cli.command('inject [...files]', 'Inject files')
+ .action(async (files, options) => {
+ const { root, fetch: doFetch } = options
+ files.forEach(async filePath => {
+ const defaultOut = options.w ? filePath : null
+ const out = options.o ? options.o : defaultOut
+ await inject(filePath, { root, out, noFetch: !doFetch })
+ })
+ })
+
+cli.help()
+cli.parse()
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..1bee928
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,183 @@
+{
+ "name": "@dpikt/module-preload",
+ "version": "1.0.0",
+ "lockfileVersion": 2,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@dpikt/module-preload",
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@import-maps/resolve": "^2.0.0",
+ "cac": "^6.7.14",
+ "es-module-lexer": "^1.4.1",
+ "htmlparser2": "^9.0.0"
+ }
+ },
+ "node_modules/@import-maps/resolve": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@import-maps/resolve/-/resolve-2.0.0.tgz",
+ "integrity": "sha512-RwzRTpmrrS6Q1ZhQExwuxJGK1Wqhv4stt+OF2JzS+uawewpwNyU7EJL1WpBex7aDiiGLs4FsXGkfUBdYuX7xiQ=="
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ]
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
+ "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz",
+ "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w=="
+ },
+ "node_modules/htmlparser2": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.0.0.tgz",
+ "integrity": "sha512-uxbSI98wmFT/G4P2zXx4OVx04qWUmyFPrD2/CNepa2Zo3GPNaCaaxElDgwUrwYWkK1nr9fft0Ya8dws8coDLLQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.1.0",
+ "entities": "^4.5.0"
+ }
+ }
+ },
+ "dependencies": {
+ "@import-maps/resolve": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@import-maps/resolve/-/resolve-2.0.0.tgz",
+ "integrity": "sha512-RwzRTpmrrS6Q1ZhQExwuxJGK1Wqhv4stt+OF2JzS+uawewpwNyU7EJL1WpBex7aDiiGLs4FsXGkfUBdYuX7xiQ=="
+ },
+ "cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="
+ },
+ "dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "requires": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ }
+ },
+ "domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="
+ },
+ "domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "requires": {
+ "domelementtype": "^2.3.0"
+ }
+ },
+ "domutils": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
+ "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
+ "requires": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ }
+ },
+ "entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
+ },
+ "es-module-lexer": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz",
+ "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w=="
+ },
+ "htmlparser2": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.0.0.tgz",
+ "integrity": "sha512-uxbSI98wmFT/G4P2zXx4OVx04qWUmyFPrD2/CNepa2Zo3GPNaCaaxElDgwUrwYWkK1nr9fft0Ya8dws8coDLLQ==",
+ "requires": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.1.0",
+ "entities": "^4.5.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2b3ccf7
--- /dev/null
+++ b/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "modulepreload",
+ "version": "1.0.0",
+ "description": "",
+ "main": "cli.js",
+ "bin": {
+ "modulepreload": "./cli.js"
+ },
+ "scripts": {
+ "test": "node --test"
+ },
+ "type": "module",
+ "author": "",
+ "license": "MIT",
+ "dependencies": {
+ "@import-maps/resolve": "^2.0.0",
+ "cac": "^6.7.14",
+ "es-module-lexer": "^1.4.1",
+ "htmlparser2": "^9.0.0"
+ }
+}
\ No newline at end of file
diff --git a/src/inject.js b/src/inject.js
new file mode 100644
index 0000000..b6202f1
--- /dev/null
+++ b/src/inject.js
@@ -0,0 +1,144 @@
+import fs from "node:fs";
+import url from "node:url";
+import { parse } from "es-module-lexer";
+import { Parser } from "htmlparser2";
+import { resolve, parseFromString } from '@import-maps/resolve'
+
+async function getImports(script) {
+ const [imports] = await parse(script);
+ return imports.map((imp) => imp.n);
+}
+
+class ImportCollector {
+ constructor({ imports, baseUrl, importMap, noFetch }) {
+ this.imports = imports;
+ this.baseUrl = baseUrl;
+ this.dependencies = new Set();
+ this.noFetch = noFetch
+ this.importMap = importMap
+ }
+ async visit(specifier, parent) {
+ let resolvedImport = null
+ if (parent.protocol === "file:") {
+ const resolved = resolve(specifier, this.importMap, parent);
+ resolvedImport = resolved.resolvedImport
+ if (specifier.startsWith('/')) {
+ resolvedImport = new URL('.' + specifier, this.baseUrl)
+ }
+ } else if (parent.protocol.startsWith("http")) {
+ resolvedImport = new URL(specifier, parent.origin)
+ }
+ if (!resolvedImport) {
+ throw 'could not resolve import: ' + specifier
+ };
+ if (this.dependencies.has(resolvedImport.href)) return;
+ this.dependencies.add(resolvedImport.href);
+ if (resolvedImport.protocol === "file:") {
+ try {
+ const contents = await fs.promises.readFile(resolvedImport, "utf8");
+ const deps = await getImports(contents);
+ await Promise.all(
+ deps.map((dep) => this.visit(dep, resolvedImport))
+ );
+ } catch (e) {
+ console.warn('WARNING: could not read file: ' + resolvedImport.href + ' - skipping')
+ }
+ } else if (resolvedImport.protocol.startsWith("http")) {
+ if (!this.noFetch) {
+ const contents = await fetch(resolvedImport).then(res => res.text())
+ const deps = await getImports(contents);
+ await Promise.all(
+ deps.map((dep) => this.visit(dep, resolvedImport))
+ );
+ }
+ }
+ }
+ async collect() {
+ const parent = new URL('./index.js', this.baseUrl)
+ await Promise.all(this.imports.map((entry) => this.visit(entry, parent)));
+ return [...this.dependencies].map((dep) => dep.replace(this.baseUrl.href, "/"));
+ }
+}
+
+async function parseHtml(contents) {
+ const scripts = [];
+ let inScript = false;
+ let inImportMap = false;
+ let importMapString = ""
+ const parser = new Parser({
+ onopentag(name, attributes) {
+ if (name === "script" && attributes.type === "module") {
+ inScript = true;
+ scripts.unshift("");
+ }
+ if (name === "script" && attributes.type === "importmap") {
+ inImportMap = true;
+ }
+ },
+ ontext(text) {
+ if (inScript) {
+ scripts[0] += text;
+ }
+ if (inImportMap) {
+ importMapString += text;
+ }
+ },
+ onclosetag(tagname) {
+ if (tagname === "script") {
+ inScript = false;
+ inImportMap = false;
+ }
+ },
+ });
+ parser.write(contents);
+ parser.end();
+ const imports = new Set()
+ for (let script of scripts) {
+ const deps = await getImports(script);
+ deps.forEach((d) => imports.add(d));
+ }
+ return { imports: [...imports], importMapString: importMapString || '{}' };
+}
+
+export async function getDependencies(contents, baseUrl, { noFetch } = {}) {
+ if (!baseUrl) {
+ throw new Error('baseUrl is required')
+ }
+ if (!baseUrl.href.endsWith('/')) {
+ baseUrl.href += '/'
+ }
+ const { importMapString, imports } = await parseHtml(contents);
+ const importMap = parseFromString(importMapString, baseUrl);
+ const collector = new ImportCollector({ imports, baseUrl, importMap, noFetch });
+ return collector.collect();
+}
+
+export function injectPreloads(contents, dependencies) {
+ let preloads = "";
+ for (const dep of dependencies) {
+ preloads += ` \n`;
+ }
+ return contents.replace("", `${preloads}`);
+}
+
+export async function link(htmlContentsOrUrl, { baseUrl: providedBaseUrl, noFetch } = {}) {
+ let html = htmlContentsOrUrl;
+ let baseUrl = providedBaseUrl;
+ if (htmlContentsOrUrl instanceof URL) {
+ html = await fs.promises.readFile(htmlContentsOrUrl, "utf8");
+ baseUrl = new URL('./', htmlContentsOrUrl)
+ }
+ const dependencies = await getDependencies(html, baseUrl, { noFetch });
+ return injectPreloads(html, dependencies);
+}
+
+export async function inject(htmlPath, { out, root, noFetch } = {}) {
+ const htmlUrl = url.pathToFileURL(htmlPath)
+ const baseUrl = root ? new URL(root) : null
+ const newHtml = await link(htmlUrl, { baseUrl, noFetch })
+ if (out) {
+ await fs.promises.writeFile(out, newHtml);
+ } else {
+ process.stdout.write(newHtml)
+ }
+}
diff --git a/tests/fixtures/absolute-dep.js b/tests/fixtures/absolute-dep.js
new file mode 100644
index 0000000..ac02139
--- /dev/null
+++ b/tests/fixtures/absolute-dep.js
@@ -0,0 +1,3 @@
+export function foo() {
+ return "foo";
+}
\ No newline at end of file
diff --git a/tests/fixtures/index.html b/tests/fixtures/index.html
new file mode 100644
index 0000000..f4ee64b
--- /dev/null
+++ b/tests/fixtures/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+ My HTML File
+
+
+
+
+ Hello, world!
+
+
+
+
\ No newline at end of file
diff --git a/tests/fixtures/js/dep.js b/tests/fixtures/js/dep.js
new file mode 100644
index 0000000..fef4957
--- /dev/null
+++ b/tests/fixtures/js/dep.js
@@ -0,0 +1,7 @@
+import { world } from "./secondary-dep.js";
+import { foo } from "/absolute-dep.js";
+
+// Export function hello
+export function hello() {
+ return "hello" + world();
+}
\ No newline at end of file
diff --git a/tests/fixtures/js/secondary-dep.js b/tests/fixtures/js/secondary-dep.js
new file mode 100644
index 0000000..6af38cf
--- /dev/null
+++ b/tests/fixtures/js/secondary-dep.js
@@ -0,0 +1,6 @@
+import jquery from 'jquery'
+
+// Export function world
+export function world() {
+ return "world";
+}
\ No newline at end of file
diff --git a/tests/inject.test.js b/tests/inject.test.js
new file mode 100644
index 0000000..9cafbf4
--- /dev/null
+++ b/tests/inject.test.js
@@ -0,0 +1,7 @@
+import { link } from '../src/inject.js';
+import test from 'node:test';
+
+test('inject', async (t) => {
+ const htmlUrl = new URL('./fixtures/index.html', import.meta.url)
+ console.log(await link(htmlUrl, { noFetch: true }))
+});