From 8e77f9f5aec5bb64cc7f25d807fbfb94d429b6d0 Mon Sep 17 00:00:00 2001 From: Alexis Sellier Date: Mon, 21 Mar 2022 12:36:14 +0100 Subject: [PATCH] Load images in markdown documents This loads images asynchronously inside markdown documents. Signed-off-by: Alexis Sellier --- src/Markdown.svelte | 27 ++++++++++++++++++++--- src/base/projects/Browser.svelte | 8 ++++++- src/base/projects/Readme.svelte | 5 ++++- src/utils.ts | 37 ++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/Markdown.svelte b/src/Markdown.svelte index 7051ffba1..7b2285c64 100644 --- a/src/Markdown.svelte +++ b/src/Markdown.svelte @@ -3,14 +3,17 @@ import { marked } from "marked"; import fm from "front-matter"; import type { FrontMatterResult } from "front-matter"; - import sanitizeHtml from 'sanitize-html'; + import type * as proj from "@app/project"; + import { getImageMime } from "@app/utils"; + import sanitizeHtml from "sanitize-html"; export let content: string; export let doc: FrontMatterResult> = fm(content); + export let getImage: (path: string) => Promise; let container: HTMLElement; - const sanitize = (content: string): string => { + const render = (content: string): string => { return sanitizeHtml(marked.parse(content), { allowedTags: sanitizeHtml.defaults.allowedTags.concat([ "img", @@ -35,6 +38,24 @@ e.classList.add("no-underline"); } } + + // Iterate over all images, and fetch their data from the API, then + // replace the source with a Data-URL. We do this due to the absence + // of a static file server. + for (let i of container.querySelectorAll("img")) { + const path = i.getAttribute("src"); + + if (path) { + getImage(path).then(blob => { + if (blob.content) { + const mime = getImageMime(path); + if (mime) { + i.setAttribute("src", `data:${mime};base64,${blob.content}`); + } + } + }); + } + } }); @@ -213,6 +234,6 @@ {/if}
- {@html marked(sanitize(doc.body))} + {@html render(doc.body)}
{/if} diff --git a/src/base/projects/Browser.svelte b/src/base/projects/Browser.svelte index 9d6341e42..6ca230d04 100644 --- a/src/base/projects/Browser.svelte +++ b/src/base/projects/Browser.svelte @@ -48,6 +48,12 @@ return state.blob; }; + // Get an image blob based on a relative path. + const getImage = async (imagePath: string): Promise => { + const finalPath = utils.canonicalize(imagePath, path); + return project.getBlob(commit, finalPath, { highlight: false }); + }; + const onSelect = async ({ detail: newPath }: { detail: string }) => { // Ensure we don't spend any time in a "loading" state. This means // the loading spinner won't be shown, and instead the blob will be @@ -159,7 +165,7 @@ {:then blob} {#if utils.isMarkdownPath(blob.path)} - + {:else} {/if} diff --git a/src/base/projects/Readme.svelte b/src/base/projects/Readme.svelte index 15b2ae7c1..204ba7990 100644 --- a/src/base/projects/Readme.svelte +++ b/src/base/projects/Readme.svelte @@ -1,6 +1,9 @@
- +
diff --git a/src/utils.ts b/src/utils.ts index a9f503ad6..860275e42 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -174,6 +174,43 @@ export function clickOutside(node: HTMLElement, onEventFunction: () => void): an }; } +// Get the mime type of an image, given a file path. +// Returns `null` if unknown. +export function getImageMime(path: string): string | null { + const mimes: Record = { + 'apng': 'image/apng', + 'png': 'image/png', + 'svg': 'image/svg+xml', + 'gif': 'image/gif', + 'jpeg': 'image/jpeg', + 'jpg': 'image/jpeg', + 'webp': 'image/webp', + }; + const ext = path.split(".").pop(); + + if (ext) { + if (mimes[ext]) { + return mimes[ext]; + } + } + return null; +} + +// TODO: Needs testing with absolute and relative paths. +export function canonicalize(path: string, base: string): string { + const finalPath = base + .split("/") + .slice(0, -1) // Remove file name. + .concat([path]) // Add image file path. + .join("/"); + + // URL is used to resolve relative paths, eg. `../../assets/image.png`. + const url = new URL(finalPath, document.location.origin); + const pathname = url.pathname.replace(/^\//, ""); + + return pathname; +} + // Takes a URL, eg. "https://twitter.com/cloudhead", and return "cloudhead". // Returns the original string if it was unable to extract the username. export function parseUsername(input: string): string {