| 1 | import { fileBasename } from "@t3tools/shared/path"; |
| 2 | import { formatFilePathPosition } from "@t3tools/shared/fileLinks"; |
| 3 | import { |
| 4 | inlineCodeFilePathCandidate, |
| 5 | normalizeMarkdownLinkDestination, |
| 6 | parseMarkdownFileLink, |
| 7 | } from "@t3tools/shared/markdownLinks"; |
| 8 | import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; |
| 9 | import { videoMimeType } from "@t3tools/shared/video"; |
| 10 | |
| 11 | import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; |
| 12 | |
| 13 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; |
| 14 | |
| 15 | export 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 | |
| 35 | export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES; |
| 36 | |
| 37 | export 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 | */ |
| 43 | export 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 | |
| 49 | const 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 | |
| 155 | const 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. */ |
| 247 | export function normalizeNativeMarkdownUrl(value: string): string { |
| 248 | return value.startsWith("//") ? `https:${value}` : value; |
| 249 | } |
| 250 | |
| 251 | export 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 | |
| 267 | export 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. */ |
| 306 | export 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 | } |