Skip to content

Commit 4c0582e

Browse files
authored
resolve script[src] (#1035)
* resolve script[src] * type is case-insensitive * only preload if module
1 parent d4cc548 commit 4c0582e

File tree

11 files changed

+200
-45
lines changed

11 files changed

+200
-45
lines changed

src/html.ts

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import he from "he";
33
import hljs from "highlight.js";
44
import type {DOMWindow} from "jsdom";
55
import {JSDOM, VirtualConsole} from "jsdom";
6-
import {relativePath, resolveLocalPath} from "./path.js";
6+
import {isAssetPath, relativePath, resolveLocalPath} from "./path.js";
77

88
const ASSET_PROPERTIES: readonly [selector: string, src: string][] = [
99
["a[href][download]", "href"],
@@ -17,55 +17,98 @@ const ASSET_PROPERTIES: readonly [selector: string, src: string][] = [
1717
["video[src]", "src"]
1818
];
1919

20-
export function isAssetPath(specifier: string): boolean {
21-
return !/^(\w+:|#)/.test(specifier);
20+
export function isJavaScript({type}: HTMLScriptElement): boolean {
21+
if (!type) return true;
22+
type = type.toLowerCase();
23+
return type === "text/javascript" || type === "application/javascript" || type === "module";
2224
}
2325

2426
export function parseHtml(html: string): DOMWindow {
2527
return new JSDOM(`<!DOCTYPE html><body>${html}`, {virtualConsole: new VirtualConsole()}).window;
2628
}
2729

28-
export function findAssets(html: string, path: string): Set<string> {
30+
interface Assets {
31+
files: Set<string>;
32+
localImports: Set<string>;
33+
globalImports: Set<string>;
34+
staticImports: Set<string>;
35+
}
36+
37+
export function findAssets(html: string, path: string): Assets {
2938
const {document} = parseHtml(html);
30-
const assets = new Set<string>();
39+
const files = new Set<string>();
40+
const localImports = new Set<string>();
41+
const globalImports = new Set<string>();
42+
const staticImports = new Set<string>();
3143

32-
const maybeAsset = (specifier: string): void => {
44+
const maybeFile = (specifier: string): void => {
3345
if (!isAssetPath(specifier)) return;
3446
const localPath = resolveLocalPath(path, specifier);
3547
if (!localPath) return console.warn(`non-local asset path: ${specifier}`);
36-
assets.add(relativePath(path, localPath));
48+
files.add(relativePath(path, localPath));
3749
};
3850

3951
for (const [selector, src] of ASSET_PROPERTIES) {
4052
for (const element of document.querySelectorAll(selector)) {
41-
const source = decodeURIComponent(element.getAttribute(src)!);
53+
const source = decodeURI(element.getAttribute(src)!);
4254
if (src === "srcset") {
4355
for (const s of parseSrcset(source)) {
44-
maybeAsset(s);
56+
maybeFile(s);
4557
}
4658
} else {
47-
maybeAsset(source);
59+
maybeFile(source);
4860
}
4961
}
5062
}
5163

52-
return assets;
64+
for (const script of document.querySelectorAll<HTMLScriptElement>("script[src]")) {
65+
let src = script.getAttribute("src")!;
66+
if (isJavaScript(script)) {
67+
if (isAssetPath(src)) {
68+
const localPath = resolveLocalPath(path, src);
69+
if (!localPath) {
70+
console.warn(`non-local asset path: ${src}`);
71+
continue;
72+
}
73+
localImports.add((src = relativePath(path, localPath)));
74+
} else {
75+
globalImports.add(src);
76+
}
77+
if (script.getAttribute("type")?.toLowerCase() === "module") {
78+
staticImports.add(src); // modulepreload
79+
}
80+
} else {
81+
maybeFile(src);
82+
}
83+
}
84+
85+
return {files, localImports, globalImports, staticImports};
86+
}
87+
88+
interface HtmlResolvers {
89+
resolveFile?: (specifier: string) => string;
90+
resolveScript?: (specifier: string) => string;
5391
}
5492

55-
export function rewriteHtml(html: string, resolve: (specifier: string) => string = String): string {
93+
export function rewriteHtml(html: string, {resolveFile = String, resolveScript = String}: HtmlResolvers): string {
5694
const {document} = parseHtml(html);
5795

58-
const maybeResolve = (specifier: string): string => {
59-
return isAssetPath(specifier) ? resolve(specifier) : specifier;
96+
const maybeResolveFile = (specifier: string): string => {
97+
return isAssetPath(specifier) ? resolveFile(specifier) : specifier;
6098
};
6199

62100
for (const [selector, src] of ASSET_PROPERTIES) {
63101
for (const element of document.querySelectorAll(selector)) {
64-
const source = decodeURIComponent(element.getAttribute(src)!);
65-
element.setAttribute(src, src === "srcset" ? resolveSrcset(source, maybeResolve) : maybeResolve(source));
102+
const source = decodeURI(element.getAttribute(src)!);
103+
element.setAttribute(src, src === "srcset" ? resolveSrcset(source, maybeResolveFile) : maybeResolveFile(source));
66104
}
67105
}
68106

107+
for (const script of document.querySelectorAll<HTMLScriptElement>("script[src]")) {
108+
const src = decodeURI(script.getAttribute("src")!);
109+
script.setAttribute("src", (isJavaScript(script) ? resolveScript : maybeResolveFile)(src));
110+
}
111+
69112
// Syntax highlighting for <code> elements. The code could contain an inline
70113
// expression within, or other HTML, but we only highlight text nodes that are
71114
// direct children of code elements.

src/javascript/imports.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export function findImports(body: Node, path: string, input: string): ImportRefe
7171
function findImport(node: ImportNode | ExportNode) {
7272
const source = node.source;
7373
if (!source || !isStringLiteral(source)) return;
74-
const name = decodeURIComponent(getStringLiteralValue(source));
74+
const name = decodeURI(getStringLiteralValue(source));
7575
const method = node.type === "ImportExpression" ? "dynamic" : "static";
7676
if (isPathImport(name)) {
7777
const localPath = resolveLocalPath(path, name);
@@ -85,7 +85,7 @@ export function findImports(body: Node, path: string, input: string): ImportRefe
8585
function findImportMetaResolve(node: CallExpression) {
8686
const source = node.arguments[0];
8787
if (!isImportMetaResolve(node) || !isStringLiteral(source)) return;
88-
const name = decodeURIComponent(getStringLiteralValue(source));
88+
const name = decodeURI(getStringLiteralValue(source));
8989
if (isPathImport(name)) {
9090
const localPath = resolveLocalPath(path, name);
9191
if (!localPath) throw syntaxError(`non-local import: ${name}`, node, input); // prettier-ignore

src/markdown.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ export function parseMarkdown(input: string, {md, path, style: configStyle}: Par
334334
const code: MarkdownCode[] = [];
335335
const context: ParseContext = {code, startLine: 0, currentLine: 0, path};
336336
const tokens = md.parse(content, context);
337-
const html = md.renderer.render(tokens, md.options, context); // Note: mutates code, assets!
337+
const html = md.renderer.render(tokens, md.options, context); // Note: mutates code!
338338
const style = getStylesheet(path, data, configStyle);
339339
return {
340340
html,

src/path.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ export function resolveLocalPath(source: string, target: string): string | null
4848
return path;
4949
}
5050

51+
/**
52+
* Returns true if the specified specifier refers to a local path, as opposed to
53+
* a global import from npm or a URL. Local paths start with ./, ../, or /.
54+
*/
5155
export function isPathImport(specifier: string): boolean {
5256
return ["./", "../", "/"].some((prefix) => specifier.startsWith(prefix));
5357
}
58+
59+
/**
60+
* Like isPathImport, but more lax; this is used to detect when an HTML element
61+
* such as an image refers to a local asset. Whereas isPathImport requires a
62+
* local path to start with ./, ../, or /, isAssetPath only requires that a
63+
* local path not start with a protocol (e.g., http: or https:) or a hash (#).
64+
*/
65+
export function isAssetPath(specifier: string): boolean {
66+
return !/^(\w+:|#)/.test(specifier);
67+
}

src/preview.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export class PreviewServer {
9292
if (this._verbose) console.log(faint(req.method!), req.url);
9393
try {
9494
const url = new URL(req.url!, "http://localhost");
95-
let pathname = decodeURIComponent(url.pathname);
95+
let pathname = decodeURI(url.pathname);
9696
let match: RegExpExecArray | null;
9797
if (pathname === "/_observablehq/client.js") {
9898
end(req, res, await rollupClient(getClientPath("preview.js"), root, pathname), "text/javascript");
@@ -334,7 +334,7 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, config: Config) {
334334

335335
async function hello({path: initialPath, hash: initialHash}: {path: string; hash: string}): Promise<void> {
336336
if (markdownWatcher || attachmentWatcher) throw new Error("already watching");
337-
path = decodeURIComponent(initialPath);
337+
path = decodeURI(initialPath);
338338
if (!(path = normalize(path)).startsWith("/")) throw new Error("Invalid path: " + initialPath);
339339
if (path.endsWith("/")) path += "index";
340340
path = join(dirname(path), basename(path, ".html") + ".md");
@@ -390,8 +390,8 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, config: Config) {
390390
}
391391
}
392392

393-
function getHtml({html}: MarkdownPage, {resolveFile}: Resolvers): string[] {
394-
return Array.from(parseHtml(rewriteHtml(html, resolveFile)).document.body.children, (d) => d.outerHTML);
393+
function getHtml({html}: MarkdownPage, resolvers: Resolvers): string[] {
394+
return Array.from(parseHtml(rewriteHtml(html, resolvers)).document.body.children, (d) => d.outerHTML);
395395
}
396396

397397
function getCode({code}: MarkdownPage, resolvers: Resolvers): Map<string, string> {

src/render.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ ${preview ? `\nopen({hash: ${JSON.stringify(resolvers.hash)}, eval: (body) => ev
7979
}
8080
<div id="observablehq-center">${renderHeader(options, data)}
8181
<main id="observablehq-main" class="observablehq${draft ? " observablehq--draft" : ""}">
82-
${html.unsafe(rewriteHtml(page.html, resolvers.resolveFile))}</main>${renderFooter(path, options, data, normalizeLink)}
82+
${html.unsafe(rewriteHtml(page.html, resolvers))}</main>${renderFooter(path, options, data, normalizeLink)}
8383
</div>
8484
`);
8585
}

src/resolvers.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,20 @@ import {getImplicitFileImports, getImplicitInputImports} from "./libraries.js";
99
import {getImplicitStylesheets} from "./libraries.js";
1010
import type {MarkdownPage} from "./markdown.js";
1111
import {extractNpmSpecifier, populateNpmCache, resolveNpmImport, resolveNpmImports} from "./npm.js";
12-
import {isPathImport, relativePath, resolvePath} from "./path.js";
12+
import {isAssetPath, isPathImport, relativePath, resolveLocalPath, resolvePath} from "./path.js";
1313

1414
export interface Resolvers {
1515
hash: string;
16-
assets: Set<string>;
16+
assets: Set<string>; // like files, but not registered for FileAttachment
1717
files: Set<string>;
1818
localImports: Set<string>;
1919
globalImports: Set<string>;
2020
staticImports: Set<string>;
21-
stylesheets: Set<string>;
21+
stylesheets: Set<string>; // stylesheets to be added by render
2222
resolveFile(specifier: string): string;
2323
resolveImport(specifier: string): string;
2424
resolveStylesheet(specifier: string): string;
25+
resolveScript(specifier: string): string;
2526
}
2627

2728
const defaultImports = [
@@ -73,7 +74,7 @@ export async function getResolvers(
7374
{root, path, loaders}: {root: string; path: string; loaders: LoaderResolver}
7475
): Promise<Resolvers> {
7576
const hash = createHash("sha256").update(page.html).update(JSON.stringify(page.data));
76-
const assets = findAssets(page.html, path);
77+
const assets = new Set<string>();
7778
const files = new Set<string>();
7879
const fileMethods = new Set<string>();
7980
const localImports = new Set<string>();
@@ -82,6 +83,13 @@ export async function getResolvers(
8283
const stylesheets = new Set<string>();
8384
const resolutions = new Map<string, string>();
8485

86+
// Add assets.
87+
const info = findAssets(page.html, path);
88+
for (const f of info.files) assets.add(f);
89+
for (const i of info.localImports) localImports.add(i);
90+
for (const i of info.globalImports) globalImports.add(i);
91+
for (const i of info.staticImports) staticImports.add(i);
92+
8593
// Add stylesheets. TODO Instead of hard-coding Source Serif Pro, parse the
8694
// page’s stylesheet to look for external imports.
8795
stylesheets.add("https://fonts.googleapis.com/css2?family=Source+Serif+Pro:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700&display=swap"); // prettier-ignore
@@ -242,6 +250,15 @@ export async function getResolvers(
242250
: specifier;
243251
}
244252

253+
function resolveScript(src: string): string {
254+
if (isAssetPath(src)) {
255+
const localPath = resolveLocalPath(path, src);
256+
return localPath ? resolveImport(relativePath(path, localPath)) : src;
257+
} else {
258+
return resolveImport(src);
259+
}
260+
}
261+
245262
return {
246263
hash: hash.digest("hex"),
247264
assets,
@@ -252,6 +269,7 @@ export async function getResolvers(
252269
stylesheets,
253270
resolveFile,
254271
resolveImport,
272+
resolveScript,
255273
resolveStylesheet
256274
};
257275
}

0 commit comments

Comments
 (0)