1import { fileBasename } from "@t3tools/shared/path";
2import { formatFilePathPosition } from "@t3tools/shared/fileLinks";
3import {
4 inlineCodeFilePathCandidate,
5 normalizeMarkdownLinkDestination,
6 parseMarkdownFileLink,
7} from "@t3tools/shared/markdownLinks";
8import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
9import { videoMimeType } from "@t3tools/shared/video";
10
11import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
12
13const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
14
15export type MarkdownLinkPresentation =
16 | {
17 readonly kind: "external";
18 readonly href: string;
19 readonly host: string;
20 }
21 | {
22 readonly kind: "file";
23 readonly href: string;
24 readonly icon: MarkdownFileIcon;
25 readonly label: string;
26 readonly path: string;
27 readonly line?: number;
28 readonly column?: number;
29 }
30 | {
31 readonly kind: "link";
32 readonly href: string | null;
33 };
34
35export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES;
36
37export type MarkdownLinkIcon = "github";
38
39/**
40 * Sites whose brand mark replaces the generic external-link glyph. The marks
41 * are monochrome and tinted with the link color, so they follow the theme.
42 */
43export function resolveMarkdownLinkIcon(host: string): MarkdownLinkIcon | null {
44 const hostname = host.toLowerCase();
45 if (hostname === "github.com" || hostname.endsWith(".github.com")) return "github";
46 return null;
47}
48
49const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = {
50 ".babelrc": "babel",
51 ".babelrc.json": "babel",
52 ".bash_profile": "bash",
53 ".bashrc": "bash",
54 ".browserslistrc": "browserslist",
55 ".dockerignore": "docker",
56 ".eslintignore": "eslint",
57 ".eslintrc": "eslint",
58 ".eslintrc.cjs": "eslint",
59 ".eslintrc.js": "eslint",
60 ".eslintrc.json": "eslint",
61 ".eslintrc.yaml": "eslint",
62 ".eslintrc.yml": "eslint",
63 ".gitattributes": "git",
64 ".gitignore": "git",
65 ".gitkeep": "git",
66 ".gitmodules": "git",
67 ".oxlintrc.json": "oxc",
68 ".postcssrc": "postcss",
69 ".postcssrc.json": "postcss",
70 ".postcssrc.yaml": "postcss",
71 ".postcssrc.yml": "postcss",
72 ".prettierignore": "prettier",
73 ".prettierrc": "prettier",
74 ".prettierrc.json": "prettier",
75 ".prettierrc.cjs": "prettier",
76 ".prettierrc.js": "prettier",
77 ".prettierrc.mjs": "prettier",
78 ".prettierrc.toml": "prettier",
79 ".prettierrc.yaml": "prettier",
80 ".prettierrc.yml": "prettier",
81 ".stylelintignore": "stylelint",
82 ".stylelintrc": "stylelint",
83 ".stylelintrc.cjs": "stylelint",
84 ".stylelintrc.js": "stylelint",
85 ".stylelintrc.json": "stylelint",
86 ".stylelintrc.mjs": "stylelint",
87 ".stylelintrc.yaml": "stylelint",
88 ".stylelintrc.yml": "stylelint",
89 ".terraform.lock.hcl": "terraform",
90 ".zprofile": "bash",
91 ".zshenv": "bash",
92 ".zshrc": "bash",
93 "agents.md": "agents",
94 "babel.config.js": "babel",
95 "babel.config.cjs": "babel",
96 "babel.config.json": "babel",
97 "babel.config.mjs": "babel",
98 "biome.json": "biome",
99 "biome.jsonc": "biome",
100 "bun.lock": "bun",
101 "bun.lockb": "bun",
102 "bunfig.toml": "bun",
103 "claude.md": "claude",
104 "compose.yaml": "docker",
105 "compose.yml": "docker",
106 "docker-compose.yaml": "docker",
107 "docker-compose.yml": "docker",
108 "docker-compose.override.yml": "docker",
109 dockerfile: "docker",
110 "eslint.config.js": "eslint",
111 "eslint.config.cjs": "eslint",
112 "eslint.config.mjs": "eslint",
113 "eslint.config.mts": "eslint",
114 "eslint.config.ts": "eslint",
115 gemfile: "ruby",
116 "next.config.js": "nextjs",
117 "next.config.mjs": "nextjs",
118 "next.config.mts": "nextjs",
119 "next.config.ts": "nextjs",
120 "package.json": "npm",
121 "pnpm-lock.yaml": "pnpm",
122 "pnpm-workspace.yaml": "pnpm",
123 "postcss.config.js": "postcss",
124 "postcss.config.cjs": "postcss",
125 "postcss.config.mjs": "postcss",
126 "postcss.config.ts": "postcss",
127 "prettier.config.js": "prettier",
128 "prettier.config.cjs": "prettier",
129 "prettier.config.mjs": "prettier",
130 rakefile: "ruby",
131 "readme.md": "markdown",
132 "stylelint.config.js": "stylelint",
133 "stylelint.config.cjs": "stylelint",
134 "stylelint.config.mjs": "stylelint",
135 "svgo.config.js": "svgo",
136 "svgo.config.cjs": "svgo",
137 "svgo.config.mjs": "svgo",
138 "svgo.config.ts": "svgo",
139 "tailwind.config.js": "tailwind",
140 "tailwind.config.cjs": "tailwind",
141 "tailwind.config.mjs": "tailwind",
142 "tailwind.config.ts": "tailwind",
143 "tsconfig.json": "typescript",
144 "vite.config.js": "vite",
145 "vite.config.mjs": "vite",
146 "vite.config.mts": "vite",
147 "vite.config.ts": "vite",
148 "webpack.config.js": "webpack",
149 "webpack.config.babel.js": "webpack",
150 "webpack.config.cjs": "webpack",
151 "webpack.config.mjs": "webpack",
152 "webpack.config.ts": "webpack",
153};
154
155const FILE_ICON_BY_EXTENSION: Readonly<Record<string, MarkdownFileIcon>> = {
156 "7z": "zip",
157 astro: "astro",
158 avif: "image",
159 "code-workspace": "vscode",
160 bash: "bash",
161 bmp: "image",
162 bz2: "zip",
163 c: "c",
164 cc: "cpp",
165 cpp: "cpp",
166 cxx: "cpp",
167 css: "css",
168 csv: "table",
169 cts: "typescript",
170 db: "database",
171 env: "text",
172 "env.development": "text",
173 "env.local": "text",
174 "env.production": "text",
175 eot: "font",
176 erb: "ruby",
177 fish: "bash",
178 gif: "image",
179 go: "go",
180 gql: "graphql",
181 graphql: "graphql",
182 gz: "zip",
183 h: "c",
184 hh: "cpp",
185 hpp: "cpp",
186 hxx: "cpp",
187 htm: "html",
188 html: "html",
189 ico: "image",
190 icns: "image",
191 ini: "text",
192 inl: "cpp",
193 jar: "zip",
194 jpeg: "image",
195 jpg: "image",
196 js: "javascript",
197 jsx: "react",
198 json: "json",
199 jsonc: "json",
200 less: "css",
201 md: "markdown",
202 mdx: "markdown",
203 "mdx.tsx": "markdown",
204 mjs: "javascript",
205 mts: "typescript",
206 png: "image",
207 postcss: "css",
208 py: "python",
209 pyi: "python",
210 pyw: "python",
211 pyx: "python",
212 rake: "ruby",
213 rar: "zip",
214 rb: "ruby",
215 rs: "rust",
216 sass: "sass",
217 scss: "sass",
218 sh: "bash",
219 sql: "database",
220 sqlite: "database",
221 sqlite3: "database",
222 svelte: "svelte",
223 svg: "svg",
224 swift: "swift",
225 tar: "zip",
226 tf: "terraform",
227 tfstate: "terraform",
228 tfvars: "terraform",
229 tgz: "zip",
230 ts: "typescript",
231 tsv: "table",
232 tsx: "react",
233 txt: "text",
234 woff: "font",
235 woff2: "font",
236 vue: "vue",
237 wasm: "wasm",
238 webp: "image",
239 yml: "yml",
240 yaml: "yml",
241 zig: "zig",
242 zip: "zip",
243 zsh: "bash",
244};
245
246/** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */
247export function normalizeNativeMarkdownUrl(value: string): string {
248 return value.startsWith("//") ? `https:${value}` : value;
249}
250
251export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {
252 const basename = fileBasename(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase();
253 if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video";
254 const exactIcon = FILE_ICON_BY_NAME[basename];
255 if (exactIcon) return exactIcon;
256 if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) {
257 return "typescript";
258 }
259 const segments = basename.split(".");
260 for (let index = 1; index < segments.length; index += 1) {
261 const icon = FILE_ICON_BY_EXTENSION[segments.slice(index).join(".")];
262 if (icon) return icon;
263 }
264 return "default";
265}
266
267export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation {
268 const normalized = normalizeMarkdownLinkDestination(href);
269 try {
270 const parsed = new URL(normalizeNativeMarkdownUrl(normalized));
271 if (parsed.protocol === "http:" || parsed.protocol === "https:") {
272 return {
273 kind: "external",
274 href: parsed.toString(),
275 host: parsed.hostname,
276 };
277 }
278 } catch {
279 // Relative paths and non-URL link destinations are handled below.
280 }
281
282 const target = parseMarkdownFileLink(normalized);
283 if (target) {
284 return {
285 kind: "file",
286 href: normalized,
287 icon: resolveMarkdownFileIcon(target.path),
288 label: fileBasename(formatFilePathPosition(target)),
289 path: target.path,
290 ...(target.line ? { line: target.line } : {}),
291 ...(target.column ? { column: target.column } : {}),
292 };
293 }
294
295 return {
296 kind: "link",
297 // A thread link keeps its href so a press reaches the feed, which opens the thread.
298 href:
299 /^(?:mailto|tel):/i.test(normalized) || parseThreadLinkHref(normalized) !== null
300 ? normalized
301 : null,
302 };
303}
304
305/** Backticks become file references only when the shared path heuristic recognizes the whole span. */
306export function resolveMarkdownInlineCodePresentation(
307 content: string,
308): Extract<MarkdownLinkPresentation, { readonly kind: "file" }> | null {
309 const candidate = inlineCodeFilePathCandidate(content);
310 if (candidate === null) return null;
311 const presentation = resolveMarkdownLinkPresentation(candidate);
312 return presentation.kind === "file" ? presentation : null;
313}