Skip to content

Commit d3f1475

Browse files
authored
promote local media assets to file attachments (#250)
* promote local media assets to file attachments * dedup files and update tests * Update src/markdown.ts ---------
1 parent 2f87fdf commit d3f1475

File tree

3 files changed

+255
-9
lines changed

3 files changed

+255
-9
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: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {computeHash} from "./hash.js";
1717
import {parseInfo} from "./info.js";
1818
import {type FileReference, type ImportReference, type Transpile, transpileJavaScript} from "./javascript.js";
1919
import {transpileTag} from "./tag.js";
20-
import {relativeUrl, resolvePath} from "./url.js";
20+
import {resolvePath} from "./url.js";
2121

2222
export interface ReadMarkdownResult {
2323
contents: string;
@@ -316,22 +316,63 @@ 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: "audio[src]", src: "src"},
327+
{query: "audio source[src]", src: "src"},
328+
{query: "img[src]", src: "src"},
329+
{query: "img[srcset]", src: "srcset"},
330+
{query: "link[href]", src: "href"},
331+
{query: "picture source[srcset]", src: "srcset"},
332+
{query: "video[src]", src: "src"},
333+
{query: "video source[src]", src: "src"}
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 filePaths = new Set<FileReference["path"]>();
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+
const file = fileReference(source, sourcePath);
354+
if (!filePaths.has(file.path)) {
355+
filePaths.add(file.path);
356+
context.files.push(file);
357+
}
358+
return `${file.path} ${parts.slice(1).join(" ")}`.trim();
359+
}
360+
return parts.join(" ");
361+
})
362+
.filter((p) => !!p);
363+
if (paths && paths.length > 0) element.setAttribute(src, paths.join(", "));
364+
} else {
365+
const source = element.getAttribute(src);
366+
const path = getLocalPath(sourcePath, source!);
367+
if (path) {
368+
const file = fileReference(source!, sourcePath);
369+
if (!filePaths.has(file.path)) {
370+
filePaths.add(file.path);
371+
context.files.push(file);
372+
}
373+
element.setAttribute(src, file.path);
374+
}
375+
}
335376
}
336377
}
337378

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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+
});
58+
59+
it("video[src]", () => {
60+
const htmlStr = html`<video src="observable.mov" controls>
61+
Your browser doesn't support HTML video.
62+
</video>`;
63+
const expected = html`<video src="./_file/observable.mov" controls>
64+
Your browser doesn't support HTML video.
65+
</video>`;
66+
const context = mockContext();
67+
const actual = normalizePieceHtml(htmlStr, sourcePath, context);
68+
69+
assert.equal(actual, expected);
70+
assert.deepEqual(context.files, [
71+
{
72+
mimeType: "video/quicktime",
73+
name: "observable.mov",
74+
path: "./_file/observable.mov"
75+
}
76+
]);
77+
});
78+
79+
it("video source[src]", () => {
80+
const htmlStr = html`<video width="320" height="240" controls>
81+
<source src="observable.mp4" type="video/mp4">
82+
<source src="observable.mov" type="video/mov">
83+
Your browser doesn't support HTML video.
84+
</video>`;
85+
86+
const expected = html`<video width="320" height="240" controls>
87+
<source src="./_file/observable.mp4" type="video/mp4">
88+
<source src="./_file/observable.mov" type="video/mov">
89+
Your browser doesn't support HTML video.
90+
</video>`;
91+
92+
const context = mockContext();
93+
const actual = normalizePieceHtml(htmlStr, sourcePath, context);
94+
95+
assert.equal(actual, expected);
96+
assert.deepEqual(context.files, [
97+
{
98+
mimeType: "video/mp4",
99+
name: "observable.mp4",
100+
path: "./_file/observable.mp4"
101+
},
102+
{
103+
mimeType: "video/quicktime",
104+
name: "observable.mov",
105+
path: "./_file/observable.mov"
106+
}
107+
]);
108+
});
109+
110+
it("picture source[srcset]", () => {
111+
const htmlStr = html`<picture>
112+
<source srcset="observable-logo-wide.png" media="(min-width: 600px)"/>
113+
<img src="observable-logo-narrow.png" />
114+
</picture>`;
115+
116+
const expected = html`<picture>
117+
<source srcset="./_file/observable-logo-wide.png" media="(min-width: 600px)">
118+
<img src="./_file/observable-logo-narrow.png">
119+
</picture>`;
120+
121+
const context = mockContext();
122+
const actual = normalizePieceHtml(htmlStr, sourcePath, context);
123+
124+
assert.equal(actual, expected);
125+
assert.deepEqual(context.files, [
126+
{
127+
mimeType: "image/png",
128+
name: "observable-logo-narrow.png",
129+
path: "./_file/observable-logo-narrow.png"
130+
},
131+
{
132+
mimeType: "image/png",
133+
name: "observable-logo-wide.png",
134+
path: "./_file/observable-logo-wide.png"
135+
}
136+
]);
137+
});
138+
});
139+
140+
describe("not added", () => {
141+
const sourcePath = "/attachments.md";
142+
143+
it("img[src] only adds local files", () => {
144+
const htmlStr = html`<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/American_Shorthair.jpg/900px-American_Shorthair.jpg">`;
145+
const expected = html`<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/American_Shorthair.jpg/900px-American_Shorthair.jpg">`;
146+
const context = mockContext();
147+
const actual = normalizePieceHtml(htmlStr, sourcePath, context);
148+
149+
assert.equal(actual, expected);
150+
assert.deepEqual(context.files, []);
151+
});
152+
153+
it("img[srcset] only adds local files", () => {
154+
const htmlStr = html`
155+
<img
156+
srcset="small.jpg 480w, https://upload.wikimedia.org/900px-American_Shorthair.jpg 900w"
157+
sizes="(max-width: 600px) 480px, 900px"
158+
src="https://upload.wikimedia.org/900px-American_Shorthair.jpg"
159+
alt="Cat image for testing"
160+
/>
161+
`;
162+
const expected = html`
163+
<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">
164+
`;
165+
const context = mockContext();
166+
const actual = normalizePieceHtml(htmlStr, sourcePath, context);
167+
168+
assert.equal(actual, expected);
169+
assert.deepEqual(context.files, [
170+
{
171+
mimeType: "image/jpeg",
172+
name: "small.jpg",
173+
path: "./_file/small.jpg"
174+
}
175+
]);
176+
});
177+
178+
it("video source[src] only adds local files", () => {
179+
const htmlStr = html`<video width="320" height="240" controls>
180+
<source src="https://www.youtube.com/watch?v=SsFyayu5csc" type="video/youtube"/>
181+
<source src="observable.mov" type="video/mov">
182+
Your browser doesn't support HTML video.
183+
</video>`;
184+
185+
const expected = html`<video width="320" height="240" controls>
186+
<source src="https://www.youtube.com/watch?v=SsFyayu5csc" type="video/youtube">
187+
<source src="./_file/observable.mov" type="video/mov">
188+
Your browser doesn't support HTML video.
189+
</video>`;
190+
191+
const context = mockContext();
192+
const actual = normalizePieceHtml(htmlStr, sourcePath, context);
193+
194+
assert.equal(actual, expected);
195+
assert.deepEqual(context.files, [
196+
{
197+
mimeType: "video/quicktime",
198+
name: "observable.mov",
199+
path: "./_file/observable.mov"
200+
}
201+
]);
202+
});
203+
});
204+
});

0 commit comments

Comments
 (0)