Skip to content
Source
Sign inSign up

filePreview.ts

242 lines8.4 KB

1import {
2 audioMimeTypeFromExtension,
3 isWorkspaceImagePreviewPath,
4 mediaMimeTypeFromExtension,
5} from "./mediaPreview.ts";
6export {
7 isWorkspaceAudioPreviewPath,
8 isWorkspaceImagePreviewPath,
9 mediaKindFromPath,
10 mediaMimeTypeFromExtension,
11} from "./mediaPreview.ts";
12import { GENERIC_MIME_TYPES } from "./image.ts";
13import { videoMimeType } from "./video.ts";
14​
15export type FilePreviewKind =
16 | "image"
17 | "video"
18 | "audio"
19 | "pdf"
20 | "html"
21 | "markdown"
22 | "mermaid"
23 | "font"
24 | "archive"
25 | "sqlite"
26 | "docx"
27 | "xlsx"
28 | "pptx"
29 | "odt"
30 | "ods"
31 | "odp"
32 | "rtf"
33 | "epub"
34 | "ipynb"
35 | "text"
36 | "unsupported";
37​
38export function filePreviewKind(
39 file: { readonly name: string; readonly mimeType?: string },
40 options?: { readonly richDocuments?: boolean },
41): FilePreviewKind {
42 const mime = file.mimeType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
43 const name = file.name.toLowerCase();
44 const extension = name.slice(name.lastIndexOf("."));
45 const generic = !mime || GENERIC_MIME_TYPES.has(mime) || mime === "text/plain";
46 const richDocuments = options?.richDocuments !== false;
47 const office = richDocuments ? officePreviewKind(file) : null;
48 if (office) return office;
49 const binary = binaryPreviewKind(file);
50 if (binary) return binary;
51 if (mime === "application/pdf") return "pdf";
52 if (mime === "text/html") return "html";
53 if (mime === "text/markdown" || mime === "text/x-markdown") return "markdown";
54 if (richDocuments && mime === "text/vnd.mermaid") return "mermaid";
55 if (richDocuments && mime.startsWith("font/")) return "font";
56 // Linux's MIME database also assigns .otf to OpenDocument formula templates.
57 // FontFace validates the actual bytes before a specimen is shown.
58 if (
59 richDocuments &&
60 extension === ".otf" &&
61 mime === "application/vnd.oasis.opendocument.formula-template"
62 )
63 return "font";
64 if (mime.startsWith("image/")) return "image";
65 if (mime.startsWith("video/") && !generic) return "video";
66 if (mime.startsWith("audio/")) return "audio";
67 if (generic) {
68 if (extension === ".pdf") return "pdf";
69 if (/^\.html?$/.test(extension)) return "html";
70 if (/^\.(md|markdown|mdown|mkd|mdx)$/.test(extension)) return "markdown";
71 if (richDocuments && /^\.(mmd|mermaid)$/.test(extension)) return "mermaid";
72 if (richDocuments && isWorkspaceFontPreviewPath(name)) return "font";
73 if (richDocuments && extension === ".rtf") return "text";
74 const media = mediaMimeTypeFromExtension(extension);
75 if (media?.startsWith("image/")) return "image";
76 if (media?.startsWith("video/")) return "video";
77 if (audioMimeTypeFromExtension(extension) !== null) return "audio";
78 if (
79 /^\.(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(
80 extension,
81 ) ||
82 /^(dockerfile|makefile|gemfile|rakefile|license|readme|\.gitignore|\.gitattributes|\.editorconfig|\.env)(\.|$)/i.test(
83 name.split(/[\\/]/).at(-1) ?? "",
84 )
85 )
86 return "text";
87 }
88 if (
89 mime.startsWith("text/") ||
90 /^(application\/(json|.*\+json|xml|.*\+xml|javascript|x-javascript|yaml|x-yaml|toml|sql))$/.test(
91 mime,
92 )
93 )
94 return "text";
95 return "unsupported";
97​
98const BINARY_MIME_TYPES = new Map([
99 [".zip", "application/zip"],
100 [".jar", "application/java-archive"],
101 [".tar", "application/x-tar"],
102 [".gz", "application/gzip"],
103 [".tgz", "application/gzip"],
104 [".sqlite", "application/vnd.sqlite3"],
105 [".sqlite3", "application/vnd.sqlite3"],
106 [".db", "application/vnd.sqlite3"],
107]);
108​
109export function binaryPreviewKind(file: {
110 readonly name: string;
111 readonly mimeType?: string;
112}): "archive" | "sqlite" | null {
113 const mime = file.mimeType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
114 if (["application/vnd.sqlite3", "application/x-sqlite3", "application/x-sqlite"].includes(mime))
115 return "sqlite";
116 if (
117 [
118 "application/zip",
119 "application/x-zip-compressed",
120 "application/java-archive",
121 "application/x-java-archive",
122 "application/x-tar",
123 "application/gzip",
124 "application/x-gzip",
125 "application/x-compressed-tar",
126 ].includes(mime)
127 )
128 return "archive";
129 if (mime && !GENERIC_MIME_TYPES.has(mime) && mime !== "text/plain") return null;
130 const extension = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
131 const inferred = BINARY_MIME_TYPES.get(extension);
132 return inferred === "application/vnd.sqlite3" ? "sqlite" : inferred ? "archive" : null;
134​
135export const FILE_TEXT_PREVIEW_MAX_BYTES = 1024 * 1024;
136​
137const OFFICE_MIME_TYPES = {
138 docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
139 xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
140 pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
141 odt: "application/vnd.oasis.opendocument.text",
142 ods: "application/vnd.oasis.opendocument.spreadsheet",
143 odp: "application/vnd.oasis.opendocument.presentation",
144 rtf: "application/rtf",
145 epub: "application/epub+zip",
146 ipynb: "application/x-ipynb+json",
147} as const;
148​
149export type OfficePreviewKind = keyof typeof OFFICE_MIME_TYPES;
150​
151export function officePreviewKind(file: {
152 readonly name: string;
153 readonly mimeType?: string;
154}): OfficePreviewKind | null {
155 const mime = file.mimeType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
156 if (mime === "text/rtf") return "rtf";
157 for (const kind of Object.keys(OFFICE_MIME_TYPES) as OfficePreviewKind[]) {
158 if (mime === OFFICE_MIME_TYPES[kind]) return kind;
159 if (
160 (!mime ||
161 GENERIC_MIME_TYPES.has(mime) ||
162 mime === "text/plain" ||
163 (kind === "ipynb" && mime === "application/json")) &&
164 file.name.toLowerCase().endsWith(`.${kind}`)
165 )
166 return kind;
167 }
168 return null;
170​
171export function decodeFilePreviewText(bytes: Uint8Array, truncated = false) {
172 const bounded = bytes.subarray(0, FILE_TEXT_PREVIEW_MAX_BYTES);
173 if (bounded.some((byte) => byte === 0))
174 throw new Error("This file contains binary data and cannot be shown as text.");
175 const decoder = new TextDecoder("utf-8", { fatal: true });
176 try {
177 return {
178 text: decoder.decode(bounded, { stream: truncated || bytes.length > bounded.length }),
179 truncated: truncated || bytes.length > bounded.length,
180 };
181 } catch {
182 throw new Error("This file is not UTF-8 text. Open it in another app to view its contents.");
183 }
185​
186export const WORKSPACE_BROWSER_PREVIEW_EXTENSIONS = [".htm", ".html", ".pdf"] as const;
187​
188const BROWSER_MIME_TYPE_BY_EXTENSION = new Map([
189 [".htm", "text/html"],
190 [".html", "text/html"],
191 [".pdf", "application/pdf"],
192]);
193​
194const FONT_MIME_TYPE_BY_EXTENSION = new Map([
195 [".ttf", "font/ttf"],
196 [".otf", "font/otf"],
197 [".woff", "font/woff"],
198 [".woff2", "font/woff2"],
199]);
200​
201export function isWorkspaceFontPreviewPath(path: string): boolean {
202 return /\.(ttf|otf|woff2?)$/i.test(path);
204​
205export function hostPreviewMimeTypeFromExtension(extension: string): string | null {
206 if (!/^\.[a-z0-9-]+$/i.test(extension)) return null;
207 return (
208 mediaMimeTypeFromExtension(extension) ??
209 audioMimeTypeFromExtension(extension) ??
210 BROWSER_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ??
211 FONT_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ??
212 BINARY_MIME_TYPES.get(extension.toLowerCase()) ??
213 (Object.hasOwn(OFFICE_MIME_TYPES, extension.toLowerCase().slice(1))
214 ? OFFICE_MIME_TYPES[extension.toLowerCase().slice(1) as OfficePreviewKind]
215 : null) ??
216 null
217 );
219​
220function hasPreviewExtension(path: string, extensions: ReadonlyArray<string>): boolean {
221 const literalPath = path.toLowerCase();
222 return extensions.some((extension) => literalPath.endsWith(extension));
224​
225export function isWorkspaceBrowserPreviewPath(path: string): boolean {
226 return hasPreviewExtension(path, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS);
228​
229/** File viewers receive literal filesystem paths, not Markdown URLs. */
230export function isWorkspaceVideoPreviewPath(path: string): boolean {
231 return videoMimeType({ name: path, mimeType: "" }) !== null;
233​
234export function isWorkspacePreviewEntryPath(path: string): boolean {
235 return (
236 isWorkspaceBrowserPreviewPath(path) ||
237 isWorkspaceImagePreviewPath(path) ||
238 isWorkspaceFontPreviewPath(path) ||
239 officePreviewKind({ name: path }) !== null ||
240 binaryPreviewKind({ name: path }) !== null
241 );