1import { isWindowsAbsolutePath } from "@t3tools/shared/path";
2
3import { safeDecodeURIComponent } from "@t3tools/shared/markdownLinks";
4
5/** The authored media location, never the temporary URL used to load its bytes. */
6export type MediaReference =
7 | {
8 readonly kind: "file";
9 readonly path: string;
10 readonly relativePath?: string;
11 }
12 | { readonly kind: "url"; readonly url: string };
13
14function absolutePathParts(path: string) {
15 const windows = isWindowsAbsolutePath(path) || path.startsWith("//");
16 const normalized = windows ? path.replaceAll("\\", "/") : path;
17 const prefix = windows
18 ? /^(?:[a-z]:\/|\/\/[^/]+\/[^/]+(?:\/|$))/i.exec(normalized)?.[0]
19 : normalized.startsWith("/")
20 ? "/"
21 : undefined;
22 if (!prefix) return undefined;
23
24 const segments: string[] = [];
25 for (const segment of normalized.slice(prefix.length).split("/")) {
26 if (!segment || segment === ".") continue;
27 if (segment === "..") segments.pop();
28 else segments.push(segment);
29 }
30 const root = prefix.replace(/\/$/, "");
31 return { root: windows ? root.toLowerCase() : root, segments, windows };
32}
33
34/** Compares paths lexically for the copy menu; it does not resolve filesystem symlinks. */
35export function mediaFileReference(
36 path: string,
37 workspaceRoot?: string | null,
38): Extract<MediaReference, { kind: "file" }> {
39 const target = absolutePathParts(path);
40 const workspace = workspaceRoot ? absolutePathParts(workspaceRoot) : undefined;
41 if (
42 !target ||
43 !workspace ||
44 target.windows !== workspace.windows ||
45 target.root !== workspace.root ||
46 target.segments.length <= workspace.segments.length ||
47 !workspace.segments.every((segment, index) =>
48 workspace.windows
49 ? segment.toLowerCase() === target.segments[index]?.toLowerCase()
50 : segment === target.segments[index],
51 )
52 ) {
53 return { kind: "file", path };
54 }
55 return {
56 kind: "file",
57 path,
58 relativePath: target.segments.slice(workspace.segments.length).join("/"),
59 };
60}
61
62/** Pass the authored source, not a generated URL used by the media player. */
63export function mediaUrlReference(
64 url: string,
65): Extract<MediaReference, { kind: "url" }> | undefined {
66 if (!/^(?:https?:\/\/|\/\/)/i.test(url)) return undefined;
67 try {
68 const parsed = new URL(url.startsWith("//") ? `https:${url}` : url);
69 return parsed.protocol === "http:" || parsed.protocol === "https:"
70 ? { kind: "url", url }
71 : undefined;
72 } catch {
73 return undefined;
74 }
75}
76
77/** Local paths are already decoded; URL filename escapes are decoded exactly once. */
78export function mediaReferenceFileName(reference: MediaReference): string | undefined {
79 if (reference.kind === "file") {
80 const windows = isWindowsAbsolutePath(reference.path) || reference.path.startsWith("//");
81 return reference.path.split(windows ? /[\\/]/ : "/").at(-1) || undefined;
82 }
83
84 let basename: string | undefined;
85 try {
86 const url = reference.url;
87 basename = new URL(url.startsWith("//") ? `https:${url}` : url).pathname.split("/").at(-1);
88 } catch {
89 return undefined;
90 }
91 return basename ? safeDecodeURIComponent(basename) : undefined;
92}