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