Skip to content

Commit 848bb4e

Browse files
committed
promote local media assets to file attachments
1 parent 5f34821 commit 848bb4e

File tree

3 files changed

+223
-8
lines changed

3 files changed

+223
-8
lines changed

src/files.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {relativeUrl, resolvePath} from "./url.js";
99
// A path is local if it doesn’t go outside the the root.
1010
export function getLocalPath(sourcePath: string, name: string): string | null {
1111
if (/^\w+:/.test(name)) return null; // URL
12+
if (name.startsWith("#")) return null; // anchor tag
1213
const path = resolvePath(sourcePath, name);
1314
if (path.startsWith("../")) return null; // goes above root
1415
return path;

src/markdown.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -316,24 +316,58 @@ function renderIntoPieces(renderer: Renderer, root: string, sourcePath: string):
316316
}
317317
let result = "";
318318
for (const piece of context.pieces) {
319-
result += piece.html = normalizePieceHtml(piece.html, root, sourcePath, context);
319+
result += piece.html = normalizePieceHtml(piece.html, sourcePath, context);
320320
}
321321
return result;
322322
};
323323
}
324324

325-
function normalizePieceHtml(html: string, root: string, sourcePath: string, context: ParseContext): string {
325+
const SUPPORTED_PROPERTIES: readonly {query: string; src: "href" | "src" | "srcset"}[] = Object.freeze([
326+
{query: "img[src]", src: "src"},
327+
{query: "img[srcset]", src: "srcset"},
328+
{query: "picture source[srcset]", src: "srcset"},
329+
{query: "video[src]", src: "src"},
330+
{query: "video source[src]", src: "src"},
331+
{query: "audio[src]", src: "src"},
332+
{query: "audio source[src]", src: "src"},
333+
{query: "link[href]", src: "href"}
334+
]);
335+
export function normalizePieceHtml(html: string, sourcePath: string, context: ParseContext): string {
326336
const {document} = parseHTML(html);
327337

328338
// Extracting references to files (such as from linked stylesheets).
329-
for (const element of document.querySelectorAll("link[href]") as any as Iterable<Element>) {
330-
const href = element.getAttribute("href")!;
331-
const path = getLocalPath(sourcePath, href);
332-
if (path) {
333-
context.files.push(fileReference(href, sourcePath));
334-
element.setAttribute("href", relativeUrl(sourcePath, join("_file", path)));
339+
const files = new Set<FileReference>();
340+
for (const {query, src} of SUPPORTED_PROPERTIES) {
341+
for (const element of document.querySelectorAll(query) as any as Iterable<Element>) {
342+
if (src === "srcset") {
343+
const srcset = element.getAttribute(src);
344+
const paths =
345+
srcset &&
346+
srcset
347+
.split(",")
348+
.map((p) => {
349+
const parts = p.trim().split(/\s+/);
350+
const source = parts[0];
351+
const path = getLocalPath(sourcePath, source);
352+
if (path) {
353+
files.add(fileReference(source, sourcePath));
354+
return `${relativeUrl(sourcePath, join("_file", path))} ${parts.slice(1).join(" ")}`;
355+
}
356+
return parts.join(" ");
357+
})
358+
.filter((p) => !!p);
359+
if (paths && paths.length > 0) element.setAttribute(src, paths.join(", "));
360+
} else {
361+
const source = element.getAttribute(src);
362+
const path = getLocalPath(sourcePath, source!);
363+
if (path) {
364+
files.add(fileReference(source!, sourcePath));
365+
element.setAttribute(src, relativeUrl(sourcePath, join("_file", path)));
366+
}
367+
}
335368
}
336369
}
370+
if (files.size > 0) context.files.push(...files);
337371

338372
// Syntax highlighting for <code> elements. The code could contain an inline
339373
// expression within, or other HTML, but we only highlight text nodes that are
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import assert from "node:assert";
2+
import {normalizePieceHtml} from "../src/markdown.js";
3+
4+
const html = (strings, ...values) => String.raw({raw: strings}, ...values);
5+
const mockContext = () => ({files: [], imports: [], pieces: [], startLine: 0, currentLine: 0});
6+
7+
describe("file attachments", () => {
8+
describe("added", () => {
9+
const sourcePath = "/attachments.md";
10+
11+
it("img[src]", () => {
12+
const htmlStr = html`<img src="./test.png">`;
13+
const expected = html`<img src="./_file/test.png">`;
14+
const context = mockContext();
15+
const actual = normalizePieceHtml(htmlStr, sourcePath, context);
16+
17+
assert.equal(actual, expected);
18+
assert.deepEqual(context.files, [
19+
{
20+
mimeType: "image/png",
21+
name: "./test.png",
22+
path: "./_file/test.png"
23+
}
24+
]);
25+
});
26+
27+
it("img[srcset]", () => {
28+
const htmlStr = html`
29+
<img
30+
srcset="small.jpg 480w, large.jpg 800w"
31+
sizes="(max-width: 600px) 480px,
32+
800px"
33+
src="large.jpg"
34+
alt="Image for testing"
35+
/>
36+
`;
37+
const expected = html`
38+
<img srcset="./_file/small.jpg 480w, ./_file/large.jpg 800w" sizes="(max-width: 600px) 480px,
39+
800px" src="./_file/large.jpg" alt="Image for testing">
40+
`;
41+
const context = mockContext();
42+
const actual = normalizePieceHtml(htmlStr, sourcePath, context);
43+
44+
assert.equal(actual, expected);
45+
assert.deepEqual(context.files, [
46+
{
47+
mimeType: "image/jpeg",
48+
name: "large.jpg",
49+
path: "./_file/large.jpg"
50+
},
51+
{
52+
mimeType: "image/jpeg",
53+
name: "small.jpg",
54+
path: "./_file/small.jpg"
55+
},
56+
{
57+
mimeType: "image/jpeg",
58+
name: "large.jpg",
59+
path: "./_file/large.jpg"
60+
}
61+
]);
62+
});
63+
64+
it("video[src]", () => {
65+
const htmlStr = html`<video src="observable.mov" controls>
66+
Your browser doesn't support HTML video.
67+
</video>`;
68+
const expected = html`<video src="./_file/observable.mov" controls>
69+
Your browser doesn't support HTML video.
70+
</video>`;
71+
const context = mockContext();
72+
const actual = normalizePieceHtml(htmlStr, sourcePath, context);
73+
74+
assert.equal(actual, expected);
75+
assert.deepEqual(context.files, [
76+
{
77+
mimeType: "video/quicktime",
78+
name: "observable.mov",
79+
path: "./_file/observable.mov"
80+
}
81+
]);
82+
});
83+
84+
it("video source[src]", () => {
85+
const htmlStr = html`<video width="320" height="240" controls>
86+
<source src="observable.mp4" type="video/mp4">
87+
<source src="observable.mov" type="video/mov">
88+
Your browser doesn't support HTML video.
89+
</video>`;
90+
91+
const expected = html`<video width="320" height="240" controls>
92+
<source src="./_file/observable.mp4" type="video/mp4">
93+
<source src="./_file/observable.mov" type="video/mov">
94+
Your browser doesn't support HTML video.
95+
</video>`;
96+
97+
const context = mockContext();
98+
const actual = normalizePieceHtml(htmlStr, sourcePath, context);
99+
100+
assert.equal(actual, expected);
101+
assert.deepEqual(context.files, [
102+
{
103+
mimeType: "video/mp4",
104+
name: "observable.mp4",
105+
path: "./_file/observable.mp4"
106+
},
107+
{
108+
mimeType: "video/quicktime",
109+
name: "observable.mov",
110+
path: "./_file/observable.mov"
111+
}
112+
]);
113+
});
114+
});
115+
116+
describe("not added", () => {
117+
const sourcePath = "/attachments.md";
118+
119+
it("img[src] only adds local files", () => {
120+
const htmlStr = html`<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/American_Shorthair.jpg/900px-American_Shorthair.jpg">`;
121+
const expected = html`<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/American_Shorthair.jpg/900px-American_Shorthair.jpg">`;
122+
const context = mockContext();
123+
const actual = normalizePieceHtml(htmlStr, sourcePath, context);
124+
125+
assert.equal(actual, expected);
126+
assert.deepEqual(context.files, []);
127+
});
128+
129+
it("img[srcset] only adds local files", () => {
130+
const htmlStr = html`
131+
<img
132+
srcset="small.jpg 480w, https://upload.wikimedia.org/900px-American_Shorthair.jpg 900w"
133+
sizes="(max-width: 600px) 480px, 900px"
134+
src="https://upload.wikimedia.org/900px-American_Shorthair.jpg"
135+
alt="Cat image for testing"
136+
/>
137+
`;
138+
const expected = html`
139+
<img srcset="./_file/small.jpg 480w, https://upload.wikimedia.org/900px-American_Shorthair.jpg 900w" sizes="(max-width: 600px) 480px, 900px" src="https://upload.wikimedia.org/900px-American_Shorthair.jpg" alt="Cat image for testing">
140+
`;
141+
const context = mockContext();
142+
const actual = normalizePieceHtml(htmlStr, sourcePath, context);
143+
144+
assert.equal(actual, expected);
145+
assert.deepEqual(context.files, [
146+
{
147+
mimeType: "image/jpeg",
148+
name: "small.jpg",
149+
path: "./_file/small.jpg"
150+
}
151+
]);
152+
});
153+
154+
it("video source[src] only adds local files", () => {
155+
const htmlStr = html`<video width="320" height="240" controls>
156+
<source src="https://www.youtube.com/watch?v=SsFyayu5csc" type="video/youtube"/>
157+
<source src="observable.mov" type="video/mov">
158+
Your browser doesn't support HTML video.
159+
</video>`;
160+
161+
const expected = html`<video width="320" height="240" controls>
162+
<source src="https://www.youtube.com/watch?v=SsFyayu5csc" type="video/youtube">
163+
<source src="./_file/observable.mov" type="video/mov">
164+
Your browser doesn't support HTML video.
165+
</video>`;
166+
167+
const context = mockContext();
168+
const actual = normalizePieceHtml(htmlStr, sourcePath, context);
169+
170+
assert.equal(actual, expected);
171+
assert.deepEqual(context.files, [
172+
{
173+
mimeType: "video/quicktime",
174+
name: "observable.mov",
175+
path: "./_file/observable.mov"
176+
}
177+
]);
178+
});
179+
});
180+
});

0 commit comments

Comments
 (0)