1import { videoMimeType } from "./video.ts";
2
3const ICON_EXTENSION_BY_MIME_TYPE: Readonly<Record<string, string>> = {
4 "image/png": "png",
5 "image/jpeg": "jpg",
6 "image/gif": "gif",
7 "image/webp": "webp",
8 "application/pdf": "pdf",
9 "application/zip": "zip",
10 "application/vnd.sqlite3": "sqlite",
11 "text/x-python": "py",
12 "text/javascript": "js",
13 "text/x-ruby": "rb",
14 "text/x-shellscript": "sh",
15};
16
17export function fileIconPathForMimeType(path: string, mimeType?: string): string {
18 const extension = ICON_EXTENSION_BY_MIME_TYPE[mimeType ?? ""];
19 return extension ? `file.${extension}` : path;
20}
21
22export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [
23 ".avif",
24 ".bmp",
25 ".gif",
26 ".ico",
27 ".heic",
28 ".heif",
29 ".jpeg",
30 ".jpg",
31 ".png",
32 ".svg",
33 ".tif",
34 ".tiff",
35 ".webp",
36] as const;
37
38const IMAGE_MIME_TYPE_BY_EXTENSION = new Map([
39 [".avif", "image/avif"],
40 [".bmp", "image/bmp"],
41 [".heic", "image/heic"],
42 [".heif", "image/heif"],
43 [".gif", "image/gif"],
44 [".ico", "image/x-icon"],
45 [".jpeg", "image/jpeg"],
46 [".jpg", "image/jpeg"],
47 [".png", "image/png"],
48 [".svg", "image/svg+xml"],
49 [".tif", "image/tiff"],
50 [".tiff", "image/tiff"],
51 [".webp", "image/webp"],
52]);
53
54const AUDIO_MIME_TYPE_BY_EXTENSION = new Map([
55 [".mp3", "audio/mpeg"],
56 [".wav", "audio/wav"],
57 [".ogg", "audio/ogg"],
58 [".oga", "audio/ogg"],
59 [".flac", "audio/flac"],
60 [".aac", "audio/aac"],
61 [".m4a", "audio/mp4"],
62 [".opus", "audio/ogg"],
63 [".aiff", "audio/aiff"],
64 [".webm-audio", "audio/webm"],
65 [".weba", "audio/webm"],
66]);
67
68export function audioMimeTypeFromExtension(extension: string): string | null {
69 if (!/^\.[a-z0-9-]+$/i.test(extension)) return null;
70 return AUDIO_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ?? null;
71}
72
73/** Classifies a literal filesystem extension, without URL decoding or suffix removal. */
74export function mediaMimeTypeFromExtension(extension: string): string | null {
75 if (!/^\.[a-z0-9]+$/i.test(extension)) return null;
76 return (
77 IMAGE_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ??
78 videoMimeType({ name: `media${extension}`, mimeType: "" })
79 );
80}
81
82/** Classifies an authored media path or URL. Filesystem validation uses the literal extension. */
83export function mediaMimeType(path: string): string | null {
84 const trimmed = path.trim();
85 const source = trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
86 const dataMimeType = /^data:((?:image|video|audio)\/[\w.+-]+)[;,]/i.exec(source)?.[1];
87 if (dataMimeType) return dataMimeType.toLowerCase();
88
89 let sourcePath = source.split(/[?#]/, 1)[0] ?? "";
90 if (/^(?:https?:|file:|\/\/)/i.test(source)) {
91 try {
92 sourcePath = new URL(source, "https://media.invalid").pathname;
93 } catch {
94 return null;
95 }
96 }
97 try {
98 sourcePath = decodeURIComponent(sourcePath);
99 } catch {}
100 const basename = sourcePath.split(/[\\/]/).at(-1) ?? "";
101 const extensionIndex = basename.lastIndexOf(".");
102 return extensionIndex < 0
103 ? null
104 : (mediaMimeTypeFromExtension(basename.slice(extensionIndex)) ??
105 audioMimeTypeFromExtension(basename.slice(extensionIndex)));
106}
107
108export function mediaKindFromPath(path: string): "image" | "video" | "audio" | null {
109 const mimeType = mediaMimeType(path);
110 if (mimeType === null) return null;
111 return mimeType.startsWith("audio/")
112 ? "audio"
113 : mimeType.startsWith("video/")
114 ? "video"
115 : "image";
116}
117
118export function isWorkspaceImagePreviewPath(path: string): boolean {
119 const literalPath = path.toLowerCase();
120 return WORKSPACE_IMAGE_PREVIEW_EXTENSIONS.some((extension) => literalPath.endsWith(extension));
121}
122
123export function isWorkspaceAudioPreviewPath(path: string): boolean {
124 const extensionIndex = path.lastIndexOf(".");
125 return extensionIndex >= 0 && audioMimeTypeFromExtension(path.slice(extensionIndex)) !== null;
126}