Skip to content

Commit 52236ca

Browse files
committed
map assets in markdown and html cells
1 parent f540726 commit 52236ca

File tree

9 files changed

+260
-4
lines changed

9 files changed

+260
-4
lines changed

.prettierrc.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"bracketSpacing": false,
3+
"embeddedLanguageFormatting": "off",
34
"trailingComma": "none",
45
"printWidth": 100
56
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"@types/markdown-it": "^14.1.2",
6666
"eslint": "^9.29.0",
6767
"globals": "^16.2.0",
68+
"htl": "^0.3.1",
6869
"jsdom": "^26.1.0",
6970
"tsx": "^4.20.3",
7071
"typescript": "^5.8.3",

src/runtime/define.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {Mutator} from "./stdlib/mutable.js";
77

88
export type DefineState = DisplayState & {
99
/** the runtime variables associated with this cell */
10-
variables: Variable[]
10+
variables: Variable[];
1111
};
1212

1313
export type Definition = {
@@ -27,12 +27,14 @@ export type Definition = {
2727
autoview?: boolean;
2828
/** whether this cell’s singular output is a mutable */
2929
automutable?: boolean;
30+
/** an asset mapping to apply to any autodisplayed assets (e.g., images and videos) */
31+
assets?: Map<string, string>;
3032
};
3133

3234
export function define(state: DefineState, definition: Definition, observer = observe): void {
3335
const {id, body, inputs = [], outputs = [], output, autodisplay, autoview, automutable} = definition;
3436
const variables = state.variables;
35-
const v = main.variable(observer(state, id, autodisplay), {shadow: {}});
37+
const v = main.variable(observer(state, definition), {shadow: {}});
3638
const vid = output ?? (outputs.length ? `cell ${id}` : null);
3739
if (inputs.includes("display") || inputs.includes("view")) {
3840
let displayVersion = -1; // the variable._version of currently-displayed values

src/runtime/display.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import type {Definition} from "./define.js";
12
import {inspect, inspectError, getExpanded} from "./inspect.js";
3+
import {mapAssets} from "./stdlib/assets.js";
24

35
export type DisplayState = {
46
/** the HTML element in which to render this cell’s display */
@@ -44,7 +46,7 @@ export function clear(state: DisplayState): void {
4446
while (state.root.lastChild) state.root.lastChild.remove();
4547
}
4648

47-
export function observe(state: DisplayState, _id: number, autodisplay?: boolean) {
49+
export function observe(state: DisplayState, {autodisplay, assets}: Definition) {
4850
return {
4951
_error: false,
5052
_node: state.root, // _node for visibility promise
@@ -57,6 +59,7 @@ export function observe(state: DisplayState, _id: number, autodisplay?: boolean)
5759
fulfilled(value: unknown) {
5860
if (autodisplay) {
5961
clear(state);
62+
if (assets && value instanceof Element) mapAssets(value, assets);
6063
display(state, value);
6164
}
6265
},

src/runtime/stdlib/assets.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// @vitest-environment jsdom
2+
import {html} from "htl";
3+
import {assert, test} from "vitest";
4+
import {collectAssets} from "./assets.js";
5+
6+
function getAssets(root: HTMLElement): Set<string> {
7+
const assets = new Set<string>();
8+
collectAssets(assets, root);
9+
return assets;
10+
}
11+
12+
test("find asset paths in descendants", () => {
13+
assert.deepStrictEqual(
14+
new Set([
15+
"./a-href.txt",
16+
"./audio-source-src.wav",
17+
"./audio-src.wav",
18+
"./img-src.png",
19+
"./img-srcset-src.png",
20+
"./img-srcset.png",
21+
"./link.css",
22+
"./picture-source-src.png",
23+
"./picture-srcset-src.png",
24+
"./picture-srcset.png",
25+
"./video-source-src.mp4",
26+
"./video-src.mp4"
27+
]),
28+
getAssets(html`<div>
29+
<a href="./a-href.txt" download>download</a>
30+
<audio><source src="./audio-source-src.wav"></audio>
31+
<audio src="./audio-src.wav"></audio>
32+
<img src="./img-src.png">
33+
<img src="./img-srcset-src.png" srcset="./img-srcset.png 2x">
34+
<link href="./link.css" rel="stylesheet">
35+
<picture><source src="./picture-source-src.png"></picture>
36+
<picture><source src="./picture-srcset-src.png" srcset="./picture-srcset.png 2x"></picture>
37+
<video><source src="./video-source-src.mp4"></video>
38+
<video src="./video-src.mp4"></video>
39+
</div>`)
40+
);
41+
});
42+
43+
test("decodes paths", () => {
44+
assert.deepStrictEqual(
45+
new Set(["./hello world.png"]),
46+
getAssets(html`<div><img src="./hello%20world.png"></div>`)
47+
);
48+
});
49+
50+
test("strips query strings and anchor fragments from the path", () => {
51+
assert.deepStrictEqual(
52+
new Set(["./img1.png", "./img2.png", "./img3.png", "./img4.png"]),
53+
getAssets(html`<div>
54+
<img src="./img1.png?foo=bar">
55+
<img src="./img2.png#baz">
56+
<img src="./img3.png?foo#bar">
57+
<img src="./img4.png#foo?bar">
58+
</div>`)
59+
);
60+
});
61+
62+
test("adds a leading dot slash to relative paths", () => {
63+
assert.deepStrictEqual(
64+
new Set([
65+
"./file.png",
66+
"./path/to/file.png",
67+
"/root.png",
68+
"./dot-slash.png",
69+
"../dot-dot-slash.png"
70+
]),
71+
getAssets(html`<div>
72+
<img src="file.png">
73+
<img src="path/to/file.png">
74+
<img src="/root.png">
75+
<img src="./dot-slash.png">
76+
<img src="../dot-dot-slash.png">
77+
</div>`)
78+
);
79+
});
80+
81+
test("ignores protocol links", () => {
82+
assert.deepStrictEqual(
83+
new Set([]),
84+
getAssets(html`<div>
85+
<img src="https://example.com/test.png">
86+
</div>`)
87+
);
88+
});
89+
90+
test("ignores fragment links", () => {
91+
assert.deepStrictEqual(
92+
new Set([]),
93+
getAssets(html`<div>
94+
<a href="#test" download>download</a>
95+
</div>`)
96+
);
97+
});
98+
99+
test("ignores rel=external elements", () => {
100+
assert.deepStrictEqual(
101+
new Set(["./internal.txt"]),
102+
getAssets(html`<div>
103+
<a href="external.txt" rel="external" download>download</a>
104+
<a href="internal.txt" rel="notexternal" download>download</a>
105+
</div>`)
106+
);
107+
});

src/runtime/stdlib/assets.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
const SRC_SELECTOR = [
2+
"audio source[src]", // audio
3+
"audio[src]", // audio
4+
"img[src]", // images
5+
"picture source[src]", // images
6+
"video source[src]", // videos
7+
"video[src]" // videos
8+
].join();
9+
10+
const SRCSET_SELECTOR = [
11+
"img[srcset]", // images
12+
"picture source[srcset]" // images
13+
].join();
14+
15+
const HREF_SELECTOR = [
16+
"a[href][download]", // download links
17+
"link[href]" // stylesheets
18+
].join();
19+
20+
const ASSET_ATTRIBUTES = [
21+
[SRC_SELECTOR, "src"],
22+
[SRCSET_SELECTOR, "srcset"],
23+
[HREF_SELECTOR, "href"]
24+
];
25+
26+
/** Populates the asset keys from the specified root. */
27+
export function collectAssets(assets: Set<string>, root: Element): void {
28+
for (const [selector, name] of ASSET_ATTRIBUTES) {
29+
for (const element of root.querySelectorAll(selector)) {
30+
if (isRelExternal(element)) continue;
31+
const source = decodeURI(element.getAttribute(name)!);
32+
if (name === "srcset") {
33+
for (const s of parseSrcset(source)) {
34+
if (isSourcePath(s)) {
35+
assets.add(asImportPath(s));
36+
}
37+
}
38+
} else if (isSourcePath(source)) {
39+
assets.add(asImportPath(source));
40+
}
41+
}
42+
}
43+
}
44+
45+
/** Mutates the specified root to apply the specified asset mapping. */
46+
export function mapAssets(root: Element, assets: Map<string, string>): void {
47+
const resolve = (s: string) => assets.get(asImportPath(s)) ?? s;
48+
for (const [selector, src] of ASSET_ATTRIBUTES) {
49+
for (const element of root.querySelectorAll(selector)) {
50+
if (isRelExternal(element)) continue;
51+
const source = decodeURI(element.getAttribute(src)!);
52+
if (src === "srcset") element.setAttribute(src, resolveSrcset(source, resolve));
53+
else element.setAttribute(src, resolve(source));
54+
}
55+
}
56+
}
57+
58+
/** Returns true if the specified element has rel=external. */
59+
function isRelExternal(a: Element): boolean {
60+
return /(?:^|\s)external(?:\s|$)/i.test(a.getAttribute("rel") ?? ""); // e.g., <a href rel="external">
61+
}
62+
63+
/** Strips the query string and anchor fragment from the specified source. */
64+
function asPath(source: string): string {
65+
const i = source.indexOf("?");
66+
const j = source.indexOf("#");
67+
const k = i >= 0 && j >= 0 ? Math.min(i, j) : i >= 0 ? i : j;
68+
return k >= 0 ? source.slice(0, k) : source; // strip query string or anchor fragment
69+
}
70+
71+
/** Converts the specified source into an import path, typically with ./. */
72+
function asImportPath(source: string): string {
73+
const path = asPath(source);
74+
return isImportPath(path) ? path : `./${path}`;
75+
}
76+
77+
/**
78+
* Returns true if the specified import specifier is a path, as opposed to a
79+
* bare module specifier or a URL; import paths start with ./, ../, or /.
80+
*/
81+
function isImportPath(specifier: string): boolean {
82+
return ["./", "../", "/"].some((prefix) => specifier.startsWith(prefix));
83+
}
84+
85+
/**
86+
* Returns true if the specified source (such as an img element src attribute)
87+
* is a path; this is anything that doesn’t start with a protocol or a hash.
88+
*/
89+
function isSourcePath(specifier: string): boolean {
90+
return !/^(\w+:|#)/.test(specifier);
91+
}
92+
93+
/** Parses the specified srcset attribute, returning the array of sources. */
94+
function parseSrcset(srcset: string): string[] {
95+
return srcset
96+
.trim()
97+
.split(/\s*,\s*/)
98+
.filter((src) => src)
99+
.map((src) => src.split(/\s+/)[0]);
100+
}
101+
102+
/** Resolves the specified srcset attribute, resolving any sources. */
103+
function resolveSrcset(srcset: string, resolve: (src: string) => string): string {
104+
return srcset
105+
.trim()
106+
.split(/\s*,\s*/)
107+
.filter((src) => src)
108+
.map((src) => {
109+
const parts = src.split(/\s+/);
110+
const path = resolve(parts[0]);
111+
if (path) parts[0] = encodeURI(path);
112+
return parts.join(" ");
113+
})
114+
.join(", ");
115+
}

src/vite/observable.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {deserialize} from "../lib/serialize.js";
99
import {Sourcemap} from "../javascript/sourcemap.js";
1010
import {transpile} from "../javascript/transpile.js";
1111
import {parseTemplate} from "../javascript/template.js";
12+
import {collectAssets} from "../runtime/stdlib/assets.js";
1213
import {highlight} from "../runtime/stdlib/highlight.js";
1314
import {MarkdownRenderer} from "../runtime/stdlib/md.js";
1415

@@ -46,6 +47,7 @@ export function observable({
4647
const tsource = await readFile(template, "utf-8");
4748
const document = parser.parseFromString(tsource, "text/html");
4849
const statics = new Set<Cell>();
50+
const assets = new Set<string>();
4951
const md = MarkdownRenderer({document});
5052

5153
const {version} = (await import("../../package.json", {with: {type: "json"}})).default;
@@ -77,6 +79,7 @@ export function observable({
7779
if (!template.expressions.length) statics.add(cell);
7880
div.innerHTML = stripExpressions(template, value);
7981
}
82+
collectAssets(assets, div);
8083
if (pinned) {
8184
const pre = cells.appendChild(document.createElement("pre"));
8285
const code = pre.appendChild(document.createElement("code"));
@@ -94,7 +97,22 @@ export function observable({
9497
`<style type="text/css">
9598
@import url("observable:styles/theme-${notebook.theme}.css");
9699
</style><script type="module">
97-
import {define} from "observable:runtime/define";
100+
import {define} from "observable:runtime/define";${Array.from(assets)
101+
.map(
102+
(asset, i) => `
103+
import asset${i + 1} from ${JSON.stringify(`${asset}?url`)};`
104+
)
105+
.join("")}${
106+
assets.size > 0
107+
? `
108+
109+
const assets = new Map([
110+
${Array.from(assets)
111+
.map((asset, i) => ` [${JSON.stringify(asset)}, asset${i + 1}]`)
112+
.join(",\n")}
113+
]);`
114+
: ""
115+
}
98116
${notebook.cells
99117
.filter((cell) => !statics.has(cell))
100118
.map((cell) => {
@@ -112,6 +130,7 @@ define(
112130
inputs: ${JSON.stringify(transpiled.inputs)},
113131
outputs: ${JSON.stringify(transpiled.outputs)},
114132
output: ${JSON.stringify(transpiled.output)},
133+
assets: ${assets.size > 0 ? "assets" : "undefined"},
115134
autodisplay: ${transpiled.autodisplay},
116135
autoview: ${transpiled.autoview},
117136
automutable: ${transpiled.automutable}

types/htl.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
declare module "htl" {
2+
export const html: (template: readonly string[], ...values: unknown[]) => HTMLElement;
3+
}

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,6 +1149,11 @@ has-flag@^4.0.0:
11491149
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
11501150
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
11511151

1152+
htl@^0.3.1:
1153+
version "0.3.1"
1154+
resolved "https://registry.yarnpkg.com/htl/-/htl-0.3.1.tgz#13c5a32fa46434f33b84d4553dd37e58a80e8d8a"
1155+
integrity sha512-1LBtd+XhSc+++jpOOt0lCcEycXs/zTQSupOISnVAUmvGBpV7DH+C2M6hwV7zWYfpTMMg9ch4NO0lHiOTAMHdVA==
1156+
11521157
html-encoding-sniffer@^4.0.0:
11531158
version "4.0.0"
11541159
resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz#696df529a7cfd82446369dc5193e590a3735b448"

0 commit comments

Comments
 (0)