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 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 = '
'; // 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 = '
'; // 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 @@
Home
@@ -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
+
+
+
+
+