-
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
12 changed files
with
218 additions
and
140 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 @@ | ||
21 |
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,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.
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,11 @@ | ||
<!DOCTYPE html> | ||
<html> | ||
|
||
<body> | ||
<h1>Hello, world!</h1> | ||
</body> | ||
<script type="module"> | ||
import { hello } from './absolute-dep.js'; | ||
</script> | ||
|
||
</html> |
This file was deleted.
Oops, something went wrong.
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,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) | ||
}); | ||
}) |
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,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> |
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 @@ | ||
<!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> |