diff --git a/packages/core/package.json b/packages/core/package.json index 6bf40604b5..18213a2a08 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -109,6 +109,7 @@ "remark-rehype": "^11.1.1", "remark-stringify": "^11.0.0", "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", "uuid": "^8.3.2", "y-prosemirror": "^1.3.4", "y-protocols": "^1.0.6", diff --git a/packages/core/src/api/exporters/markdown/markdownExporter.ts b/packages/core/src/api/exporters/markdown/markdownExporter.ts index 488886c76d..ed439519a9 100644 --- a/packages/core/src/api/exporters/markdown/markdownExporter.ts +++ b/packages/core/src/api/exporters/markdown/markdownExporter.ts @@ -11,8 +11,9 @@ import { initializeESMDependencies, } from "../../../util/esmDependencies.js"; import { createExternalHTMLExporter } from "../html/externalHTMLExporter.js"; -import { removeUnderlines } from "./removeUnderlinesRehypePlugin.js"; +import { removeUnderlines } from "./util/removeUnderlinesRehypePlugin.js"; import { addSpacesToCheckboxes } from "./util/addSpacesToCheckboxesRehypePlugin.js"; +import { convertVideoToMarkdown } from "./util/convertVideoToMarkdownRehypePlugin.js"; // Needs to be sync because it's used in drag handler event (SideMenuPlugin) // Ideally, call `await initializeESMDependencies()` before calling this function @@ -28,12 +29,15 @@ export function cleanHTMLToMarkdown(cleanHTMLString: string) { const markdownString = deps.unified .unified() .use(deps.rehypeParse.default, { fragment: true }) + .use(convertVideoToMarkdown) .use(removeUnderlines) .use(addSpacesToCheckboxes) .use(deps.rehypeRemark.default) .use(deps.remarkGfm.default) .use(deps.remarkStringify.default, { - handlers: { text: (node) => node.value }, + handlers: { + text: (node) => node.value, + }, }) .processSync(cleanHTMLString); diff --git a/packages/core/src/api/exporters/markdown/util/convertVideoToMarkdownRehypePlugin.ts b/packages/core/src/api/exporters/markdown/util/convertVideoToMarkdownRehypePlugin.ts new file mode 100644 index 0000000000..34fbcec7d4 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/util/convertVideoToMarkdownRehypePlugin.ts @@ -0,0 +1,18 @@ +import { visit } from "unist-util-visit"; + +// Originally, rehypeParse parses videos as links, which is incorrect. +export function convertVideoToMarkdown() { + return (tree: any) => { + visit(tree, "element", (node, index, parent) => { + if (node.tagName === "video") { + const src = node.properties?.src || node.properties?.["data-url"] || ""; + const name = + node.properties?.title || node.properties?.["data-name"] || ""; + parent.children[index!] = { + type: "text", + value: `![${name}](${src})`, + }; + } + }); + }; +} diff --git a/packages/core/src/api/exporters/markdown/removeUnderlinesRehypePlugin.ts b/packages/core/src/api/exporters/markdown/util/removeUnderlinesRehypePlugin.ts similarity index 100% rename from packages/core/src/api/exporters/markdown/removeUnderlinesRehypePlugin.ts rename to packages/core/src/api/exporters/markdown/util/removeUnderlinesRehypePlugin.ts diff --git a/packages/core/src/api/parsers/markdown/parseMarkdown.ts b/packages/core/src/api/parsers/markdown/parseMarkdown.ts index d48a0fae19..ce97ddef6c 100644 --- a/packages/core/src/api/parsers/markdown/parseMarkdown.ts +++ b/packages/core/src/api/parsers/markdown/parseMarkdown.ts @@ -8,6 +8,7 @@ import { } from "../../../schema/index.js"; import { initializeESMDependencies } from "../../../util/esmDependencies.js"; import { HTMLToBlocks } from "../html/parseHTML.js"; +import { isVideoUrl } from "../../../util/string.js"; // modified version of https://github.com/syntax-tree/mdast-util-to-hast/blob/main/lib/handlers/code.js // that outputs a data-language attribute instead of a CSS class (e.g.: language-typescript) @@ -48,6 +49,27 @@ function code(state: any, node: any) { return result; } +function video(state: any, node: any) { + const url = String(node?.url || ""); + const title = node?.title ? String(node.title) : undefined; + + let result: any = { + type: "element", + tagName: "video", + properties: { + src: url, + "data-name": title, + "data-url": url, + controls: true, + }, + children: [], + }; + state.patch?.(node, result); + result = state.applyData ? state.applyData(node, result) : result; + + return result; +} + export async function markdownToHTML(markdown: string): Promise { const deps = await initializeESMDependencies(); @@ -58,6 +80,15 @@ export async function markdownToHTML(markdown: string): Promise { .use(deps.remarkRehype.default, { handlers: { ...(deps.remarkRehype.defaultHandlers as any), + image: (state: any, node: any) => { + const url = String(node?.url || ""); + + if (isVideoUrl(url)) { + return video(state, node); + } else { + return deps.remarkRehype.defaultHandlers.image(state, node); + } + }, code, }, }) diff --git a/packages/core/src/util/string.ts b/packages/core/src/util/string.ts index a2bbc6822d..8f863af0a8 100644 --- a/packages/core/src/util/string.ts +++ b/packages/core/src/util/string.ts @@ -13,3 +13,24 @@ export function filenameFromURL(url: string): string { } return parts[parts.length - 1]; } + +export function isVideoUrl(url: string) { + const videoExtensions = [ + "mp4", + "webm", + "ogg", + "mov", + "mkv", + "flv", + "avi", + "wmv", + "m4v", + ]; + try { + const pathname = new URL(url).pathname; + const ext = pathname.split(".").pop()?.toLowerCase() || ""; + return videoExtensions.includes(ext); + } catch (_) { + return false; + } +}