添加显示图片和视频功能

This commit is contained in:
2026-07-08 17:07:54 +08:00
parent 52abffc3c4
commit 7ee990f7a2
6 changed files with 375 additions and 13 deletions

7
src-tauri/Cargo.lock generated
View File

@@ -1465,6 +1465,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@@ -3291,6 +3297,7 @@ dependencies = [
"gtk", "gtk",
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"http-range",
"jni", "jni",
"libc", "libc",
"log", "log",

View File

@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }

View File

@@ -1,7 +1,7 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use std::fs; use std::fs;
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}; use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
use tauri::Emitter; use tauri::{Emitter, Manager};
use tauri_plugin_dialog::DialogExt; use tauri_plugin_dialog::DialogExt;
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
@@ -43,8 +43,12 @@ fn pick_folder(app: tauri::AppHandle) -> Option<String> {
} }
#[tauri::command] #[tauri::command]
fn read_dir(path: String) -> Result<Vec<DirEntry>, String> { fn read_dir(app: tauri::AppHandle, path: String) -> Result<Vec<DirEntry>, String> {
let entries = fs::read_dir(&path).map_err(|e| format!("无法读取目录: {}", e))?; let entries = fs::read_dir(&path).map_err(|e| format!("无法读取目录: {}", e))?;
app.asset_protocol_scope()
.allow_directory(&path, true)
.map_err(|e| format!("无法授权媒体预览目录: {}", e))?;
let allowed_extensions = [ let allowed_extensions = [
"md", "png", "jpg", "jpeg", "gif", "webp", "avif", "mp4", "webm", "mov", "m4v", "ogv", "md", "png", "jpg", "jpeg", "gif", "webp", "avif", "mp4", "webm", "mov", "m4v", "ogv",
]; ];

View File

@@ -21,7 +21,11 @@
} }
], ],
"security": { "security": {
"csp": null "csp": null,
"assetProtocol": {
"enable": true,
"scope": []
}
} }
}, },
"bundle": { "bundle": {

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref, watch, onMounted, onUnmounted} from "vue"; import {computed, ref, watch, onMounted, onUnmounted} from "vue";
import {listen, type EventCallback, type EventName} from "@tauri-apps/api/event"; import {listen, type EventCallback, type EventName} from "@tauri-apps/api/event";
import {invoke, isTauri} from "@tauri-apps/api/core"; import {convertFileSrc, invoke, isTauri} from "@tauri-apps/api/core";
import {getCurrentWindow} from "@tauri-apps/api/window"; import {getCurrentWindow} from "@tauri-apps/api/window";
import MarkdownEditor from "./components/MarkdownEditor.vue"; import MarkdownEditor from "./components/MarkdownEditor.vue";
import DirectorySidebar from "./components/DirectorySidebar.vue"; import DirectorySidebar from "./components/DirectorySidebar.vue";
@@ -13,8 +13,19 @@ const charCount = ref(0);
type DocumentMode = "preview" | "edit" | "mixed"; type DocumentMode = "preview" | "edit" | "mixed";
type AppTheme = "system" | "warm" | "white" | "black" | "gruvbox" | "gray"; type AppTheme = "system" | "warm" | "white" | "black" | "gruvbox" | "gray";
type WorkspaceFileType = "markdown" | "image" | "video";
const DEFAULT_DOCUMENT_MODE_KEY = "yurou:default-document-mode"; const DEFAULT_DOCUMENT_MODE_KEY = "yurou:default-document-mode";
const APP_THEME_KEY = "yurou:app-theme"; const APP_THEME_KEY = "yurou:app-theme";
const MARKDOWN_EXTENSIONS = new Set(["md"]);
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif"]);
const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]);
const VIDEO_MIME_TYPES: Record<string, string> = {
mp4: "video/mp4",
webm: "video/webm",
mov: "video/quicktime",
m4v: "video/mp4",
ogv: "video/ogg",
};
function isDocumentMode(value: string | null): value is DocumentMode { function isDocumentMode(value: string | null): value is DocumentMode {
return value === "preview" || value === "edit" || value === "mixed"; return value === "preview" || value === "edit" || value === "mixed";
@@ -62,6 +73,10 @@ function applyAppTheme(theme: AppTheme) {
// File state // File state
const currentFilePath = ref<string | null>(null); const currentFilePath = ref<string | null>(null);
const currentFileType = ref<WorkspaceFileType>("markdown");
const mediaPreviewSrc = ref("");
const mediaPreviewError = ref("");
const mediaPreviewKey = ref(0);
const saveStatus = ref(""); const saveStatus = ref("");
const hasDraftDocument = ref(false); const hasDraftDocument = ref(false);
@@ -145,6 +160,11 @@ const settingsRequest = ref(0);
const settingsInitialPage = ref<"about" | "editor">("about"); const settingsInitialPage = ref<"about" | "editor">("about");
const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined } | null>(null); const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined } | null>(null);
const isWindowMaximized = ref(false); const isWindowMaximized = ref(false);
const isMarkdownDocument = computed(() => currentFileType.value === "markdown");
const currentVideoMimeType = computed(() => {
if (!currentFilePath.value) return "";
return VIDEO_MIME_TYPES[getFileExtension(currentFilePath.value)] || "";
});
const showStartActions = computed(() => { const showStartActions = computed(() => {
return !folderPath.value && !currentFilePath.value && !hasDraftDocument.value; return !folderPath.value && !currentFilePath.value && !hasDraftDocument.value;
}); });
@@ -351,6 +371,8 @@ function setDocumentContent(md: string) {
function resetMarkdownEditorState() { function resetMarkdownEditorState() {
setDocumentContent(""); setDocumentContent("");
currentFilePath.value = null; currentFilePath.value = null;
currentFileType.value = "markdown";
resetMediaPreviewState();
hasDraftDocument.value = false; hasDraftDocument.value = false;
saveStatus.value = ""; saveStatus.value = "";
showOutline.value = false; showOutline.value = false;
@@ -521,6 +543,7 @@ async function loadDir(path: string): Promise<boolean> {
} }
async function saveCurrentWorkspaceDocumentBeforeSwitch() { async function saveCurrentWorkspaceDocumentBeforeSwitch() {
if (!isMarkdownDocument.value) return true;
if (!currentFilePath.value && !hasDraftDocument.value) return true; if (!currentFilePath.value && !hasDraftDocument.value) return true;
const contentToSave = getLatestEditorContent(); const contentToSave = getLatestEditorContent();
@@ -591,6 +614,60 @@ function getFileName(path: string): string {
return path.split(/[/\\]/).pop() || path; return path.split(/[/\\]/).pop() || path;
} }
function getFileExtension(path: string): string {
const fileName = getFileName(path);
const dotIndex = fileName.lastIndexOf(".");
if (dotIndex < 0) return "";
return fileName.slice(dotIndex + 1).toLowerCase();
}
function getWorkspaceFileType(path: string): WorkspaceFileType {
const ext = getFileExtension(path);
if (IMAGE_EXTENSIONS.has(ext)) return "image";
if (VIDEO_EXTENSIONS.has(ext)) return "video";
if (MARKDOWN_EXTENSIONS.has(ext)) return "markdown";
return "markdown";
}
function getLocalAssetSrc(path: string): string {
if (isTauri()) return convertFileSrc(path);
if (/^[A-Za-z]:[\\/]/.test(path)) return `file:///${path.replace(/\\/g, "/")}`;
if (path.startsWith("\\\\")) return `file:${path.replace(/\\/g, "/")}`;
return path;
}
function resetMediaPreviewState() {
mediaPreviewSrc.value = "";
mediaPreviewError.value = "";
mediaPreviewKey.value += 1;
}
function openMediaPreview(path: string, fileType: Extract<WorkspaceFileType, "image" | "video">) {
currentFilePath.value = path;
currentFileType.value = fileType;
mediaPreviewSrc.value = getLocalAssetSrc(path);
mediaPreviewError.value = "";
mediaPreviewKey.value += 1;
setDocumentContent("");
hasDraftDocument.value = false;
saveStatus.value = "";
showOutline.value = false;
showMoreMenu.value = false;
editorResetKey.value += 1;
}
function clearMediaPreviewError() {
mediaPreviewError.value = "";
}
function markMediaPreviewError() {
const kind = currentFileType.value === "video" ? "视频" : "图片";
mediaPreviewError.value = `无法预览此${kind}`;
}
// ---- file tree helpers ---- // ---- file tree helpers ----
function findEntry(entries: DirEntry[], path: string): DirEntry | null { function findEntry(entries: DirEntry[], path: string): DirEntry | null {
for (const entry of entries) { for (const entry of entries) {
@@ -615,8 +692,16 @@ async function onToggleFolder(path: string) {
} }
async function onOpenFile(path: string) { async function onOpenFile(path: string) {
const fileType = getWorkspaceFileType(path);
if (fileType === "image" || fileType === "video") {
openMediaPreview(path, fileType);
return;
}
try { try {
const fileContent = await invoke<string>("read_file", {path}); const fileContent = await invoke<string>("read_file", {path});
currentFileType.value = "markdown";
resetMediaPreviewState();
setDocumentContent(fileContent); setDocumentContent(fileContent);
currentFilePath.value = path; currentFilePath.value = path;
hasDraftDocument.value = false; hasDraftDocument.value = false;
@@ -628,10 +713,13 @@ async function onOpenFile(path: string) {
function getLatestEditorContent(contentOverride?: string) { function getLatestEditorContent(contentOverride?: string) {
if (typeof contentOverride === "string") return contentOverride; if (typeof contentOverride === "string") return contentOverride;
if (!isMarkdownDocument.value) return content.value;
return markdownEditorRef.value?.flushPendingChanges() ?? content.value; return markdownEditorRef.value?.flushPendingChanges() ?? content.value;
} }
async function doSave(contentOverride?: string) { async function doSave(contentOverride?: string) {
if (!isMarkdownDocument.value) return false;
const contentToSave = getLatestEditorContent(contentOverride); const contentToSave = getLatestEditorContent(contentOverride);
if (!currentFilePath.value) { if (!currentFilePath.value) {
@@ -662,6 +750,8 @@ async function doSave(contentOverride?: string) {
} }
async function doSaveAs() { async function doSaveAs() {
if (!isMarkdownDocument.value) return;
const contentToSave = getLatestEditorContent(); const contentToSave = getLatestEditorContent();
try { try {
@@ -680,6 +770,8 @@ async function doSaveAs() {
} }
function doNew() { function doNew() {
currentFileType.value = "markdown";
resetMediaPreviewState();
setDocumentContent(""); setDocumentContent("");
currentFilePath.value = null; currentFilePath.value = null;
hasDraftDocument.value = true; hasDraftDocument.value = true;
@@ -755,7 +847,7 @@ registerTauriListener("menu-event", (event) => {
> >
<!-- Left: Outline toggle --> <!-- Left: Outline toggle -->
<div <div
v-if="!showStartActions" v-if="!showStartActions && isMarkdownDocument"
class="flex items-center shrink-0" class="flex items-center shrink-0"
data-no-window-drag data-no-window-drag
> >
@@ -787,7 +879,7 @@ registerTauriListener("menu-event", (event) => {
data-no-window-drag data-no-window-drag
> >
<!-- Document mode --> <!-- Document mode -->
<div class="mode-toggle" role="group" aria-label="文档模式"> <div v-if="isMarkdownDocument" class="mode-toggle" role="group" aria-label="文档模式">
<button <button
class="mode-toggle-btn" class="mode-toggle-btn"
:class="{ 'mode-toggle-btn-active': documentMode === 'preview' }" :class="{ 'mode-toggle-btn-active': documentMode === 'preview' }"
@@ -815,7 +907,7 @@ registerTauriListener("menu-event", (event) => {
</div> </div>
<!-- More menu --> <!-- More menu -->
<div class="relative"> <div v-if="isMarkdownDocument" class="relative">
<button <button
class="toolbar-btn" class="toolbar-btn"
title="更多" title="更多"
@@ -875,23 +967,67 @@ registerTauriListener("menu-event", (event) => {
<div class="flex-1 flex min-h-0 overflow-hidden"> <div class="flex-1 flex min-h-0 overflow-hidden">
<Transition name="outline-slide"> <Transition name="outline-slide">
<OutlinePanel <OutlinePanel
v-if="showOutline && !showStartActions" v-if="showOutline && !showStartActions && isMarkdownDocument"
:markdown-content="content" :markdown-content="content"
@heading-click="onHeadingClick" @heading-click="onHeadingClick"
/> />
</Transition> </Transition>
<MarkdownEditor <MarkdownEditor
v-if="!showStartActions" v-if="!showStartActions && isMarkdownDocument"
:key="editorResetKey" :key="editorResetKey"
ref="markdownEditorRef" ref="markdownEditorRef"
:model-value="content" :model-value="content"
:document-mode="documentMode" :document-mode="documentMode"
:file-path="currentFilePath"
placeholder="输入 Markdown 内容... 支持标题粗体斜体代码块等" placeholder="输入 Markdown 内容... 支持标题粗体斜体代码块等"
@update:model-value="onUpdate" @update:model-value="onUpdate"
@save-shortcut="doSave" @save-shortcut="doSave"
class="flex-1 w-full min-w-0" class="flex-1 w-full min-w-0"
/> />
<div
v-else-if="!showStartActions && currentFileType === 'image'"
class="media-preview flex-1 min-w-0"
>
<div class="media-preview-stage">
<img
:key="mediaPreviewKey"
class="media-preview-image"
:src="mediaPreviewSrc"
:alt="getFileName(currentFilePath || '')"
@load="clearMediaPreviewError"
@error="markMediaPreviewError"
/>
<div v-if="mediaPreviewError" class="media-preview-error">
<i class="ri-image-line"></i>
<span>{{ mediaPreviewError }}</span>
</div>
</div>
</div>
<div
v-else-if="!showStartActions && currentFileType === 'video'"
class="media-preview flex-1 min-w-0"
>
<div class="media-preview-stage">
<video
:key="mediaPreviewKey"
class="media-preview-video"
controls
preload="metadata"
@loadeddata="clearMediaPreviewError"
@error="markMediaPreviewError"
>
<source
:src="mediaPreviewSrc"
:type="currentVideoMimeType || undefined"
/>
</video>
<div v-if="mediaPreviewError" class="media-preview-error">
<i class="ri-film-line"></i>
<span>{{ mediaPreviewError }}</span>
</div>
</div>
</div>
<div <div
v-else v-else
class="start-actions flex-1 flex items-center justify-center px-8" class="start-actions flex-1 flex items-center justify-center px-8"
@@ -998,6 +1134,72 @@ registerTauriListener("menu-event", (event) => {
color: var(--app-control-contrast); color: var(--app-control-contrast);
} }
/* ── Media preview ──────────────────────────────────────────────── */
.media-preview {
overflow: auto;
background:
linear-gradient(45deg, color-mix(in srgb, var(--app-border) 46%, transparent) 25%, transparent 25%),
linear-gradient(-45deg, color-mix(in srgb, var(--app-border) 46%, transparent) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--app-border) 46%, transparent) 75%),
linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--app-border) 46%, transparent) 75%),
var(--app-bg);
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
background-size: 20px 20px;
}
.media-preview-stage {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
width: 100%;
padding: 32px;
}
.media-preview-image,
.media-preview-video {
display: block;
max-width: 100%;
max-height: calc(100vh - 104px);
border: 1px solid var(--app-border-strong);
border-radius: 6px;
background: var(--app-surface);
box-shadow: 0 16px 44px color-mix(in srgb, var(--app-shadow) 64%, transparent);
}
.media-preview-image {
object-fit: contain;
}
.media-preview-video {
width: min(100%, 1040px);
/*aspect-ratio: 16 / 9;*/
}
.media-preview-error {
position: absolute;
inset: 32px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
border: 1px solid var(--app-border-strong);
border-radius: 8px;
background: color-mix(in srgb, var(--app-surface) 88%, transparent);
color: var(--app-muted);
font-size: 13px;
text-align: center;
}
.media-preview-error i {
color: var(--app-subtle);
font-size: 28px;
line-height: 1;
}
/* ── Toolbar ───────────────────────────────────────────────────── */ /* ── Toolbar ───────────────────────────────────────────────────── */
.titlebar-drag-surface { .titlebar-drag-surface {

View File

@@ -7,14 +7,24 @@ import {
watch, watch,
type ComponentPublicInstance, type ComponentPublicInstance,
} from "vue"; } from "vue";
import { unified } from "unified"; import { unified, type Plugin, type Transformer } from "unified";
import remarkParse from "remark-parse"; import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype"; import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify"; import rehypeStringify from "rehype-stringify";
import rehypeSanitize from "rehype-sanitize"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { convertFileSrc, isTauri } from "@tauri-apps/api/core";
type DocumentMode = "preview" | "edit" | "mixed"; type DocumentMode = "preview" | "edit" | "mixed";
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif"]);
const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]);
const VIDEO_MIME_TYPES: Record<string, string> = {
mp4: "video/mp4",
webm: "video/webm",
mov: "video/quicktime",
m4v: "video/mp4",
ogv: "video/ogg",
};
interface MarkdownAstNode { interface MarkdownAstNode {
position?: { position?: {
@@ -23,6 +33,20 @@ interface MarkdownAstNode {
}; };
} }
interface MarkdownTreeNode {
type: string;
url?: string;
alt?: string | null;
children?: MarkdownTreeNode[];
data?: Record<string, unknown>;
}
interface MarkdownTree {
type: string;
children?: MarkdownTreeNode[];
data?: Record<string, unknown>;
}
interface MarkdownBlock { interface MarkdownBlock {
id: string; id: string;
source: string; source: string;
@@ -37,12 +61,14 @@ const props = withDefaults(
placeholder?: string; placeholder?: string;
documentMode?: DocumentMode; documentMode?: DocumentMode;
previewOnly?: boolean; previewOnly?: boolean;
filePath?: string | null;
}>(), }>(),
{ {
modelValue: "", modelValue: "",
placeholder: "开始写作...", placeholder: "开始写作...",
documentMode: undefined, documentMode: undefined,
previewOnly: false, previewOnly: false,
filePath: null,
}, },
); );
@@ -51,14 +77,133 @@ const emit = defineEmits<{
"save-shortcut": [value?: string]; "save-shortcut": [value?: string];
}>(); }>();
function getFileExtension(path: string): string {
const fileName = path.split(/[/\\]/).pop() || path;
const dotIndex = fileName.lastIndexOf(".");
if (dotIndex < 0) return "";
return fileName.slice(dotIndex + 1).toLowerCase();
}
function getCurrentFileDirectory() {
if (!props.filePath) return "";
const separatorIndex = Math.max(
props.filePath.lastIndexOf("/"),
props.filePath.lastIndexOf("\\"),
);
if (separatorIndex < 0) return "";
return props.filePath.slice(0, separatorIndex);
}
function isExternalUrl(url: string) {
return /^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith("//") || url.startsWith("#");
}
function normalizeLocalMediaPath(url: string) {
const trimmed = url.trim();
if (!trimmed || isExternalUrl(trimmed)) return trimmed;
if (/^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.startsWith("\\\\")) {
return trimmed;
}
const directory = getCurrentFileDirectory();
if (!directory) return trimmed;
const separator = directory.includes("\\") ? "\\" : "/";
const normalizedRelativePath = trimmed.replace(/[\\/]+/g, separator);
return `${directory}${separator}${normalizedRelativePath}`;
}
function toAssetSrc(path: string) {
if (isExternalUrl(path)) return path;
if (isTauri()) return convertFileSrc(path);
if (/^[A-Za-z]:[\\/]/.test(path)) return `file:///${path.replace(/\\/g, "/")}`;
if (path.startsWith("\\\\")) return `file:${path.replace(/\\/g, "/")}`;
return path;
}
function getVideoProperties(url: string, alt?: string | null) {
const ext = getFileExtension(url);
const properties: Record<string, string | boolean> = {
controls: true,
preload: "metadata",
src: toAssetSrc(normalizeLocalMediaPath(url)),
};
if (alt) properties.title = alt;
if (VIDEO_MIME_TYPES[ext]) properties.type = VIDEO_MIME_TYPES[ext];
return properties;
}
const remarkLocalMedia: Plugin<[], MarkdownTree> = function (): Transformer<MarkdownTree> {
return (tree) => {
visitMediaNodes(tree.children || []);
};
};
function visitMediaNodes(children: MarkdownTreeNode[]) {
for (let index = 0; index < children.length; index += 1) {
const child = children[index];
if (child.children) {
visitMediaNodes(child.children);
}
if (child.type !== "image" || !child.url) continue;
const ext = getFileExtension(child.url);
if (VIDEO_EXTENSIONS.has(ext)) {
child.data = {
...child.data,
hName: "video",
hProperties: getVideoProperties(child.url, child.alt),
hChildren: [],
};
continue;
}
if (!IMAGE_EXTENSIONS.has(ext) || isExternalUrl(child.url)) continue;
child.url = toAssetSrc(normalizeLocalMediaPath(child.url));
}
}
// ── Unified processors ───────────────────────────────────────────── // ── Unified processors ─────────────────────────────────────────────
const mediaSanitizeSchema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
img: [...(defaultSchema.attributes?.img || []), "alt", "title"],
source: [...(defaultSchema.attributes?.source || []), "src", "type"],
video: [
...(defaultSchema.attributes?.video || []),
"controls",
"preload",
"src",
"title",
"type",
],
},
protocols: {
...defaultSchema.protocols,
src: [...(defaultSchema.protocols?.src || []), "asset"],
},
tagNames: [...(defaultSchema.tagNames || []), "video"],
};
/** Full pipeline: markdown → HTML */ /** Full pipeline: markdown → HTML */
const md2html = unified() const md2html = unified()
.use(remarkParse) .use(remarkParse)
.use(remarkGfm) .use(remarkGfm)
.use(remarkLocalMedia)
.use(remarkRehype, { allowDangerousHtml: false }) .use(remarkRehype, { allowDangerousHtml: false })
.use(rehypeSanitize) .use(rehypeSanitize, mediaSanitizeSchema)
.use(rehypeStringify); .use(rehypeStringify);
const mdParser = unified().use(remarkParse).use(remarkGfm); const mdParser = unified().use(remarkParse).use(remarkGfm);