| 1 | /** Presentation helpers shared by the pages. */ |
| 2 | |
| 3 | export const timeAgo = (epochMs: number): string => { |
| 4 | const s = Math.max(0, Math.floor((Date.now() - epochMs) / 1000)); |
| 5 | if (s < 60) return "just now"; |
| 6 | const m = Math.floor(s / 60); |
| 7 | if (m < 60) return `${m} minute${m === 1 ? "" : "s"} ago`; |
| 8 | const h = Math.floor(m / 60); |
| 9 | if (h < 24) return `${h} hour${h === 1 ? "" : "s"} ago`; |
| 10 | const d = Math.floor(h / 24); |
| 11 | if (d < 30) return `${d} day${d === 1 ? "" : "s"} ago`; |
| 12 | const mo = Math.floor(d / 30); |
| 13 | if (mo < 12) return `${mo} month${mo === 1 ? "" : "s"} ago`; |
| 14 | const y = Math.floor(mo / 12); |
| 15 | return `${y} year${y === 1 ? "" : "s"} ago`; |
| 16 | }; |
| 17 | |
| 18 | export const formatBytes = (bytes: number): string => { |
| 19 | if (bytes < 1024) return `${bytes} B`; |
| 20 | const units = ["KiB", "MiB", "GiB", "TiB"]; |
| 21 | let value = bytes / 1024; |
| 22 | let unit = 0; |
| 23 | while (value >= 1024 && unit < units.length - 1) { |
| 24 | value /= 1024; |
| 25 | unit++; |
| 26 | } |
| 27 | return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[unit]}`; |
| 28 | }; |
| 29 | |
| 30 | export const shortOid = (oid: string): string => oid.slice(0, 7); |
| 31 | |
| 32 | /** First line of a commit message. */ |
| 33 | export const subject = (message: string): string => |
| 34 | message.split("\n", 1)[0] ?? ""; |
| 35 | |
| 36 | const decoder = new TextDecoder("utf-8", { fatal: true }); |
| 37 | |
| 38 | /** Decodes bytes as UTF-8, or `null` if the content is binary. */ |
| 39 | export const decodeText = (bytes: Uint8Array): string | null => { |
| 40 | // Quick binary sniff: NUL byte in the first 8 KiB. |
| 41 | const head = bytes.subarray(0, 8192); |
| 42 | for (const byte of head) if (byte === 0) return null; |
| 43 | try { |
| 44 | return decoder.decode(bytes); |
| 45 | } catch { |
| 46 | return null; |
| 47 | } |
| 48 | }; |
| 49 | |
| 50 | const IMAGE_TYPES: Record<string, string> = { |
| 51 | png: "image/png", |
| 52 | jpg: "image/jpeg", |
| 53 | jpeg: "image/jpeg", |
| 54 | gif: "image/gif", |
| 55 | svg: "image/svg+xml", |
| 56 | webp: "image/webp", |
| 57 | ico: "image/x-icon", |
| 58 | }; |
| 59 | |
| 60 | export const imageMime = (path: string): string | undefined => |
| 61 | IMAGE_TYPES[path.split(".").pop()?.toLowerCase() ?? ""]; |
| 62 | |
| 63 | export const isMarkdown = (path: string): boolean => |
| 64 | /\.(md|markdown)$/i.test(path); |