// How the pages put things into words. const encoder = new TextEncoder(); /** A path for a URL: each segment percent-encoded, the slashes kept. */ export function urlPath(path: string): string { let out = ''; for (const b of encoder.encode(path)) { const c = String.fromCharCode(b); out += /[A-Za-z0-9\-._~/:@]/.test(c) ? c : `%${b.toString(16).toUpperCase().padStart(2, '0')}`; } return out; } /** The page of `owner`'s repository `name`, and what follows. */ export const repoHref = (owner: string, name: string, tail = '') => `/${urlPath(owner)}/${urlPath(name)}${tail}`; const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; /** `2026-10-20T…` as `Oct 20, 2026`; anything else as it is. */ export function date(iso: string): string { const [y, m, d] = [iso.slice(0, 4), iso.slice(5, 7), iso.slice(8, 10)]; const month = MONTHS[Number(m) - 1]; const day = Number(d); if (y.length < 4 || !month || !/^\d\d$/.test(m) || !/^\d+$/.test(d)) return iso; return `${month} ${day}, ${y}`; } const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; const LONG_MONTHS = [ 'january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december' ]; /** `2026-10-09` as `friday, october 9`. */ export function weekday(iso: string): string { const d = new Date(`${iso.slice(0, 10)}T00:00:00Z`); if (isNaN(+d)) return iso; return `${WEEKDAYS[d.getUTCDay()]}, ${LONG_MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`; } /** A commit message's first line. */ export const subject = (message: string) => message.split('\n')[0]; export function size(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } export const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; export const capitalized = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); /** A branch or tag as it is called, without the `refs/…/` before it. */ export const shortRef = (ref: string) => ref.replace(/^refs\/(heads|tags)\//, ''); /** A hue from 0 to 359 that `name` picks, the same each time. */ export function hue(name: string): number { let h = 7; for (const b of encoder.encode(name)) h = (Math.imul(h, 31) + b) >>> 0; return h % 360; } /** The first letter or digit of `name`. */ export const initial = (name: string) => /[\p{L}\p{N}]/u.exec(name)?.[0] ?? '?'; /** The first paragraph of Markdown, as plain text, cut short. */ export function excerpt(text: string, max: number): string { const plain = text.split('\n\n')[0].replaceAll('\n', ' ').replace(/[`*]/g, ''); const chars = [...plain]; return chars.length > max ? `${chars.slice(0, max).join('').trimEnd()}…` : plain; } /** The page of the pull or issue at `uri`, on the repository at `repo`. */ export function itemHref(repo: string, uri: string): string { const [, , did, , rkey] = uri.split('/'); return `${repo}/${kindOf(uri)}/${did}/${rkey}`; } /** Commits a push is walked for: a push that brought this many brought at least as many. */ const WALKED = 100; /** How many commits a push brought, in words. */ export function commits(n: number): string { if (n === 0) return 'nothing new'; if (n === 1) return '1 commit'; return n >= WALKED ? `${WALKED}+ commits` : `${n} commits`; } /** What a comment with `verdict` did. */ export function said(verdict?: string | null): string { if (verdict === 'approve') return 'approved'; if (verdict === 'changes') return 'asked for changes on'; return 'commented on'; } /** What moving to `state` did. */ export const changed = (state: string) => (state === 'open' ? 'reopened' : state); /** `pull` or `issue`, for the record at `uri`. */ export const kindOf = (uri: string) => (uri.split('/')[3] === 'dev.knotless.pull' ? 'pull' : 'issue');