Skip to content
Open
Show file tree
Hide file tree
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
131 changes: 85 additions & 46 deletions src/components/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,27 @@ import rehypeStringify from "rehype-stringify"
import remarkGfm from "remark-gfm"
import remarkParse from "remark-parse"
import remarkRehype from "remark-rehype"
import { For, Show, createEffect, createMemo, createSignal, on } from "solid-js"
import {
For,
Show,
createEffect,
createMemo,
createSignal,
on,
untrack,
} from "solid-js"
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 {
api,
loadCSS,
loadScriptIIFE,
notify,
pathDir,
pathJoin,
pathResolve,
} from "~/utils"
import { getMainColor, getSettingBool, objStore } from "~/store"
import { loadCSS, loadScriptIIFE, notify, pathDir, pathJoin } from "~/utils"
import { isMobile } from "~/utils/compatibility.js"
import hljs from "highlight.js"
import { EncodingSelect } from "."
import { watchMediaSrc } from "./markdown/media-fallback"
import { rehypeMedia } from "./markdown/rehype-media"
import type { HastNode, MediaContext } from "./markdown/rehype-media"
import "./markdown.css"

type TocItem = { indent: number; text: string; tagName: string; key: string }
Expand Down Expand Up @@ -157,7 +160,7 @@ const { katexCSSPath, mermaidJSPath } = useCDN()

async function renderMarkdown(
content: string,
sanitize: boolean,
ctx: { sanitize: boolean } & MediaContext,
): Promise<{ html: string; hasMermaid: boolean }> {
let processor = unified()

Expand All @@ -181,17 +184,60 @@ async function renderMarkdown(
}

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

if (sanitize)
// resolve media urls and render the image syntax of a video as a player
processor.use(() => (tree: unknown) => rehypeMedia(tree as HastNode, ctx))

if (ctx.sanitize) {
const attrs = defaultSchema.attributes ?? {}
const allow = (tag: string, ...names: string[]) => [
...(attrs[tag] ?? []),
...names,
]
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"],
// the default only allows http/https, which drops inline base64 pictures
protocols: {
...defaultSchema.protocols,
src: [...(defaultSchema.protocols?.src ?? []), "data"],
},
attributes: {
...defaultSchema.attributes,
...attrs,
// `data-md-path` feeds the runtime fallback of media links
"*": allow("*", "data*"),
code: [
["className", /^language-[\w-]+$/, "math-inline", "math-display"],
],
// attribute names are hast properties, not html attributes
video: allow(
"video",
"src",
"poster",
"controls",
"preload",
"autoplay",
"loop",
"muted",
"playsInline",
"crossOrigin",
),
audio: allow(
"audio",
"src",
"controls",
"preload",
"autoplay",
"loop",
"muted",
"crossOrigin",
),
source: allow("source", "src", "srcSet", "type", "media"),
track: allow("track", "src", "kind", "label", "srclang", "default"),
},
})
}

