diff --git a/src/html.ts b/src/html.ts index 3220efdbe..1227960d1 100644 --- a/src/html.ts +++ b/src/html.ts @@ -3,7 +3,7 @@ import he from "he"; import hljs from "highlight.js"; import type {DOMWindow} from "jsdom"; import {JSDOM, VirtualConsole} from "jsdom"; -import {relativePath, resolveLocalPath} from "./path.js"; +import {isAssetPath, relativePath, resolveLocalPath} from "./path.js"; const ASSET_PROPERTIES: readonly [selector: string, src: string][] = [ ["a[href][download]", "href"], @@ -17,55 +17,98 @@ const ASSET_PROPERTIES: readonly [selector: string, src: string][] = [ ["video[src]", "src"] ]; -export function isAssetPath(specifier: string): boolean { - return !/^(\w+:|#)/.test(specifier); +export function isJavaScript({type}: HTMLScriptElement): boolean { + if (!type) return true; + type = type.toLowerCase(); + return type === "text/javascript" || type === "application/javascript" || type === "module"; } export function parseHtml(html: string): DOMWindow { return new JSDOM(`${html}`, {virtualConsole: new VirtualConsole()}).window; } -export function findAssets(html: string, path: string): Set { +interface Assets { + files: Set; + localImports: Set; + globalImports: Set; + staticImports: Set; +} + +export function findAssets(html: string, path: string): Assets { const {document} = parseHtml(html); - const assets = new Set(); + const files = new Set(); + const localImports = new Set(); + const globalImports = new Set(); + const staticImports = new Set(); - const maybeAsset = (specifier: string): void => { + const maybeFile = (specifier: string): void => { if (!isAssetPath(specifier)) return; const localPath = resolveLocalPath(path, specifier); if (!localPath) return console.warn(`non-local asset path: ${specifier}`); - assets.add(relativePath(path, localPath)); + files.add(relativePath(path, localPath)); }; for (const [selector, src] of ASSET_PROPERTIES) { for (const element of document.querySelectorAll(selector)) { - const source = decodeURIComponent(element.getAttribute(src)!); + const source = decodeURI(element.getAttribute(src)!); if (src === "srcset") { for (const s of parseSrcset(source)) { - maybeAsset(s); + maybeFile(s); } } else { - maybeAsset(source); + maybeFile(source); } } } - return assets; + for (const script of document.querySelectorAll("script[src]")) { + let src = script.getAttribute("src")!; + if (isJavaScript(script)) { + if (isAssetPath(src)) { + const localPath = resolveLocalPath(path, src); + if (!localPath) { + console.warn(`non-local asset path: ${src}`); + continue; + } + localImports.add((src = relativePath(path, localPath))); + } else { + globalImports.add(src); + } + if (script.getAttribute("type")?.toLowerCase() === "module") { + staticImports.add(src); // modulepreload + } + } else { + maybeFile(src); + } + } + + return {files, localImports, globalImports, staticImports}; +} + +interface HtmlResolvers { + resolveFile?: (specifier: string) => string; + resolveScript?: (specifier: string) => string; } -export function rewriteHtml(html: string, resolve: (specifier: string) => string = String): string { +export function rewriteHtml(html: string, {resolveFile = String, resolveScript = String}: HtmlResolvers): string { const {document} = parseHtml(html); - const maybeResolve = (specifier: string): string => { - return isAssetPath(specifier) ? resolve(specifier) : specifier; + const maybeResolveFile = (specifier: string): string => { + return isAssetPath(specifier) ? resolveFile(specifier) : specifier; }; for (const [selector, src] of ASSET_PROPERTIES) { for (const element of document.querySelectorAll(selector)) { - const source = decodeURIComponent(element.getAttribute(src)!); - element.setAttribute(src, src === "srcset" ? resolveSrcset(source, maybeResolve) : maybeResolve(source)); + const source = decodeURI(element.getAttribute(src)!); + element.setAttribute(src, src === "srcset" ? resolveSrcset(source, maybeResolveFile) : maybeResolveFile(source)); } } + for (const script of document.querySelectorAll("script[src]")) { + const src = decodeURI(script.getAttribute("src")!); + script.setAttribute("src", (isJavaScript(script) ? resolveScript : maybeResolveFile)(src)); + } + // Syntax highlighting for elements. The code could contain an inline // expression within, or other HTML, but we only highlight text nodes that are // direct children of code elements. diff --git a/src/javascript/imports.ts b/src/javascript/imports.ts index e6eca9f7e..961f94b3f 100644 --- a/src/javascript/imports.ts +++ b/src/javascript/imports.ts @@ -71,7 +71,7 @@ export function findImports(body: Node, path: string, input: string): ImportRefe function findImport(node: ImportNode | ExportNode) { const source = node.source; if (!source || !isStringLiteral(source)) return; - const name = decodeURIComponent(getStringLiteralValue(source)); + const name = decodeURI(getStringLiteralValue(source)); const method = node.type === "ImportExpression" ? "dynamic" : "static"; if (isPathImport(name)) { const localPath = resolveLocalPath(path, name); @@ -85,7 +85,7 @@ export function findImports(body: Node, path: string, input: string): ImportRefe function findImportMetaResolve(node: CallExpression) { const source = node.arguments[0]; if (!isImportMetaResolve(node) || !isStringLiteral(source)) return; - const name = decodeURIComponent(getStringLiteralValue(source)); + const name = decodeURI(getStringLiteralValue(source)); if (isPathImport(name)) { const localPath = resolveLocalPath(path, name); if (!localPath) throw syntaxError(`non-local import: ${name}`, node, input); // prettier-ignore diff --git a/src/markdown.ts b/src/markdown.ts index 01020f4c2..6e887a7c5 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -334,7 +334,7 @@ export function parseMarkdown(input: string, {md, path, style: configStyle}: Par const code: MarkdownCode[] = []; const context: ParseContext = {code, startLine: 0, currentLine: 0, path}; const tokens = md.parse(content, context); - const html = md.renderer.render(tokens, md.options, context); // Note: mutates code, assets! + const html = md.renderer.render(tokens, md.options, context); // Note: mutates code! const style = getStylesheet(path, data, configStyle); return { html, diff --git a/src/path.ts b/src/path.ts index 6934e3195..cf4d86279 100644 --- a/src/path.ts +++ b/src/path.ts @@ -48,6 +48,20 @@ export function resolveLocalPath(source: string, target: string): string | null return path; } +/** + * Returns true if the specified specifier refers to a local path, as opposed to + * a global import from npm or a URL. Local paths start with ./, ../, or /. + */ export function isPathImport(specifier: string): boolean { return ["./", "../", "/"].some((prefix) => specifier.startsWith(prefix)); } + +/** + * Like isPathImport, but more lax; this is used to detect when an HTML element + * such as an image refers to a local asset. Whereas isPathImport requires a + * local path to start with ./, ../, or /, isAssetPath only requires that a + * local path not start with a protocol (e.g., http: or https:) or a hash (#). + */ +export function isAssetPath(specifier: string): boolean { + return !/^(\w+:|#)/.test(specifier); +} diff --git a/src/preview.ts b/src/preview.ts index 9bb800d25..6f56cf4c2 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -92,7 +92,7 @@ export class PreviewServer { if (this._verbose) console.log(faint(req.method!), req.url); try { const url = new URL(req.url!, "http://localhost"); - let pathname = decodeURIComponent(url.pathname); + let pathname = decodeURI(url.pathname); let match: RegExpExecArray | null; if (pathname === "/_observablehq/client.js") { end(req, res, await rollupClient(getClientPath("preview.js"), root, pathname), "text/javascript"); @@ -334,7 +334,7 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, config: Config) { async function hello({path: initialPath, hash: initialHash}: {path: string; hash: string}): Promise { if (markdownWatcher || attachmentWatcher) throw new Error("already watching"); - path = decodeURIComponent(initialPath); + path = decodeURI(initialPath); if (!(path = normalize(path)).startsWith("/")) throw new Error("Invalid path: " + initialPath); if (path.endsWith("/")) path += "index"; path = join(dirname(path), basename(path, ".html") + ".md"); @@ -390,8 +390,8 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, config: Config) { } } -function getHtml({html}: MarkdownPage, {resolveFile}: Resolvers): string[] { - return Array.from(parseHtml(rewriteHtml(html, resolveFile)).document.body.children, (d) => d.outerHTML); +function getHtml({html}: MarkdownPage, resolvers: Resolvers): string[] { + return Array.from(parseHtml(rewriteHtml(html, resolvers)).document.body.children, (d) => d.outerHTML); } function getCode({code}: MarkdownPage, resolvers: Resolvers): Map { diff --git a/src/render.ts b/src/render.ts index 8b0e62aad..c895f34fc 100644 --- a/src/render.ts +++ b/src/render.ts @@ -79,7 +79,7 @@ ${preview ? `\nopen({hash: ${JSON.stringify(resolvers.hash)}, eval: (body) => ev }
${renderHeader(options, data)}
-${html.unsafe(rewriteHtml(page.html, resolvers.resolveFile))}
${renderFooter(path, options, data, normalizeLink)} +${html.unsafe(rewriteHtml(page.html, resolvers))}${renderFooter(path, options, data, normalizeLink)}
`); } diff --git a/src/resolvers.ts b/src/resolvers.ts index 1cd4ecb67..bde853a5a 100644 --- a/src/resolvers.ts +++ b/src/resolvers.ts @@ -9,19 +9,20 @@ import {getImplicitFileImports, getImplicitInputImports} from "./libraries.js"; import {getImplicitStylesheets} from "./libraries.js"; import type {MarkdownPage} from "./markdown.js"; import {extractNpmSpecifier, populateNpmCache, resolveNpmImport, resolveNpmImports} from "./npm.js"; -import {isPathImport, relativePath, resolvePath} from "./path.js"; +import {isAssetPath, isPathImport, relativePath, resolveLocalPath, resolvePath} from "./path.js"; export interface Resolvers { hash: string; - assets: Set; + assets: Set; // like files, but not registered for FileAttachment files: Set; localImports: Set; globalImports: Set; staticImports: Set; - stylesheets: Set; + stylesheets: Set; // stylesheets to be added by render resolveFile(specifier: string): string; resolveImport(specifier: string): string; resolveStylesheet(specifier: string): string; + resolveScript(specifier: string): string; } const defaultImports = [ @@ -73,7 +74,7 @@ export async function getResolvers( {root, path, loaders}: {root: string; path: string; loaders: LoaderResolver} ): Promise { const hash = createHash("sha256").update(page.html).update(JSON.stringify(page.data)); - const assets = findAssets(page.html, path); + const assets = new Set(); const files = new Set(); const fileMethods = new Set(); const localImports = new Set(); @@ -82,6 +83,13 @@ export async function getResolvers( const stylesheets = new Set(); const resolutions = new Map(); + // Add assets. + const info = findAssets(page.html, path); + for (const f of info.files) assets.add(f); + for (const i of info.localImports) localImports.add(i); + for (const i of info.globalImports) globalImports.add(i); + for (const i of info.staticImports) staticImports.add(i); + // Add stylesheets. TODO Instead of hard-coding Source Serif Pro, parse the // page’s stylesheet to look for external imports. 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( : specifier; } + function resolveScript(src: string): string { + if (isAssetPath(src)) { + const localPath = resolveLocalPath(path, src); + return localPath ? resolveImport(relativePath(path, localPath)) : src; + } else { + return resolveImport(src); + } + } + return { hash: hash.digest("hex"), assets, @@ -252,6 +269,7 @@ export async function getResolvers( stylesheets, resolveFile, resolveImport, + resolveScript, resolveStylesheet }; } diff --git a/test/html-test.ts b/test/html-test.ts index ae6bc22c8..f18e97940 100644 --- a/test/html-test.ts +++ b/test/html-test.ts @@ -42,78 +42,107 @@ describe("html(strings, ...values)", () => { describe("findAssets(html, path)", () => { it("finds local files from img[src]", () => { const html = ''; - assert.deepStrictEqual(findAssets(html, "foo"), new Set(["./test.png"])); + assert.deepStrictEqual(findAssets(html, "foo").files, new Set(["./test.png"])); }); it("finds local files from img[srcset]", () => { const html = 'Image for testing'; // prettier-ignore - assert.deepStrictEqual(findAssets(html, "foo"), new Set(["./large.jpg", "./small.jpg"])); + assert.deepStrictEqual(findAssets(html, "foo").files, new Set(["./large.jpg", "./small.jpg"])); }); it("finds local files from video[src]", () => { const html = ''; // prettier-ignore - assert.deepStrictEqual(findAssets(html, "foo"), new Set(["./observable.mov"])); + assert.deepStrictEqual(findAssets(html, "foo").files, new Set(["./observable.mov"])); }); it("finds local files from video source[src]", () => { const html = ''; // prettier-ignore - assert.deepStrictEqual(findAssets(html, "foo"), new Set(["./observable.mp4", "./observable.mov"])); + assert.deepStrictEqual(findAssets(html, "foo").files, new Set(["./observable.mp4", "./observable.mov"])); }); it("finds local files from picture source[srcset]", () => { const html = ''; // prettier-ignore - assert.deepStrictEqual(findAssets(html, "foo"), new Set(["./observable-logo-narrow.png", "./observable-logo-wide.png"])); // prettier-ignore + assert.deepStrictEqual(findAssets(html, "foo").files, new Set(["./observable-logo-narrow.png", "./observable-logo-wide.png"])); // prettier-ignore }); it("ignores non-local files from img[src]", () => { const html = ''; // prettier-ignore - assert.deepStrictEqual(findAssets(html, "foo"), new Set()); + assert.deepStrictEqual(findAssets(html, "foo").files, new Set()); }); it("ignores non-local files from img[srcset]", () => { const html = 'Cat image for testing'; // prettier-ignore - assert.deepStrictEqual(findAssets(html, "foo"), new Set(["./small.jpg"])); + assert.deepStrictEqual(findAssets(html, "foo").files, new Set(["./small.jpg"])); }); it("ignores non-local files from video source[src]", () => { const html = ''; // prettier-ignore - assert.deepStrictEqual(findAssets(html, "foo"), new Set(["./observable.mov"])); + assert.deepStrictEqual(findAssets(html, "foo").files, new Set(["./observable.mov"])); + }); + it("finds local imports from script[src]", () => { + const html = ' + diff --git a/test/output/build/imports/foo/foo.html b/test/output/build/imports/foo/foo.html index 3fcd51956..8c3935a1c 100644 --- a/test/output/build/imports/foo/foo.html +++ b/test/output/build/imports/foo/foo.html @@ -47,6 +47,7 @@
    +
@@ -62,7 +63,7 @@

Foo
diff --git a/test/output/build/imports/script.html b/test/output/build/imports/script.html new file mode 100644 index 000000000..510ccd227 --- /dev/null +++ b/test/output/build/imports/script.html @@ -0,0 +1,46 @@ + + + +Scripts + + + + + + + + + + + + + + + +
+
+

Scripts

+ + +
+ +