1import type { AssetResource, ThreadId } from "@t3tools/contracts";
2import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
3
4import {
5 classifyMarkdownImageSource,
6 markdownImageSourceFragment,
7 type MarkdownImageSource,
8} from "./markdownImages.ts";
9import { fileBasename } from "@t3tools/shared/path";
10import { splitFilePathPosition } from "@t3tools/shared/fileLinks";
11import { splitMarkdownLinkSearchAndHash } from "@t3tools/shared/markdownLinks";
12import {
13 mediaFileReference,
14 mediaReferenceFileName,
15 mediaUrlReference,
16 type MediaReference,
17} from "./mediaReference.ts";
18
19export type MediaSourceResource = Extract<AssetResource, { readonly _tag: "media-file" }>;
20
21/** What a piece of authored media is and how its bytes can be reached. */
22export type ResolvedMediaSource = {
23 readonly kind: "image" | "video";
24 readonly mimeType: string;
25 readonly name: string;
26 readonly reference?: MediaReference;
27 readonly srcFragment: string;
28} & (
29 | {
30 readonly access: "direct";
31 readonly uri: string;
32 }
33 | {
34 readonly access: "environment";
35 readonly resource: MediaSourceResource;
36 }
37 | {
38 readonly access: "unavailable";
39 }
40);
41
42export interface ResolveMediaSourceInput {
43 readonly threadId: ThreadId | undefined;
44 readonly workspaceRoot?: string | null | undefined;
45 readonly resolvedFilePath?: string | undefined;
46 /** Image syntax can target an endpoint without a recognizable extension. */
47 readonly imageEmbed?: boolean | undefined;
48}
49
50function classify(source: string, input: ResolveMediaSourceInput): MarkdownImageSource {
51 return input.resolvedFilePath === undefined
52 ? classifyMarkdownImageSource(source, input.workspaceRoot)
53 : { _tag: "WorkspaceFile", path: input.resolvedFilePath };
54}
55
56export function resolveMediaSource(
57 source: string,
58 input: ResolveMediaSourceInput,
59): ResolvedMediaSource | null {
60 const classified = classify(source, input);
61 if (classified._tag === "Blocked") return null;
62
63 const path =
64 classified._tag === "Direct"
65 ? splitMarkdownLinkSearchAndHash(classified.uri).path
66 : splitFilePathPosition(classified.path).path;
67 // Local paths have already been decoded. Do not interpret literal #, ?, or % characters again.
68 const basename = fileBasename(path);
69 const extensionIndex = basename.lastIndexOf(".");
70 const detectedMimeType =
71 classified._tag === "Direct"
72 ? mediaMimeType(classified.uri)
73 : extensionIndex < 0
74 ? null
75 : mediaMimeTypeFromExtension(basename.slice(extensionIndex));
76 const mimeType = detectedMimeType ?? (input.imageEmbed ? "image/*" : null);
77 if (mimeType === null) return null;
78 const kind = mimeType.startsWith("video/") ? "video" : "image";
79
80 const reference =
81 classified._tag === "Direct"
82 ? mediaUrlReference(classified.uri)
83 : mediaFileReference(path, input.workspaceRoot);
84 const name = (reference && mediaReferenceFileName(reference)) || basename || kind;
85 const common = {
86 kind,
87 mimeType,
88 name,
89 ...(reference ? { reference } : {}),
90 srcFragment: markdownImageSourceFragment(source),
91 } as const;
92
93 if (classified._tag === "Direct") {
94 return { ...common, access: "direct", uri: classified.uri };
95 }
96 if (input.threadId === undefined) return { ...common, access: "unavailable" };
97 return {
98 ...common,
99 access: "environment",
100 resource: { _tag: "media-file", threadId: input.threadId, path },
101 };
102}