Skip to content
Source
Sign inSign up

markdownLinks.ts

297 lines7.9 KB

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