if (hasMath) {
const { default: rehypeKatex } = await import("rehype-katex")
Expand Down Expand Up @@ -220,45 +266,37 @@ export function Markdown(props: {
const { isString, text } = useParseText(props.children)
const { pathname } = useRouter()

// media urls of the markdown are relative to the dir it belongs to: the
// folder itself for a readme, the parent dir for a previewed file
const baseDir = createMemo(() =>
props.readme ? pathname() : pathDir(pathname()),
)
// the listing already carries the sign of every object next to the markdown
const siblingSigns = () => {
const signs = new Map<string, string>()
for (const obj of objStore.objs) {
if (obj.sign) signs.set(pathJoin(baseDir(), obj.name), obj.sign)
}
return signs
}

const md = createMemo(() => {
const raw = text(encoding())
const content =
!props.ext || props.ext.toLowerCase() === "md"
? raw
: `\`\`\`${props.ext}\n${raw}\n\`\`\``

return content.replace(/!\[.*?\]\((.*?)\)/g, (match) => {
const name = match.match(/!\[(.*?)\]\(.*?\)/)![1]
const rawUrl = match.match(/!\[.*?\]\((.*?)\)/)![1]

if (
rawUrl.startsWith("data:image/") ||
rawUrl.startsWith("http://") ||
rawUrl.startsWith("https://") ||
rawUrl.startsWith("//")
) {
return match
}

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 !props.ext || props.ext.toLowerCase() === "md"
? raw
: `\`\`\`${props.ext}\n${raw}\n\`\`\``
})

createEffect(
on([md, mermaidTheme], async () => {
setShow(false)

const { html, hasMermaid } = await renderMarkdown(
md(),
props.sanitize || getSettingBool("filter_readme_scripts"),
)
const { html, hasMermaid } = await renderMarkdown(md(), {
sanitize: props.sanitize || getSettingBool("filter_readme_scripts"),
baseDir: baseDir(),
// untracked: appending objects must not re-render the whole markdown
signs: untrack(siblingSigns),
})
setMarkdownHTML(html)

setTimeout(() => {
Expand Down Expand Up @@ -292,6 +330,7 @@ export function Markdown(props: {
window.mermaid.run({ querySelector: ".language-mermaid" })
}

watchMediaSrc(markdownRef())
window.onMDRender?.()
})
}),
Expand Down
12 changes: 12 additions & 0 deletions src/components/markdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@
/* background-color: #fff; */
}

/* media referenced by a markdown file must not overflow the container */
.markdown-body video,
.markdown-body audio {
max-width: 100%;
}

.markdown-body video {
display: block;
height: auto;
margin: 0.5em 0;
}

.markdown-body code,
.markdown-body kbd,
.markdown-body pre {
Expand Down
78 changes: 78 additions & 0 deletions src/components/markdown/media-fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { me, password } from "~/store"
import { fsGet, pathJoin } from "~/utils"

/**
* Media of this deployment is linked through `/d`, which answers 401 when the
* target needs a signature the folder listing didn't carry - a path outside
* the current dir, a mounted share, or an encrypted meta. Only then the raw
* url is resolved, per element that actually failed to load.
*/

// a raw url may be a temporary direct link, so it is not cached forever
const TTL = 10 * 60 * 1000
const resolved = new Map<string, { url: string; at: number }>()
const pending = new Map<string, Promise<string>>()

function fetchRawUrl(path: string) {
const cached = resolved.get(path)
if (cached && Date.now() - cached.at < TTL) return Promise.resolve(cached.url)
const inflight = pending.get(path)
if (inflight) return inflight

const request = (async () => {
try {
const resp = await fsGet(pathJoin(me().base_path, path), password())
return resp.code === 200 && resp.data?.raw_url ? resp.data.raw_url : ""
} catch {
return ""
}
})().then((url) => {
pending.delete(path)
if (url) resolved.set(path, { url, at: Date.now() })
return url
})
pending.set(path, request)
return request
}

function isBroken(el: HTMLElement) {
if (el instanceof HTMLImageElement)
return el.complete && el.naturalWidth === 0
if (el instanceof HTMLMediaElement)
return el.networkState === HTMLMediaElement.NETWORK_NO_SOURCE
return false
}

async function retryWithRawUrl(el: HTMLElement, path: string) {
// one attempt per element per render, a raw url can't fail twice silently
el.dataset.mdRetried = "1"
const url = await fetchRawUrl(path)
if (!url) {
delete el.dataset.mdRetried
return
}
el.setAttribute("src", url)
// candidates of srcset win over src, so they must go away
el.removeAttribute("srcset")
el.removeAttribute("sizes")
// <source>/<track> are only picked up again when the host reloads
const host = el instanceof HTMLMediaElement ? el : el.closest("video, audio")
if (host instanceof HTMLMediaElement) host.load()
}

export function watchMediaSrc(root?: ParentNode | null) {
root
?.querySelectorAll<HTMLElement>("img,video,audio,source,track")
.forEach((el) => {
const path = el.dataset.mdPath
if (!path || el.dataset.mdRetried) return
// the request may already have failed before this runs
if (isBroken(el)) {
void retryWithRawUrl(el, path)
return
}
el.addEventListener("error", () => void retryWithRawUrl(el, path), {
once: true,
})
})
}
Loading