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
17/** A detected type fills in an unknown filename's icon; callers keep any
18 * specific icon already supplied by the filename. */
19export function fileIconPathForMimeType(path: string, mimeType?: string): string {
20 const extension = ICON_EXTENSION_BY_MIME_TYPE[mimeType ?? ""];
21 return extension ? `file.${extension}` : path;
22}
23
24export type FilePreviewKind =
25 | "image"
26 | "video"
27 | "audio"
28 | "pdf"
29 | "html"
30 | "markdown"
31 | "text"
32 | "unsupported";
33
34/** Content classification is identical for captured attachments and workspace references. */
35export function filePreviewKind(file: {
36 readonly name: string;
37 readonly mimeType?: string;
38}): FilePreviewKind {
39 const mime = file.mimeType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
40 const name = file.name.toLowerCase();
41 const extension = name.slice(name.lastIndexOf("."));
42 const generic = !mime || mime === "application/octet-stream" || mime === "text/plain";
43 if (mime === "application/pdf") return "pdf";
44 if (mime === "text/html") return "html";
45 if (mime === "text/markdown" || mime === "text/x-markdown") return "markdown";
46 if (mime.startsWith("image/")) return "image";
47 if (mime.startsWith("video/")) return "video";
48 if (mime.startsWith("audio/")) return "audio";
49 if (generic) {
50 if (extension === ".pdf") return "pdf";
51 if (/^\.html?$/.test(extension)) return "html";
52 if (/^\.(md|markdown|mdown|mkd|mdx)$/.test(extension)) return "markdown";
53 const media = mediaMimeTypeFromExtension(extension);
54 if (media?.startsWith("image/")) return "image";
55 if (media?.startsWith("video/")) return "video";
56 if (audioMimeTypeFromExtension(extension) !== null) return "audio";
57 if (
58 /^\.(txt|log|json|jsonc|jsonl|ndjson|yaml|yml|toml|ini|conf|config|env|csv|tsv|xml|css|scss|sass|less|js|jsx|mjs|cjs|ts|tsx|mts|cts|py|pyi|rb|go|rs|swift|kt|kts|java|c|h|cc|cpp|hpp|cs|php|sh|bash|zsh|fish|sql|graphql|gql|vue|svelte|r|lua|ex|exs|erl|hs|clj|dart|diff|patch|lock|properties|gradle)$/.test(
59 extension,
60 ) ||
61 /^(dockerfile|makefile|gemfile|rakefile|license|readme|\.gitignore|\.gitattributes|\.editorconfig|\.env)(\.|$)/i.test(
62 name.split(/[\\/]/).at(-1) ?? "",
63 )
64 )
65 return "text";
66 }
67 if (
68 mime.startsWith("text/") ||
69 /^(application\/(json|.*\+json|xml|.*\+xml|javascript|x-javascript|yaml|x-yaml|toml|sql))$/.test(
70 mime,
71 )
72 )
73 return "text";
74 return "unsupported";
75}
76
77export const FILE_TEXT_PREVIEW_MAX_BYTES = 1024 * 1024;
78
79/** Reject binary data rather than displaying replacement characters as a document. */
80export function decodeFilePreviewText(bytes: Uint8Array, truncated = false) {
81 const bounded = bytes.subarray(0, FILE_TEXT_PREVIEW_MAX_BYTES);
82 if (bounded.some((byte) => byte === 0))
83 throw new Error("This file contains binary data and cannot be shown as text.");
84 const decoder = new TextDecoder("utf-8", { fatal: true });
85 try {
86 return {
87 text: decoder.decode(bounded, { stream: truncated || bytes.length > bounded.length }),
88 truncated: truncated || bytes.length > bounded.length,
89 };
90 } catch {
91 throw new Error("This file is not UTF-8 text. Open it in another app to view its contents.");
92 }
93}
94
95export const WORKSPACE_BROWSER_PREVIEW_EXTENSIONS = [".htm", ".html", ".pdf"] as const;
96
97export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [
98 ".avif",
99 ".gif",
100 ".ico",
101 ".jpeg",
102 ".jpg",
103 ".png",
104 ".svg",
105 ".webp",
106] as const;
107
108const IMAGE_MIME_TYPE_BY_EXTENSION = new Map([
109 [".avif", "image/avif"],
110 [".gif", "image/gif"],
111 [".ico", "image/x-icon"],
112 [".jpeg", "image/jpeg"],
113 [".jpg", "image/jpeg"],
114 [".png", "image/png"],
115 [".svg", "image/svg+xml"],
116 [".webp", "image/webp"],
117]);
118
119const BROWSER_MIME_TYPE_BY_EXTENSION = new Map([
120 [".htm", "text/html"],
121 [".html", "text/html"],
122 [".pdf", "application/pdf"],
123]);
124
125const AUDIO_MIME_TYPE_BY_EXTENSION = new Map([
126 [".mp3", "audio/mpeg"],
127 [".wav", "audio/wav"],
128 [".ogg", "audio/ogg"],
129 [".oga", "audio/ogg"],
130 [".flac", "audio/flac"],
131 [".aac", "audio/aac"],
132 [".m4a", "audio/mp4"],
133 [".opus", "audio/ogg"],
134 [".aiff", "audio/aiff"],
135]);
136
137/** Audio a player can request inline; the server serves these with byte ranges like video. */
138export function audioMimeTypeFromExtension(extension: string): string | null {
139 if (!/^\.[a-z0-9]+$/i.test(extension)) return null;
140 return AUDIO_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ?? null;
141}
142
143/** Classifies a literal filesystem extension, without URL decoding or suffix removal. */
144export function mediaMimeTypeFromExtension(extension: string): string | null {
145 if (!/^\.[a-z0-9]+$/i.test(extension)) return null;
146 return (
147 IMAGE_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ??
148 videoMimeType({ name: `media${extension}`, mimeType: "" })
149 );
150}
151
152/** Files the server serves in place from anywhere on its host: media, audio and browser documents. */
153export function hostPreviewMimeTypeFromExtension(extension: string): string | null {
154 if (!/^\.[a-z0-9]+$/i.test(extension)) return null;
155 return (
156 mediaMimeTypeFromExtension(extension) ??
157 audioMimeTypeFromExtension(extension) ??
158 BROWSER_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ??
159 null
160 );
161}
162
163/** Classifies an authored media path or URL. Filesystem validation uses the literal extension. */
164export function mediaMimeType(path: string): string | null {
165 const trimmed = path.trim();
166 const source = trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
167 const dataMimeType = /^data:((?:image|video)\/[\w.+-]+)[;,]/i.exec(source)?.[1];
168 if (dataMimeType) return dataMimeType.toLowerCase();
169
170 let sourcePath = source.split(/[?#]/, 1)[0] ?? "";
171 if (/^(?:https?:|file:|\/\/)/i.test(source)) {
172 try {
173 sourcePath = new URL(source, "https://media.invalid").pathname;
174 } catch {
175 return null;
176 }
177 }
178 try {
179 sourcePath = decodeURIComponent(sourcePath);
180 } catch {
181 // A literal percent character is valid in a filename.
182 }
183 const basename = sourcePath.split(/[\\/]/).at(-1) ?? "";
184 const extensionIndex = basename.lastIndexOf(".");
185 return extensionIndex < 0 ? null : mediaMimeTypeFromExtension(basename.slice(extensionIndex));
186}
187
188export function mediaKindFromPath(path: string): "image" | "video" | null {
189 const mimeType = mediaMimeType(path);
190 if (mimeType === null) return null;
191 return mimeType.startsWith("video/") ? "video" : "image";
192}
193
194function hasPreviewExtension(path: string, extensions: ReadonlyArray<string>): boolean {
195 const literalPath = path.toLowerCase();
196 return extensions.some((extension) => literalPath.endsWith(extension));
197}
198
199export function isWorkspaceBrowserPreviewPath(path: string): boolean {
200 return hasPreviewExtension(path, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS);
201}
202
203export function isWorkspaceImagePreviewPath(path: string): boolean {
204 return hasPreviewExtension(path, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS);
205}
206
207/** File viewers receive literal filesystem paths, not Markdown URLs. */
208export function isWorkspaceVideoPreviewPath(path: string): boolean {
209 return videoMimeType({ name: path, mimeType: "" }) !== null;
210}
211
212export function isWorkspaceAudioPreviewPath(path: string): boolean {
213 const extensionIndex = path.lastIndexOf(".");
214 return extensionIndex >= 0 && audioMimeTypeFromExtension(path.slice(extensionIndex)) !== null;
215}
216
217export function isWorkspacePreviewEntryPath(path: string): boolean {
218 return isWorkspaceBrowserPreviewPath(path) || isWorkspaceImagePreviewPath(path);
219}