feat: file explorer
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 50s

This commit is contained in:
domrichardson
2026-03-25 11:27:15 +00:00
parent b253bec9fc
commit 168f5eac83
16 changed files with 1297 additions and 20 deletions

View File

@@ -174,11 +174,12 @@
:note="selectedNote"
:category-options="categoryOptions"
:can-delete="canDeleteNotes"
:space-id="currentSpace?.id"
@save="updateNote"
@delete="deleteNote"
@cancel="cancelEditingNote"
/>
<NoteViewer v-else-if="selectedNote" :note="selectedNote" :category-options="categoryOptions" />
<NoteViewer v-else-if="selectedNote" :note="selectedNote" :category-options="categoryOptions" :space-id="currentSpace?.id" />
<NoteList
v-else
:notes="displayedNotes"

View File

@@ -0,0 +1,331 @@
<template>
<div
class="file-explorer d-flex flex-column border rounded"
style="min-height: 300px"
@dragover.prevent="dragOver = true"
@dragleave="dragOver = false"
@drop.prevent="handleDrop"
:class="{ 'drag-active': dragOver }"
>
<!-- Breadcrumb toolbar -->
<div class="file-explorer-header px-2 py-1 border-bottom bg-light d-flex align-items-center gap-1 flex-wrap">
<i class="mdi mdi-folder-network-outline text-muted me-1" aria-hidden="true"></i>
<button class="btn btn-link btn-sm p-0 text-decoration-none text-dark" @click="navigateTo('')">Space Files</button>
<template v-for="(seg, idx) in breadcrumbs" :key="idx">
<span class="text-muted">/</span>
<button class="btn btn-link btn-sm p-0 text-decoration-none text-dark" @click="navigateTo(seg.prefix)">{{ seg.name }}</button>
</template>
<div class="ms-auto d-flex gap-1">
<button class="btn btn-sm btn-outline-secondary py-0 px-1" title="Upload files" @click="fileInputRef.click()">
<i class="mdi mdi-upload" aria-hidden="true"></i>
</button>
<button class="btn btn-sm btn-outline-secondary py-0 px-1" title="New folder" @click="showNewFolderInput = !showNewFolderInput">
<i class="mdi mdi-folder-plus-outline" aria-hidden="true"></i>
</button>
<button class="btn btn-sm btn-link p-0 text-muted" title="Refresh" @click="loadFiles">
<i class="mdi mdi-refresh" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- New folder input -->
<div v-if="showNewFolderInput" class="px-2 py-1 border-bottom bg-white d-flex gap-1">
<input
ref="newFolderInputRef"
v-model="newFolderName"
type="text"
class="form-control form-control-sm"
placeholder="Folder name"
@keyup.enter="createFolder"
@keyup.esc="showNewFolderInput = false"
/>
<button class="btn btn-sm btn-primary" @click="createFolder">Create</button>
<button class="btn btn-sm btn-secondary" @click="showNewFolderInput = false">Cancel</button>
</div>
<!-- Upload progress -->
<div v-if="uploading" class="px-2 py-1 bg-light border-bottom">
<div class="d-flex align-items-center gap-2">
<div class="progress flex-grow-1" style="height: 6px">
<div class="progress-bar progress-bar-striped progress-bar-animated" :style="{ width: uploadProgress + '%' }"></div>
</div>
<span class="text-muted" style="font-size: 0.7rem">{{ uploadProgress }}%</span>
</div>
</div>
<!-- Error message -->
<div v-if="error" class="alert alert-danger alert-sm m-1 p-1 small mb-0" role="alert">
<i class="mdi mdi-alert-circle-outline me-1" aria-hidden="true"></i>{{ error }}
<button type="button" class="btn-close float-end" style="font-size: 0.6rem" @click="error = ''"></button>
</div>
<!-- Loading / empty -->
<div v-if="loading" class="p-3 text-muted text-center small flex-grow-1"><i class="mdi mdi-loading mdi-spin me-1" aria-hidden="true"></i> Loading...</div>
<div v-else-if="!error && objects.length === 0" class="p-3 text-muted text-center small flex-grow-1">
<i class="mdi mdi-cloud-upload-outline d-block mb-1" style="font-size: 1.5rem" aria-hidden="true"></i>
Drop files here or click Upload
</div>
<!-- File list -->
<div v-else class="file-list flex-grow-1 overflow-auto">
<div
v-for="obj in objects"
:key="obj.key"
class="file-item d-flex align-items-center gap-1 px-2 py-1"
:title="obj.is_folder ? 'Open folder' : 'Insert into note'"
@click="handleClick(obj)"
>
<i :class="fileIcon(obj)" style="font-size: 1rem; width: 1.1rem; flex-shrink: 0" aria-hidden="true"></i>
<span class="flex-grow-1 text-truncate" style="font-size: 0.82rem">{{ displayName(obj) }}</span>
<span v-if="!obj.is_folder && obj.size > 0" class="text-muted flex-shrink-0" style="font-size: 0.68rem">{{ formatSize(obj.size) }}</span>
<button class="btn-delete btn btn-sm btn-link p-0 text-danger ms-1" :title="obj.is_folder ? 'Delete folder' : 'Delete file'" @click.stop="deleteItem(obj)">
<i class="mdi mdi-trash-can-outline" style="font-size: 0.85rem" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- Hidden file input -->
<input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFilePick" />
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from "vue";
import apiClient from "../services/apiClient";
const props = defineProps({
spaceId: {
type: String,
required: true,
},
modelValue: {
type: String,
default: "",
},
});
const emit = defineEmits(["insert", "update:modelValue"]);
const objects = ref([]);
const loading = ref(false);
const error = ref("");
const currentPrefix = ref(props.modelValue || "");
const dragOver = ref(false);
const uploading = ref(false);
const uploadProgress = ref(0);
const showNewFolderInput = ref(false);
const newFolderName = ref("");
const fileInputRef = ref(null);
const newFolderInputRef = ref(null);
const breadcrumbs = computed(() => {
if (!currentPrefix.value) return [];
const parts = currentPrefix.value.replace(/\/$/, "").split("/").filter(Boolean);
return parts.map((name, i) => ({
name,
prefix: parts.slice(0, i + 1).join("/"),
}));
});
const loadFiles = async () => {
if (!props.spaceId) return;
loading.value = true;
error.value = "";
try {
const res = await apiClient.get(`/api/v1/spaces/${props.spaceId}/files/list`, {
params: { prefix: currentPrefix.value },
});
objects.value = res.data.objects || [];
} catch (e) {
error.value = e.response?.data || "Failed to load files";
} finally {
loading.value = false;
}
};
const navigateTo = (prefix) => {
currentPrefix.value = prefix;
emit("update:modelValue", prefix);
loadFiles();
};
const handleClick = (obj) => {
if (obj.is_folder) {
navigateTo(obj.key.replace(/\/$/, ""));
return;
}
const url = `/api/v1/spaces/${props.spaceId}/files/object?key=${encodeURIComponent(obj.key)}`;
const name = displayName(obj);
const ext = name.split(".").pop().toLowerCase();
const imageExts = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif"];
const snippet = imageExts.includes(ext) ? `![${name}](${url})` : `[${name}](${url})`;
emit("insert", snippet);
};
const handleFilePick = (e) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) uploadFiles(files);
e.target.value = "";
};
const handleDrop = (e) => {
dragOver.value = false;
const files = Array.from(e.dataTransfer?.files || []);
if (files.length > 0) uploadFiles(files);
};
const uploadFiles = async (files) => {
if (!props.spaceId || files.length === 0) return;
uploading.value = true;
uploadProgress.value = 0;
error.value = "";
const form = new FormData();
form.append("path", currentPrefix.value);
for (const f of files) form.append("files", f);
try {
await apiClient.post(`/api/v1/spaces/${props.spaceId}/files/upload`, form, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: (e) => {
uploadProgress.value = e.total ? Math.round((e.loaded * 100) / e.total) : 50;
},
});
await loadFiles();
} catch (e) {
error.value = e.response?.data || "Upload failed";
} finally {
uploading.value = false;
uploadProgress.value = 0;
}
};
const createFolder = async () => {
const name = newFolderName.value.trim();
if (!name || !props.spaceId) return;
const path = currentPrefix.value ? `${currentPrefix.value}/${name}` : name;
error.value = "";
try {
await apiClient.post(`/api/v1/spaces/${props.spaceId}/files/folder`, { path });
newFolderName.value = "";
showNewFolderInput.value = false;
await loadFiles();
} catch (e) {
error.value = e.response?.data || "Failed to create folder";
}
};
const deleteItem = async (obj) => {
const label = displayName(obj);
if (!confirm(`Delete "${label}"?${obj.is_folder ? "\n\nThis will delete all files inside the folder." : ""}`)) return;
error.value = "";
try {
if (obj.is_folder) {
const prefix = obj.key.replace(/\/$/, "");
await apiClient.delete(`/api/v1/spaces/${props.spaceId}/files/folder`, { params: { prefix } });
} else {
await apiClient.delete(`/api/v1/spaces/${props.spaceId}/files/object`, { params: { key: obj.key } });
}
await loadFiles();
} catch (e) {
error.value = e.response?.data || "Delete failed";
}
};
const displayName = (obj) => {
const key = obj.is_folder ? obj.key.replace(/\/$/, "") : obj.key;
return key.split("/").pop() || key;
};
const fileIcon = (obj) => {
if (obj.is_folder) return "mdi mdi-folder text-warning";
const ext = displayName(obj).split(".").pop().toLowerCase();
if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif"].includes(ext)) return "mdi mdi-file-image text-info";
if (["pdf"].includes(ext)) return "mdi mdi-file-pdf-box text-danger";
if (["doc", "docx", "odt"].includes(ext)) return "mdi mdi-file-word text-primary";
if (["xls", "xlsx", "ods"].includes(ext)) return "mdi mdi-file-excel text-success";
if (["zip", "tar", "gz", "rar", "7z"].includes(ext)) return "mdi mdi-folder-zip text-secondary";
if (["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) return "mdi mdi-file-video";
if (["mp3", "wav", "ogg", "flac"].includes(ext)) return "mdi mdi-file-music";
if (["js", "ts", "py", "go", "java", "c", "cpp", "rs", "html", "css", "json", "yaml", "yml", "sh"].includes(ext)) return "mdi mdi-file-code text-success";
return "mdi mdi-file-outline text-muted";
};
const formatSize = (bytes) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
};
// Load on mount and when spaceId or prefix changes from parent
watch(
() => props.spaceId,
(v) => {
if (v) loadFiles();
},
{ immediate: true },
);
watch(
() => props.modelValue,
(val) => {
if (val !== currentPrefix.value) {
currentPrefix.value = val || "";
loadFiles();
}
},
);
watch(showNewFolderInput, async (v) => {
if (v) {
await nextTick();
newFolderInputRef.value?.focus();
}
});
</script>
<style scoped>
.file-explorer {
background: #fff;
overflow: hidden;
}
.file-explorer-header {
font-size: 0.8rem;
min-height: 36px;
}
.file-list {
max-height: 480px;
}
.file-item {
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.1s;
color: #333;
line-height: 1.3;
}
.file-item:last-child {
border-bottom: none;
}
.file-item:hover {
background-color: #f0f4ff;
}
.drag-active {
outline: 2px dashed #0d6efd;
outline-offset: -2px;
}
.btn-delete {
opacity: 0;
transition: opacity 0.1s;
}
.file-item:hover .btn-delete {
opacity: 1;
}
</style>

View File

@@ -4,6 +4,16 @@
<button class="btn btn-sm btn-primary" @click="saveNote">Save</button>
<button v-if="canDelete" class="btn btn-sm btn-danger ms-2" @click="confirmDelete">Delete</button>
<button class="btn btn-sm btn-outline-secondary ms-2" @click="emit('cancel')">Cancel</button>
<button
v-if="fileExplorerEnabled"
class="btn btn-sm ms-2"
:class="showFileExplorer ? 'btn-secondary' : 'btn-outline-secondary'"
:title="showFileExplorer ? 'Hide file explorer' : 'Browse & insert files'"
@click="showFileExplorer = !showFileExplorer"
>
<i class="mdi mdi-folder-open-outline me-1" aria-hidden="true"></i>
Files
</button>
<span class="save-status ms-auto" :class="saveState">{{ saveStatusLabel }}</span>
</div>
@@ -16,15 +26,19 @@
</div>
<div class="row">
<div class="col-12 col-md-6">
<textarea v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
<div :class="showFileExplorer ? 'col-12 col-md-5' : 'col-12 col-md-6'">
<textarea ref="contentTextareaRef" v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
</div>
<div class="col-12 col-md-6 mt-3 mt-md-0">
<div :class="showFileExplorer ? 'col-12 col-md-4 mt-3 mt-md-0' : 'col-12 col-md-6 mt-3 mt-md-0'">
<div class="preview-pane border rounded p-3">
<div v-html="renderedMarkdown"></div>
</div>
</div>
<div v-if="showFileExplorer" class="col-12 col-md-3 mt-3 mt-md-0">
<FileExplorer v-model="fileExplorerPrefix" :space-id="spaceId" @insert="insertAtCursor" />
</div>
</div>
<div class="mt-3">
@@ -73,10 +87,13 @@
</template>
<script setup>
import { ref, computed, watch, onBeforeUnmount, onMounted } from "vue";
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { useSettingsStore } from "../stores/settingsStore";
import { useAuthStore } from "../stores/authStore";
import { preprocessMarkdown } from "../utils/markdown.js";
import FileExplorer from "./FileExplorer.vue";
const props = defineProps({
note: {
@@ -91,13 +108,22 @@ const props = defineProps({
type: Boolean,
default: true,
},
spaceId: {
type: String,
default: "",
},
});
const emit = defineEmits(["save", "delete", "cancel"]);
const settingsStore = useSettingsStore();
const authStore = useAuthStore();
const publicSharingEnabled = ref(true);
const fileExplorerEnabled = computed(() => settingsStore.fileExplorerEnabled);
const editingNote = ref({ ...props.note });
const contentTextareaRef = ref(null);
const showFileExplorer = ref(false);
const fileExplorerPrefix = ref("");
const tagsInput = ref(props.note.tags?.join(", ") || "");
const passwordAction = ref("keep");
const notePassword = ref("");
@@ -106,8 +132,18 @@ const saveState = ref("saved");
const saveStateTimeout = ref(null);
const renderedMarkdown = computed(() => {
const html = marked.parse(editingNote.value.content || "");
return DOMPurify.sanitize(html);
const html = marked.parse(preprocessMarkdown(editingNote.value.content || ""));
let clean = DOMPurify.sanitize(html);
// Inject access token into space file API URLs so images render without a separate JS fetch
const token = authStore.accessToken;
if (token && props.spaceId) {
clean = clean.replace(/((?:src|href)=["'])([^"']*\/api\/v1\/spaces\/[^"']*\/files\/object[^"']*)(["'])/g, (_, attr, url, quote) => {
if (url.includes("token=")) return attr + url + quote;
const sep = url.includes("?") ? "&" : "?";
return `${attr}${url}${sep}token=${encodeURIComponent(token)}${quote}`;
});
}
return clean;
});
const saveStatusLabel = computed(() => {
@@ -197,6 +233,27 @@ const confirmDelete = () => {
}
};
/** Insert markdown snippet at the textarea cursor position. */
const insertAtCursor = (snippet) => {
const textarea = contentTextareaRef.value;
if (!textarea) {
editingNote.value.content = (editingNote.value.content || "") + snippet;
autoSave();
return;
}
const start = textarea.selectionStart ?? editingNote.value.content?.length ?? 0;
const end = textarea.selectionEnd ?? start;
const before = (editingNote.value.content || "").substring(0, start);
const after = (editingNote.value.content || "").substring(end);
editingNote.value.content = before + snippet + after;
autoSave();
nextTick(() => {
const newPos = start + snippet.length;
textarea.setSelectionRange(newPos, newPos);
textarea.focus();
});
};
onBeforeUnmount(() => {
clearTimeout(saveTimeout.value);
clearTimeout(saveStateTimeout.value);

View File

@@ -32,6 +32,8 @@
import { computed } from "vue";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { useAuthStore } from "../stores/authStore";
import { preprocessMarkdown } from "../utils/markdown.js";
const props = defineProps({
note: {
@@ -42,11 +44,26 @@ const props = defineProps({
type: Array,
default: () => [],
},
spaceId: {
type: String,
default: "",
},
});
const authStore = useAuthStore();
const renderedMarkdown = computed(() => {
const html = marked.parse(props.note.content || "");
return DOMPurify.sanitize(html);
const html = marked.parse(preprocessMarkdown(props.note.content || ""));
let clean = DOMPurify.sanitize(html);
const token = authStore.accessToken;
if (token && props.spaceId) {
clean = clean.replace(/((?:src|href)=["'])([^"']*\/api\/v1\/spaces\/[^"']*\/files\/object[^"']*)(["'])/g, (_, attr, url, quote) => {
if (url.includes("token=")) return attr + url + quote;
const sep = url.includes("?") ? "&" : "?";
return `${attr}${url}${sep}token=${encodeURIComponent(token)}${quote}`;
});
}
return clean;
});
const categoryLabel = computed(() => {

View File

@@ -254,6 +254,49 @@
</div>
</div>
<div class="feature-flag-item border rounded p-3">
<div class="d-flex justify-content-between align-items-center mb-0" :class="{ 'mb-3': featureFlagsForm.file_explorer_enabled }">
<div>
<div class="fw-semibold">Enable File Explorer</div>
<div class="small text-muted">Allow users to browse and insert files from an S3 bucket directly into notes.</div>
</div>
<div class="form-check form-switch m-0">
<input id="flag-file-explorer" v-model="featureFlagsForm.file_explorer_enabled" class="form-check-input" type="checkbox" />
</div>
</div>
<div v-if="featureFlagsForm.file_explorer_enabled" class="row g-2 mt-1">
<div class="col-md-6">
<label class="form-label small mb-1">S3 Endpoint URL</label>
<input v-model="featureFlagsForm.s3_endpoint" type="url" class="form-control form-control-sm" placeholder="https://s3.amazonaws.com or custom endpoint" />
</div>
<div class="col-md-6">
<label class="form-label small mb-1">Bucket Name</label>
<input v-model="featureFlagsForm.s3_bucket" type="text" class="form-control form-control-sm" placeholder="my-bucket" />
</div>
<div class="col-md-4">
<label class="form-label small mb-1">Region</label>
<input v-model="featureFlagsForm.s3_region" type="text" class="form-control form-control-sm" placeholder="us-east-1" />
</div>
<div class="col-md-4">
<label class="form-label small mb-1">Access Key</label>
<input v-model="featureFlagsForm.s3_access_key" type="text" class="form-control form-control-sm" autocomplete="off" />
</div>
<div class="col-md-4">
<label class="form-label small mb-1">Secret Key</label>
<input
v-model="featureFlagsForm.s3_secret_key"
type="password"
class="form-control form-control-sm"
:placeholder="featureFlagsForm.s3_secret_key_set ? 'Leave blank to keep current secret' : 'Enter secret key'"
autocomplete="new-password"
/>
<div v-if="featureFlagsForm.s3_secret_key_set && !featureFlagsForm.s3_secret_key" class="small text-success mt-1">
<i class="mdi mdi-check-circle-outline" aria-hidden="true"></i> Secret key is set
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-end">
<button class="btn btn-primary" :disabled="savingFeatureFlags" @click="saveFeatureFlags">
{{ savingFeatureFlags ? "Saving..." : "Save Feature Flags" }}
@@ -364,6 +407,13 @@ const featureFlagsForm = ref({
registration_enabled: true,
provider_login_enabled: true,
public_sharing_enabled: true,
file_explorer_enabled: false,
s3_endpoint: "",
s3_bucket: "",
s3_region: "",
s3_access_key: "",
s3_secret_key: "",
s3_secret_key_set: false,
});
const clearMessages = () => {
@@ -584,6 +634,13 @@ const loadFeatureFlags = async () => {
registration_enabled: !!res.data.registration_enabled,
provider_login_enabled: !!res.data.provider_login_enabled,
public_sharing_enabled: !!res.data.public_sharing_enabled,
file_explorer_enabled: !!res.data.file_explorer_enabled,
s3_endpoint: res.data.s3_endpoint || "",
s3_bucket: res.data.s3_bucket || "",
s3_region: res.data.s3_region || "",
s3_access_key: res.data.s3_access_key || "",
s3_secret_key: "", // never pre-fill the secret
s3_secret_key_set: !!res.data.s3_secret_key_set,
};
} catch (e) {
error.value = e.response?.data || "Failed to load feature flags.";
@@ -596,11 +653,28 @@ const saveFeatureFlags = async () => {
savingFeatureFlags.value = true;
clearMessages();
try {
const res = await apiClient.put("/api/v1/admin/feature-flags", featureFlagsForm.value);
const res = await apiClient.put("/api/v1/admin/feature-flags", {
registration_enabled: featureFlagsForm.value.registration_enabled,
provider_login_enabled: featureFlagsForm.value.provider_login_enabled,
public_sharing_enabled: featureFlagsForm.value.public_sharing_enabled,
file_explorer_enabled: featureFlagsForm.value.file_explorer_enabled,
s3_endpoint: featureFlagsForm.value.s3_endpoint,
s3_bucket: featureFlagsForm.value.s3_bucket,
s3_region: featureFlagsForm.value.s3_region,
s3_access_key: featureFlagsForm.value.s3_access_key,
s3_secret_key: featureFlagsForm.value.s3_secret_key, // blank = keep existing
});
featureFlagsForm.value = {
registration_enabled: !!res.data.registration_enabled,
provider_login_enabled: !!res.data.provider_login_enabled,
public_sharing_enabled: !!res.data.public_sharing_enabled,
file_explorer_enabled: !!res.data.file_explorer_enabled,
s3_endpoint: res.data.s3_endpoint || "",
s3_bucket: res.data.s3_bucket || "",
s3_region: res.data.s3_region || "",
s3_access_key: res.data.s3_access_key || "",
s3_secret_key: "",
s3_secret_key_set: !!res.data.s3_secret_key_set,
};
successMessage.value = "Feature flags updated.";
} catch (e) {

View File

@@ -6,6 +6,7 @@ const DEFAULT_FLAGS = {
registration_enabled: true,
provider_login_enabled: true,
public_sharing_enabled: true,
file_explorer_enabled: false,
};
export const useSettingsStore = defineStore("settings", () => {
@@ -15,6 +16,7 @@ export const useSettingsStore = defineStore("settings", () => {
const registrationEnabled = computed(() => !!featureFlags.value.registration_enabled);
const providerLoginEnabled = computed(() => !!featureFlags.value.provider_login_enabled);
const publicSharingEnabled = computed(() => !!featureFlags.value.public_sharing_enabled);
const fileExplorerEnabled = computed(() => !!featureFlags.value.file_explorer_enabled);
const loadFeatureFlags = async (force = false) => {
if (flagsLoaded.value && !force) {
@@ -42,6 +44,7 @@ export const useSettingsStore = defineStore("settings", () => {
registrationEnabled,
providerLoginEnabled,
publicSharingEnabled,
fileExplorerEnabled,
loadFeatureFlags,
};
});

View File

@@ -0,0 +1,29 @@
/**
* Preprocesses markdown content to support extended image size syntax:
*
* ![alt](url =WIDTHxHEIGHT)
* ![alt](url "title" =WIDTHxHEIGHT)
*
* WIDTH and HEIGHT are pixel values or percentages (e.g. 50%).
* Either can be omitted:
* =200x → width 200 only
* =x150 → height 150 only
*
* The syntax is transformed into a plain <img> tag before passing to marked
* because CommonMark terminates the link destination at whitespace, making it
* impossible for marked to see the size spec otherwise.
*/
export function preprocessMarkdown(content) {
if (!content) return content;
return content.replace(
/!\[([^\]]*)\]\(([^\s)"]+)(?:\s+"([^"]*)")?\s+=(\d*%?)[xX](\d*%?)\)/gi,
(_, alt, url, title, w, h) => {
const safeAlt = alt.replace(/"/g, "&quot;");
let attrs = `src="${url}" alt="${safeAlt}"`;
if (title) attrs += ` title="${title.replace(/"/g, "&quot;")}"`;
if (w) attrs += ` width="${w}"`;
if (h) attrs += ` height="${h}"`;
return `<img ${attrs}>`;
},
);
}