Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 156 additions & 7 deletions src/components/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import { Motion } from "solid-motionone"
import { unified } from "unified"
import { useCDN, useParseText, useRouter } from "~/hooks"
import { useScrollListener } from "~/pages/home/toolbar/BackTop.jsx"
import { getMainColor, getSettingBool, me } from "~/store"
import { getMainColor, getSettingBool, me, password } from "~/store"
import {
api,
base_path,
fsGet,
loadCSS,
loadScriptIIFE,
notify,
Expand All @@ -32,6 +34,53 @@ type TocItem = { indent: number; text: string; tagName: string; key: string }
const MERMAID_PATTERN = /```mermaid[\s\S]*?```/i
const MATH_PATTERN = /\$\$[\s\S]+?\$\$|\$[^$\n]+?\$/

// markdown image syntax pointing to a video file is rendered as <video>
// instead of <img>, since browsers cannot play videos inside <img>
const VIDEO_EXTS = new Set(["mp4", "webm", "ogg", "ogv", "mov", "m4v", "mkv"])

// cache resolved raw urls across Markdown instances
const mediaSrcCache = new Map<string, Promise<string>>()

// origin of the local api, used to tell local media links from external ones
const apiOrigin = new URL(api).origin

// fetch a signed raw url for the given path, returns "" on failure
async function fetchRawUrl(path: string): Promise<string> {
const cached = mediaSrcCache.get(path)
if (cached) return cached
const pending = (async () => {
const resp = await fsGet(path, password())
return resp.code === 200 && resp.data?.raw_url ? resp.data.raw_url : ""
})()
mediaSrcCache.set(path, pending)
try {
const url = await pending
// do not cache failed lookups so that they can be retried
if (!url) mediaSrcCache.delete(path)
return url
} catch {
mediaSrcCache.delete(path)
return ""
}
}

async function runPool<T>(
items: T[],
worker: (item: T) => Promise<void>,
concurrency = 6,
) {
let index = 0
const runners = Array.from(
{ length: Math.min(concurrency, items.length) },
async () => {
while (index < items.length) {
await worker(items[index++])
}
},
)
await Promise.all(runners)
}

const [isTocVisible, setVisible] = createSignal(false)
const [isTocDisabled, setTocDisabled] = createStorageSignal(
"isMarkdownTocDisabled",
Expand Down Expand Up @@ -182,16 +231,48 @@ async function renderMarkdown(

processor.use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw)

if (sanitize)
if (sanitize) {
const attrs = defaultSchema.attributes ?? {}
processor.use(rehypeSanitize, {
...defaultSchema,
// video/audio/track are not in the default element whitelist and
// would be stripped entirely, breaking markdown video previews
tagNames: [...(defaultSchema.tagNames ?? []), "video", "audio", "track"],
attributes: {
...defaultSchema.attributes,
...attrs,
code: [
["className", /^language-[\w-]+$/, "math-inline", "math-display"],
],
// keep media elements usable: src may be rewritten to a signed url
video: [
...(attrs.video ?? []),
["src"],
["controls"],
["preload"],
["poster"],
["autoplay"],
["loop"],
["muted"],
["playsinline"],
["crossorigin"],
],
audio: [
...(attrs.audio ?? []),
["src"],
["controls"],
["preload"],
["autoplay"],
["loop"],
["crossorigin"],
],
source: [
...(attrs.source ?? []),
["src"],
["type"],
],
},
})
}

if (hasMath) {
const { default: rehypeKatex } = await import("rehype-katex")
Expand Down Expand Up @@ -240,14 +321,23 @@ export function Markdown(props: {
return match
}

// a video in image syntax cannot be played by <img>, so turn it
// into a <video> element; its src is resolved later by fixMediaSrc
const rawUrlPart = rawUrl.trim().split(/\s+/)[0]
const ext = rawUrlPart
.split(/[?#]/)[0]
.split(".")
.pop()
?.toLowerCase()
if (ext && VIDEO_EXTS.has(ext)) {
return `<video controls preload="metadata" src="${rawUrlPart}"></video>`
}

const resolvedPath = rawUrl.startsWith("/")
? rawUrl
: pathResolve(props.readme ? pathname() : pathDir(pathname()), rawUrl)

const url = `${api}/d${pathJoin(me().base_path, resolvedPath)}`
const ans = `![${name}](${url})`
console.log(ans)
return ans
return `![${name}](${api}/d${pathJoin(me().base_path, resolvedPath)})`
})
})

Expand Down Expand Up @@ -292,13 +382,72 @@ export function Markdown(props: {
window.mermaid.run({ querySelector: ".language-mermaid" })
}

fixMediaSrc()
window.onMDRender?.()
})
}),
)

const [markdownRef, setMarkdownRef] = createSignal<HTMLDivElement>()

// rewrite relative media src (img/video/audio/source) to signed raw urls,
// so that previews still work when sign_all is enabled or inside shares
const fixMediaSrc = async () => {
const $body = markdownRef()?.querySelector(".markdown-body")
if (!$body) return
const pathByEl = new Map<HTMLElement, string>()
$body
.querySelectorAll<HTMLElement>("img, video, source, audio")
.forEach((el) => {
const src = el.getAttribute("src")
if (!src) return
let rawPath = src
if (/^https?:\/\//i.test(src)) {
// markdown pipeline emits `${api}/d${...}` links; strip the origin
// and the deployment base_path to recover the storage path
let url: URL
try {
url = new URL(src)
} catch {
return // malformed url, keep the src as-is
}
if (url.origin !== apiOrigin) return // external link, keep as-is
rawPath = url.pathname
if (base_path && rawPath.startsWith(base_path)) {
rawPath = rawPath.slice(base_path.length) || "/"
}
} else if (/^(data:|blob:|\/\/)/i.test(src)) {
return // inline or protocol-relative external resource
}
if (/^\/(d|p)\//.test(rawPath)) {
// strip the /d or /p proxy prefix to get the storage path
rawPath = rawPath.slice(3)
} else if (!rawPath.startsWith("/")) {
rawPath = pathResolve(
props.readme ? pathname() : pathDir(pathname()),
rawPath,
)
}
rawPath = rawPath.split(/[?#]/)[0]
try {
rawPath = decodeURIComponent(rawPath)
} catch {
// keep the raw path if it contains malformed percent encoding
}
pathByEl.set(el, pathJoin(me().base_path, rawPath))
})
const urlByPath = new Map<string, string>()
const paths = Array.from(new Set(pathByEl.values()))
await runPool(paths, async (path) => {
const url = await fetchRawUrl(path)
if (url) urlByPath.set(path, url)
})
pathByEl.forEach((path, el) => {
const url = urlByPath.get(path)
if (url) el.setAttribute("src", url)
})
}

return (
<Box
ref={(r: HTMLDivElement) => setMarkdownRef(r)}
Expand Down