diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index 97a446884..e027506ce 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -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 } @@ -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() @@ -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") @@ -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() + 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(() => { @@ -292,6 +330,7 @@ export function Markdown(props: { window.mermaid.run({ querySelector: ".language-mermaid" }) } + watchMediaSrc(markdownRef()) window.onMDRender?.() }) }), diff --git a/src/components/markdown.css b/src/components/markdown.css index f946926cc..fb51d4d14 100644 --- a/src/components/markdown.css +++ b/src/components/markdown.css @@ -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 { diff --git a/src/components/markdown/media-fallback.ts b/src/components/markdown/media-fallback.ts new file mode 100644 index 000000000..c149cac98 --- /dev/null +++ b/src/components/markdown/media-fallback.ts @@ -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() +const pending = new Map>() + +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") + // / 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("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, + }) + }) +} diff --git a/src/components/markdown/rehype-media.ts b/src/components/markdown/rehype-media.ts new file mode 100644 index 000000000..af2a20fdc --- /dev/null +++ b/src/components/markdown/rehype-media.ts @@ -0,0 +1,185 @@ +import { getLinkByDirAndObj } from "~/hooks/useLink" +import type { Obj } from "~/types" +import { api, base_path, ext, pathBase, pathDir, pathResolve } from "~/utils" + +/** + * Minimal hast shape. This runs after `rehype-raw`, so the tree only holds + * element/text nodes and no `raw` node is left to expand. + */ +export interface HastNode { + type: string + tagName?: string + properties?: Record + children?: HastNode[] +} + +export interface MediaContext { + /** storage path of the dir relative media urls are resolved against */ + baseDir: string + /** storage path -> sign, taken from the folder listing when available */ + signs: Map +} + +// `![](x.mp4)` can never be played by an , so it becomes a