| 1 | import { videoMimeType } from "./video.ts"; |
| 2 | |
| 3 | const 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. */ |
| 19 | export function fileIconPathForMimeType(path: string, mimeType?: string): string { |
| 20 | const extension = ICON_EXTENSION_BY_MIME_TYPE[mimeType ?? ""]; |
| 21 | return extension ? `file.${extension}` : path; |
| 22 | } |
| 23 | |
| 24 | export 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. */ |
| 35 | export 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 | |
| 77 | export const FILE_TEXT_PREVIEW_MAX_BYTES = 1024 * 1024; |
| 78 | |
| 79 | /** Reject binary data rather than displaying replacement characters as a document. */ |
| 80 | export 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 | |
| 95 | export const WORKSPACE_BROWSER_PREVIEW_EXTENSIONS = [".htm", ".html", ".pdf"] as const; |
| 96 | |
| 97 | export 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 | |
| 108 | const 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 | |
| 119 | const BROWSER_MIME_TYPE_BY_EXTENSION = new Map([ |
| 120 | [".htm", "text/html"], |
| 121 | [".html", "text/html"], |
| 122 | [".pdf", "application/pdf"], |
| 123 | ]); |
| 124 | |
| 125 | const 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. */ |
| 138 | export 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. */ |
| 144 | export 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. */ |
| 153 | export 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. */ |
| 164 | export 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 | |
| 188 | export 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 | |
| 194 | function hasPreviewExtension(path: string, extensions: ReadonlyArray<string>): boolean { |
| 195 | const literalPath = path.toLowerCase(); |
| 196 | return extensions.some((extension) => literalPath.endsWith(extension)); |
| 197 | } |
| 198 | |
| 199 | export function isWorkspaceBrowserPreviewPath(path: string): boolean { |
| 200 | return hasPreviewExtension(path, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS); |
| 201 | } |
| 202 | |
| 203 | export 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. */ |
| 208 | export function isWorkspaceVideoPreviewPath(path: string): boolean { |
| 209 | return videoMimeType({ name: path, mimeType: "" }) !== null; |
| 210 | } |
| 211 | |
| 212 | export function isWorkspaceAudioPreviewPath(path: string): boolean { |
| 213 | const extensionIndex = path.lastIndexOf("."); |
| 214 | return extensionIndex >= 0 && audioMimeTypeFromExtension(path.slice(extensionIndex)) !== null; |
| 215 | } |
| 216 | |
| 217 | export function isWorkspacePreviewEntryPath(path: string): boolean { |
| 218 | return isWorkspaceBrowserPreviewPath(path) || isWorkspaceImagePreviewPath(path); |
| 219 | } |