Skip to content

Commit

Permalink
Refactor + update injection
Browse files Browse the repository at this point in the history
  • Loading branch information
dpikt committed Dec 23, 2023
1 parent 2723736 commit 5390171
Show file tree
Hide file tree
Showing 12 changed files with 218 additions and 140 deletions.
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
21
136 changes: 3 additions & 133 deletions src/inject.js
Original file line number Diff line number Diff line change
@@ -1,136 +1,6 @@
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 += `<link rel="modulepreload" href="${dep}" />\n`;
}
return contents.replace("</head>", `${preloads}</head>`);
}

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);
}
import fs from 'node:fs'
import url from 'node:url'
import link from './link.js'

export async function inject(htmlPath, { out, root, noFetch } = {}) {
const htmlUrl = url.pathToFileURL(htmlPath)
Expand Down
145 changes: 145 additions & 0 deletions src/link.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
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) {
console.warn(`WARNING: could not resolve import specifier: ${specifier} from parent: ${parent} - skipping`)
return
};
if (this.dependencies.has(resolvedImport.href)) return;
this.dependencies.add(resolvedImport.href);
if (resolvedImport.protocol === "file:") {
let contents = null
try {
contents = await fs.promises.readFile(resolvedImport, "utf8");
} catch (e) {
console.warn('WARNING: could not read file: ' + resolvedImport.href + ' - skipping')
}
if (contents) {
const deps = await getImports(contents);
await Promise.all(
deps.map((dep) => this.visit(dep, resolvedImport))
);
}
// console.error(e)
} 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 += `<link rel="modulepreload" href="${dep}" />\n`;
}
if (contents.includes("</head>")) {
return contents.replace("</head>", `${preloads}</head>`);
} else if (contents.includes("</html>")) {
return contents.replace("<html>", `<html>\n${preloads}`);
} else {
console.warn('WARNING: could not find <head> or <html> in HTML - skipping.')
return contents;
}
}

export default 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);
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
11 changes: 11 additions & 0 deletions tests/fixtures/link/no-head.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>

<body>
<h1>Hello, world!</h1>
</body>
<script type="module">
import { hello } from './absolute-dep.js';
</script>

</html>
7 changes: 0 additions & 7 deletions tests/inject.test.js

This file was deleted.

19 changes: 19 additions & 0 deletions tests/link.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import link from '../src/link.js';
import { describe, it } from 'node:test';
import fs from 'node:fs';
import assert from 'node:assert';

describe('link', () => {
it('injects dependencies into HTML', async () => {
const htmlUrl = new URL('./fixtures/link/index.html', import.meta.url)
const result = await link(htmlUrl, { noFetch: true })
const expected = await fs.promises.readFile(new URL('./snapshots/link/index.snapshot.html', import.meta.url), 'utf8')
assert.strictEqual(result, expected)
})
it('injects dependencies to top of <html> if there is no <head> tag', async () => {
const htmlUrl = new URL('./fixtures/link/no-head.html', import.meta.url)
const result = await link(htmlUrl, { noFetch: true })
const expected = await fs.promises.readFile(new URL('./snapshots/link/no-head.snapshot.html', import.meta.url), 'utf8')
assert.strictEqual(result, expected)
});
})
26 changes: 26 additions & 0 deletions tests/snapshots/link/index.snapshot.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>

<head>
<title>My HTML File</title>
<script type="importmap">
{
"imports": {
"jquery": "https://cdn.skypack.dev/jquery"
}
}
</script>
<link rel="modulepreload" href="/js/dep.js" />
<link rel="modulepreload" href="/js/secondary-dep.js" />
<link rel="modulepreload" href="/absolute-dep.js" />
<link rel="modulepreload" href="https://cdn.skypack.dev/jquery" />
</head>

<body>
<h1>Hello, world!</h1>
</body>
<script type="module">
import { hello } from './js/dep.js';
</script>

</html>
13 changes: 13 additions & 0 deletions tests/snapshots/link/no-head.snapshot.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<link rel="modulepreload" href="/absolute-dep.js" />


<body>
<h1>Hello, world!</h1>
</body>
<script type="module">
import { hello } from './absolute-dep.js';
</script>

</html>

0 comments on commit 5390171

Please sign in to comment.